Search code examples
jsonscalaplayframeworkplayframework-2.5

Convert a JsValue to a model via Reads[T] which consists of a list of tuples


I have the following class:

case class Model(elements: List[(String, String)])

Now I want to fill my model Model with the values of a JsValue by using Reads[T]. The JSON could have different key values pairs which are unknown at the time of unmarshaling them and therefore I want to have them as a list of tuples.

For example:

{ "foo": "bar", "barfoo": "foobar"}

Should become:

List(("foo" -> "bar"), ("barfoo" -> "foobar"))

The problem is that I don't know how I can achieve a sort of wildcard function that matches all elements in a JSON object, but not nested ones or arrays.

implicit val modelReads: Reads[Model] = (
        (JsPath \ "?").read[String] // and
     // (JsPath \ "foo").read[String] // and <- key not known in advance
     // (JsPath \ "barfoo").read[String] // <- key not known in advance
        ) (Model.apply _)

Solution

  • You won't be able to use Play JSON combinators for everything here, as they only work with fixed field mappings. For you to be able to read the elements field, you would need to implement a Reads[List[(String, String)]]. Fortunately, Play already has a Reads[Map[A, B]] available (for types A and B that also have a Reads), and a Map[A, B] can easily be converted into a List[(A, B)] (underneath a Map is just a collection of tuples).

    For a one-off case, we can use read[Map[String, String]] and map it to a List. Then, we can map that to the case class. Assuming the following JSON structure:

    val js = Json.parse("""{"element": { "foo": "bar", "barfoo": "foobar"}}""")
    

    You can write:

    implicit val reads = (__ \ "elements").read[Map[String, String]]
      .map(_.toList)
      .map(tuples => Model(tuples))
    

    And try it out:

    scala> js.validate[Model]
    res8: play.api.libs.json.JsResult[Model] = JsSuccess(Model(List((foo,bar), (barfoo,foobar))),/elements)
    

    Note that the Reads[Model] above is kind of a special case, because the case class only had a single field. To take this a bit further and see how it can play with JSON combinators, let's add a new field:

    case class Model(elements: List[(String, String)], info: String)
    

    Then, let's also make our Reads for the tuples a little more generic, so that it can handle values of any type A where a Reads[A] is available:

    implicit def tupleReads[A](implicit rds: Reads[A]): Reads[List[(String, A)]] =
      Reads.mapReads(rds).map(_.toList)
    

    Now we can write a Reads using combinators for the newly defined Model, the same as you're used to:

    implicit val reads = (
      (__ \ "elements").read[List[(String, String)]] and
      (__ \ "info").read[String]
    )(Model.apply _)
    

    Trying it out:

    val js = Json.parse("""{"elements": { "foo": "bar", "barfoo": "foobar"}, "info": "test"}""")
    
    scala> js.validate[Model]
    res0: play.api.libs.json.JsResult[Model] = JsSuccess(Model(List((foo,bar), (barfoo,foobar)),test),)
    

    If your JSON structure only looks like {"foo": "bar", "barfoo": "foobar"} (without an elements key), then we can still leverage the same generic Reads[List[(String, A)]], but will need to implement a more custom Reads[Model] to map an entire object to one model field. Let's we want to map the above JSON to:

    Model(List(("foo" -> "bar"), ("barfoo" -> "foobar")))
    

    The Reads[Model] we need will basically be the same as the first one I defined, except that we can drop the JsPath from it:

    // Use `tupleReads` as defined above, restricted to `String`
    implicit val reads = tupleReads[String].map(tuples => Model(tuples))
    

    It works:

    val js = Json.parse("""{"foo": "bar", "barfoo": "foobar"}""")
    
    scala> js.validate[Model]
    res0: play.api.libs.json.JsResult[Model] = JsSuccess(Model(List((foo,bar), (barfoo,foobar))),)