Search code examples
jsonscaladecodingcircegeneric-derivation

Decoding JSON values in circe where the key is not known at compile time


Suppose I've been working with some JSON like this:

{ "id": 123, "name": "aubergine" }

By decoding it into a Scala case class like this:

case class Item(id: Long, name: String)

This works just fine with circe's generic derivation:

scala> import io.circe.generic.auto._, io.circe.jawn.decode
import io.circe.generic.auto._
import io.circe.jawn.decode

scala> decode[Item]("""{ "id": 123, "name": "aubergine" }""")
res1: Either[io.circe.Error,Item] = Right(Item(123,aubergine))

Now suppose I want to add localization information to the representation:

{ "id": 123, "name": { "localized": { "en_US": "eggplant" } } }

I can't use a case class like this directly via generic derivation:

case class LocalizedString(lang: String, value: String)

…because the language tag is a key, not a field. How can I do this, preferably without too much boilerplate?


Solution

  • You can decode a singleton JSON object into a case class like LocalizedString in a few different ways. The easiest would be something like this:

    import io.circe.Decoder
    
    implicit val decodeLocalizedString: Decoder[LocalizedString] =
      Decoder[Map[String, String]].map { kvs =>
        LocalizedString(kvs.head._1, kvs.head._2)
      }
    

    This has the disadvantage of throwing an exception on an empty JSON object, and in the behavior being undefined for cases where there's more than one field. You could fix those issues like this:

    implicit val decodeLocalizedString: Decoder[LocalizedString] =
      Decoder[Map[String, String]].map(_.toList).emap {
        case List((k, v)) => Right(LocalizedString(k, v))
        case Nil          => Left("Empty object, expected singleton")
        case _            => Left("Multiply-fielded object, expected singleton")
      }
    

    This is potentially inefficient, though, especially if there's a chance you might end up trying to decode really big JSON objects (which would be converted into a map, then a list of pairs, just to fail.).

    If you're concerned about performance, you could write something like this:

    import io.circe.DecodingFailure
    
    implicit val decodeLocalizedString: Decoder[LocalizedString] = { c =>
      c.value.asObject match {
        case Some(obj) if obj.size == 1 =>
          val (k, v) = obj.toIterable.head
          v.as[String].map(LocalizedString(k, _))
        case None => Left(
          DecodingFailure("LocalizedString; expected singleton object", c.history)
        )
      }
    }
    

    That decodes the singleton object itself, though, and in our desired representation we have a {"localized": { ... }} wrapper. We can accommodate that with a single extra line at the end:

    implicit val decodeLocalizedString: Decoder[LocalizedString] = 
      Decoder.instance { c =>
        c.value.asObject match {
          case Some(obj) if obj.size == 1 =>
            val (k, v) = obj.toIterable.head
            v.as[String].map(LocalizedString(k, _))
          case None => Left(
            DecodingFailure("LocalizedString; expected singleton object", c.history)
          )
        }
      }.prepare(_.downField("localized"))
    

    This will fit right in with a generically derived instance for our updated Item class:

    import io.circe.generic.auto._, io.circe.jawn.decode
    
    case class Item(id: Long, name: LocalizedString)
    

    And then:

    scala> val doc = """{"id":123,"name":{"localized":{"en_US":"eggplant"}}}"""
    doc: String = {"id":123,"name":{"localized":{"en_US":"eggplant"}}}
    
    scala> val Right(result) = decode[Item](doc)
    result: Item = Item(123,LocalizedString(en_US,eggplant))
    

    The customized encoder is a little more straightforward:

    import io.circe.{Encoder, Json, JsonObject}, io.circe.syntax._
    
    implicit val encodeLocalizedString: Encoder.AsObject[LocalizedString] = {
      case LocalizedString(k, v) => JsonObject(
        "localized" := Json.obj(k := v)
      )
    }
    

    And then:

    scala> result.asJson
    res11: io.circe.Json =
    {
      "id" : 123,
      "name" : {
        "localized" : {
          "en_US" : "eggplant"
        }
      }
    }
    

    This approach will work for any number of "dynamic" fields like this—you can transform the input into either a Map[String, Json] or JsonObject and work with the key-value pairs directly.