Defaults for missing properties in play 2 JSON for

2019-01-06 12:17发布

I have an equivalent of the following model in play scala :

case class Foo(id:Int,value:String)
object Foo{
  import play.api.libs.json.Json
  implicit val fooFormats = Json.format[Foo]
}

For the following Foo instance

Foo(1, "foo")

I would get the following JSON document:

{"id":1, "value": "foo"}

This JSON is persisted and read from a datastore. Now my requirements have changed and I need to add a property to Foo. The property has a default value :

case class Foo(id:String,value:String, status:String="pending")

Writing to JSON is not a problem :

{"id":1, "value": "foo", "status":"pending"}

Reading from it however yields a JsError for missing the "/status" path.

How can I provide a default with the least possible noise ?

(ps: I have an answer which I will post below but I am not really satisfied with it and would upvote and accept any better option)

7条回答
男人必须洒脱
2楼-- · 2019-01-06 12:55

An alternative solution is to use formatNullable[T] combined with inmap from InvariantFunctor.

import play.api.libs.functional.syntax._
import play.api.libs.json._

implicit val fooFormats = 
  ((__ \ "id").format[Int] ~
   (__ \ "value").format[String] ~
   (__ \ "status").formatNullable[String].inmap[String](_.getOrElse("pending"), Some(_))
  )(Foo.apply, unlift(Foo.unapply))
查看更多
霸刀☆藐视天下
3楼-- · 2019-01-06 13:00

The cleanest approach that I've found is to use "or pure", e.g.,

...      
((JsPath \ "notes").read[String] or Reads.pure("")) and
((JsPath \ "title").read[String] or Reads.pure("")) and
...

This can be used in the normal implicit way when the default is a constant. When it's dynamic, then you need to write a method to create the Reads, and then introduce it in-scope, a la

implicit val packageReader = makeJsonReads(jobId, url)
查看更多
何必那么认真
4楼-- · 2019-01-06 13:01

This probably won't satisfy the "least possible noise" requirement, but why not introduce the new parameter as an Option[String]?

case class Foo(id:String,value:String, status:Option[String] = Some("pending"))

When reading a Foo from an old client, you'll get a None, which I'd then handle (with a getOrElse) in your consumer code.

Or, if you don't like this, introduce an BackwardsCompatibleFoo:

case class BackwardsCompatibleFoo(id:String,value:String, status:Option[String] = "pending")
case class Foo(id:String,value:String, status: String = "pending")

and then turn that one into a Foo to work with further on, avoiding to have to deal with this kind of data gymnastics all along in the code.

查看更多
做自己的国王
5楼-- · 2019-01-06 13:01

You may define status as an Option

case class Foo(id:String, value:String, status: Option[String])

use JsPath like so:

(JsPath \ "gender").readNullable[String]
查看更多
不美不萌又怎样
6楼-- · 2019-01-06 13:03

Play 2.6

As per @CanardMoussant's answer, starting with Play 2.6 the play-json macro has been improved and proposes multiple new features including using the default values as placeholders when deserializing :

implicit def jsonFormat = Json.using[Json.WithDefaultValues].format[Foo]

For play below 2.6 the best option remains using one of the options below :

play-json-extra

I found out about a much better solution to most of the shortcomings I had with play-json including the one in the question:

play-json-extra which uses [play-json-extensions] internally to solve the particular issue in this question.

It includes a macro which will automatically include the missing defaults in the serializer/deserializer, making refactors much less error prone !

import play.json.extra.Jsonx
implicit def jsonFormat = Jsonx.formatCaseClass[Foo]

there is more to the library you may want to check: play-json-extra

Json transformers

My current solution is to create a JSON Transformer and combine it with the Reads generated by the macro. The transformer is generated by the following method:

object JsonExtensions{
  def withDefault[A](key:String, default:A)(implicit writes:Writes[A]) = __.json.update((__ \ key).json.copyFrom((__ \ key).json.pick orElse Reads.pure(Json.toJson(default))))
}

The format definition then becomes :

implicit val fooformats: Format[Foo] = new Format[Foo]{
  import JsonExtensions._
  val base = Json.format[Foo]
  def reads(json: JsValue): JsResult[Foo] = base.compose(withDefault("status","bidon")).reads(json)
  def writes(o: Foo): JsValue = base.writes(o)
}

and

Json.parse("""{"id":"1", "value":"foo"}""").validate[Foo]

will indeed generate an instance of Foo with the default value applied.

This has 2 major flaws in my opinion:

  • The defaulter key name is in a string and won't get picked up by a refactoring
  • The value of the default is duplicated and if changed at one place will need to be changed manually at the other
查看更多
叼着烟拽天下
7楼-- · 2019-01-06 13:03

I think the official answer should now be to use the WithDefaultValues coming along Play Json 2.6:

implicit def jsonFormat = Json.using[Json.WithDefaultValues].format[Foo]

Edit:

It is important to note that the behavior differs from the play-json-extra library. For instance if you have a DateTime parameter that has a default value to DateTime.Now, then you will now get the startup time of the process - probably not what you want - whereas with play-json-extra you had the time of the creation from the JSON.

查看更多
登录 后发表回答