Search code examples
jsonscalaserializationplayframeworkimplicit

Null serialization in Scala Play (implicit Writes)


I'm experimenting with Scala Play and don't get why Null json Serialization doesn't work out of the box. I wrote a simple class to encapsulate data in response (ServiceResponse) which return a parametrical nullable field data and also made a Writes[Null] implicit, what am I missing? The compiler suggests to write a Writes[ServiceResponse[Null]] which works, but feels cumbersome and I wonder if there is a more concise way of solving the problem. Below is the code and error.


// <!-- EDIT -->

// The data I want to transfer

case class Person (name: String, age: Int)

object Person {

  def apply(name: String, age: Int): Person = new Person(name, age)

  implicit val reader: Reads[Person] = Json.reads[Person]
  implicit val writer: OWrites[Person] = Json.writes[Person]

}


// <!-- END EDIT -->

case class ServiceResponse[T] (data: T, errorMessage: String, debugMessage: String)

object ServiceResponse {

  def apply[T](data: T, errorMessage: String, debugMessage: String): ServiceResponse[T] =
    new ServiceResponse(data, errorMessage, debugMessage)

  implicit def NullWriter: Writes[Null] = (_: Null) => JsNull

  implicit def writer[T](implicit fmt: Writes[T]) = Json.writes[ServiceResponse[T]]

}

// <!-- EDIT -->

// Given the above code I would expect
// the call ` Json.toJson(ServiceResponse(null, "Error in deserializing json", null))`
// to produce the following Json: `{ "data": null, "errorMessage": "Error in deserializing json", "debugMessage": null }`


No Json serializer found for type models.ServiceResponse[Null]. Try to implement an implicit Writes or Format for this type.

In C:\...\EchoController.scala:19
15    val wrapAndEcho = Action(parse.json) {
16      request =>
17        request.body.validate[Person] match {
18          case JsSuccess(p, _) => Ok(Json.toJson(echoService.wrapEcho(p)))
19          case JsError(errors) => BadRequest(Json.toJson(ServiceResponse(null, 
20            "Error in deserializing json", null)))
21        }
22    }


EDIT

Tried to use Option instead (a more Scala-ish solution, indeed) but the structure of the code is the same and reports the same error


object ServiceResponse {

  def apply[T](data: Option[T], errorMessage: Option[String], debugMessage: Option[String]): ServiceResponse[T] =
    new ServiceResponse(data, errorMessage, debugMessage)

  implicit def optionWriter[T](implicit fmt: Writes[T]) = new Writes[Option[T]] {
    override def writes(o: Option[T]): JsValue = o match {
      case Some(value) => fmt.writes(value)
      case None => JsNull
    }
  }

  implicit def writer[T](implicit fmt: Writes[Option[T]]) = Json.writes[ServiceResponse[T]]

}

I think I need some structural change, but I can't figure what. Also tried changing the type of fmt to Writes[T] (being T the only real variable type) and got a new Error.

diverging implicit expansion for type play.api.libs.json.Writes[T1]
[error] starting with object StringWrites in trait DefaultWrites
[error]           case JsError(errors) => BadRequest(Json.toJson(ServiceResponse(None,
...

Thought I found a reasonable solution, but still getting error in the expansion of the implicit :( . I really need a hint.

object ServiceResponse {

  def apply[T](data: Option[T], errorMessage: Option[String], debugMessage: Option[String]): ServiceResponse[T] =
    new ServiceResponse(data, errorMessage, debugMessage)

  implicit def writer[T](implicit fmt: Writes[T]): OWrites[ServiceResponse[T]] = (o: ServiceResponse[T]) => JsObject(
    Seq(
      ("data", o.data.map(fmt.writes).getOrElse(JsNull)),
      ("errorMessage", o.errorMessage.map(JsString).getOrElse(JsNull)),
      ("debugMessage", o.debugMessage.map(JsString).getOrElse(JsNull))
    )
  )
}

NOTE I think I got the problem. Since null is a possible value of an instance of Person I was expecting it to be handled by Writes[Person]. The fact is that Writes is defined as invariant in its type paramater (it's trait Writes[A] not trait Writes[+A]) so Writes[Null] does not match the definition of the implicit parameter as would happen if it was defined as Writes[+A] (Which in turns would be wrong violating Liskow substitution principle; it could have been Writes[-A], but this would have not solved the problem either, as we are trying to use the subtype Null of Person). Summing up: there is no shorter way to handle a ServiceResponse with a null data field than writing a specific implementation of Writes[ServiceResponse[Null]], which is neither a super nor a sub type of Write[ServiceResponse[Person]]. (An approach could be a union type, but I think it's an overkill). 90% sure of my reasoning, correct me if I'm wrong :)


Solution

  • To explain it better, the ServiceResponse case class takes a type parameter T, which can be anything. And play-json can only provide JSON formats for standard scala types, for custom types you need to define the JSON formatter.

    import play.api.libs.json._
    
    case class ServiceResponse[T](
        data: T,
        errorMessage: Option[String],
        debugMessage: Option[String]
    )
    
    def format[T](implicit format: Format[T]) = Json.format[ServiceResponse[T]]
    
    implicit val formatString = format[String]
    
    val serviceResponseStr = ServiceResponse[String]("Hello", None, None)
    
    val serviceResponseJsValue = Json.toJson(serviceResponseStr)
    
    val fromString =
      Json.fromJson[ServiceResponse[String]](serviceResponseJsValue)
    
    serviceResponseJsValue
    fromString
    
    serviceResponseJsValue.toString
    
    Json.parse(serviceResponseJsValue.toString).as[ServiceResponse[String]]
    

    In the above example, you can see that I wanted to create a ServiceResponse with data being a string, so I implement a format string which's necessary for Json.toJson, as well as Json.fromJson to have the readers and writers implemented for the type T. Since T being String and is a standard type, play-json by default is resolving the same.

    I have added the scastie snippet, which will help you understand the same better, and you can play around with the same.

    <script src="https://scastie.scala-lang.org/shankarshastri/spqJ1FQLS7ym1vm1ugDFEA/8.js"></script>

    The above explanation suffices a use-case wherein in case of None, the key won't even be present as part of the json, but the question clearly calls out for having key: null, in case if data is not found.

    import play.api.libs.json._
    
    implicit val config =
      JsonConfiguration(optionHandlers = OptionHandlers.WritesNull)
    
    case class ServiceResponse[T](
        data: Option[T],
        errorMessage: Option[String],
        debugMessage: Option[String]
    )
    
    def format[T](implicit format: Format[T]) = Json.format[ServiceResponse[T]]
    
    implicit val formatString = format[String]
    
    val serviceResponseStr = ServiceResponse[String](None, None, None)
    
    val serviceResponseJsValue = Json.toJson(serviceResponseStr)
    
    val fromString =
      Json.fromJson[ServiceResponse[String]](serviceResponseJsValue)
    
    serviceResponseJsValue
    fromString
    
    serviceResponseJsValue.toString
    
    Json.parse(serviceResponseJsValue.toString).as[ServiceResponse[String]]
    

    Bringing the below line in the scope, will ensure to write nulls for optional.

    implicit val config =
      JsonConfiguration(optionHandlers = OptionHandlers.WritesNull)
    

    <script src="https://scastie.scala-lang.org/shankarshastri/rCUmEqXLTeuGqRNG6PPLpQ/6.js"></script>