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.
Encoder.AsObject
is new in 0.12—previously you'd useObjectEncoder
. – Bricole