How to properly create OFormat in Scala/Play/JSON?

79 Views Asked by At

I am trying to create a proper OFormat that works correctly for encode and decode.

Here is the situation: I have this endpoint giving me JSON. One of the fields sometimes look like an integer. Sometimes it looks like an integer in a string. Sometimes it looks like random string. Sometimes the key/value pair is missing all together. So I created a custom decoder to handle those 4 cases. And that works great. But I need a custom encoder to match. And that I have not been able to figure out.

Here is my custom serde:

package object Example {
  implicit class JsPathOps(jsPath: JsPath) {
    def forceReadNullableInt: Reads[Option[Int]] =
      jsPath.readNullable[Int] or
      jsPath.readNullable[String]
        .map(_.flatMap(string => Try(string.toInt).toOption))

    def forceFormatNullableInt: OFormat[Option[Int]] =
      OFormat(forceReadNullableInt, JsPath.writeNullable[Int])
  }
}

Here is an example representative case class:

case class User(name: String, age: Option[Int])

object User {
  implicit val format: Format[User] =
    (
      (JsPath \ "name").format[String] and
      (JsPath \ "age").forceFormatNullableInt
    )(
      User.apply,
      unlift(User.unapply)
    )
}

And here is an object with main to try it out:

object App {
  def main(args: Array[String]): Unit = {
    val json1in: String = """{"name": "abc"}"""
    val json2in: String = """{"name": "def", "age": 123}"""

    val user1: User = Json.parse(json1in).as[User]
    val user2: User = Json.parse(json2in).as[User]

    val json1out: String = Json.toJson(user1).toString
    val json2out: String = Json.toJson(user2).toString

    println(s"1    in = ${json1in}")
    println(s"1   out = ${json1out}")
    println(s"1 match = ${json1in == json1out}")

    println(s"2    in = ${json2in}")
    println(s"2   out = ${json2out}")
    println(s"2 match = ${json2in == json2out}")
  }
}

Both of the decodes work great. But when it gets to the encoding of user2, I get the error java.lang.RuntimeException: when empty JsPath, expecting JsObject. Not sure what I am doing wrong. Nothing I have tried works.

How do I create a proper OWrites to match the Reads in my OFormat?

I am using Scala 2.12 and Play 2.6, if that makes any difference.

2

There are 2 best solutions below

1
Levi Ramsey On

It's worth remembering that OWrites[T] is ultimately just a function from a T to a JsValue. There are some DSLs and macros to make common cases easy, but you can always fall back to its "essence":

implicit val writes: OWrites[User] = new OWrites[User] {
  def writes(user: User): JsValue = {
    val justName = JsObject(Map("name" -> JsString(user.name)))
    user.age match {
      case None => justName
      case Some(age) => justName ++ JsObject(Map("age" -> JsNumber(age))
    }
  }
}
0
DCameronMauch On

I did find a solution. Changes the ops class to the following. Now have to provide the key, so Play/JSON can properly make the JsObject. I think each key/value pair of the object encode to an object, then all the objects are squashed down to a single object.

package object Example {
  implicit class JsPathOps(jsPath: JsPath) {
    def forceReadNullableInt: Reads[Option[Int]] =
      jsPath.readNullable[Int] or
      jsPath.readNullable[String]
        .map(_.flatMap(string => Try(string.toInt).toOption))

    def forceFormatNullableInt(key: String): OFormat[Option[Int]] =
      OFormat(
        forceReadNullableInt,
        OWrites[Option[Int]](option => encodeOption(key, option))
      )

    private def encodeValue[A : Writes](key: String, value: A): JsObject =
      Json
        .obj(key -> value)

    private def encodeOption[A : Writes](key: String, option: Option[A]): JsObject =
      option
        .map(value => encodeValue(key, value))
        .getOrElse(Json.obj())
  }
}