Shurik Agulyansky Shurik Agulyansky - 11 days ago 6
JSON Question

Asymmetrical, type sensitive, JSON formatter with Play Scala

In our app, we have pretty complex structure of objects that is getting converted to JSON and back. Until now most of the formatted are symmetrical (except some very specific cases, and even these for security reasons).

Now we are facing a more complex case where conversion of an object into JSON (writes) needs to create an additional field at a time of conversion while the case class does not have that field.
For example, here is one of our existing formatters:

case class ChecklistColumn(kind: ColumnKind.Value, descriptor: Descriptor.Value, data: JsValue) extends Column

implicit val checklistResultChecklistDataFormat: Format[ChecklistColumn] = (
(__ \ "kind").format[ColumnKind.Value] and
(__ \ "descriptor").format[Descriptor.Value] and
(__ \ "data").format[JsValue]
)(ChecklistColumn.apply, unlift(ChecklistColumn.unapply))


This one creates a json that will looks like:

{
"kind": <String>,
"descriptor": <String>,
"data": <JsValue>
}


What we need to achieve is:

{
"kind": <String>,
"descriptor": <String>,
"data": <JsValue>,
"normalized_data": <JsString>
}


But, only in case when the data is type of
JsString
(in any other case
normalized_data
can be left empty, byt ideally should not even exist).

I do understand that we have to create separate Reads & Writes for that.
But, I am not sure, how to implement the logic that will react differently to a different type of
data
.

Of course, there is always an option to create fully custom
writes
:

override def writes(column: ChecklistColumn): JsValue = {...}


But, this will create a huge complexity in a code that will be hard to maintain.

What is the cleanest way to implement something like that?

Answer

Have a look at ScalaJsonTransformers. You can create a transformer that creates the normalised field from a string data value and use it to, erm, transform your original Format to a new Writes. Here's a slightly simplified example that could doubtless be improved (you'll want to check various edge cases):

case class ChecklistColumn(kind: String, descriptor: String, data: JsValue)

// The original format.
val checklistFormat: Format[ChecklistColumn] = (
  (__ \ "kind").format[String] and
  (__ \ "descriptor").format[String] and
  (__ \ "data").format[JsValue]
)(ChecklistColumn.apply, unlift(ChecklistColumn.unapply))

// A transformer that looks for a "data" field with a string
// value and adds the normalized_data field if it finds one.
val checklistTransformer: Reads[JsObject] = JsPath.json.update(
  (__ \ "data").read[String].flatMap (
     str => (__ \ "normalized_data").json.put(JsString(str + "!!!"))))

// A new derived Writes which writes the transformed value if
// the transformer succeeds (a data string), otherwise the
// original value.
implicit val checklistWrites: Writes[ChecklistColumn] = checklistFormat
  .transform (js => js.transform(checklistTransformer).getOrElse(js))

That gives me:

Json.prettyPrint(Json.toJson(ChecklistColumn("a", "b", JsNumber(1))))
// {
//   "kind" : "a",
//   "descriptor" : "b",
//   "data" : 1
// }

Json.prettyPrint(Json.toJson(ChecklistColumn("a", "b", JsString("c"))))
// {
//   "kind" : "a",
//   "descriptor" : "b",
//   "data" : "c",
//   "normalized_data" : "c!!!"
// }