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?
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.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With