Optics are a powerful tool for traversing and modifying JSON documents. They can reduce boilerplate considerably, especially if you are working with deeply nested JSON.
circe provides support for optics by integrating with Monocle. To use them, add a dependency on circe-optics
to your build:
libraryDependencies += "io.circe" %% "circe-optics" % circeVersion
Note that this will require your project to depend on both Scalaz and cats.
Let´s try a few examples using this JSON document:
val json: Json = parse("""
{
"order": {
"customer": {
"name": "Custy McCustomer",
"contactDetails": {
"address": "1 Fake Street, London, England",
"phone": "0123-456-789"
}
},
"items": [{
"id": 123,
"description": "banana",
"quantity": 1
}, {
"id": 456,
"description": "apple",
"quantity": 2
}],
"total": 123.45
}
}
""").getOrElse(Json.Null)
If we wanted to get the customer’s phone number, we could do it using a cursor as follows:
val phoneNum: Option[String] = json.hcursor.downField("order").
downField("customer").
downField("contactDetails").
get[String]("phone").
toOption
// phoneNum: Option[String] = Some(0123-456-789)
This works, but we could do it in a simpler way using optics like this:
In this example we will take the customer´s address.
import io.circe.optics.JsonPath._
// import io.circe.optics.JsonPath._
val _address = root.order.customer.contactDetails.address.string
val address: Option[String] = _address.getOption(json)
Now is your turn, let´s try your answer:
val address: Option[String] = _address.getOption(json)
address should be(res0)
Note the difference between cursors and optics. With cursors, we start with a JSON document, get a cursor from it, and then use that cursor to traverse the document. With optics, on the other hand, we first define the traversal we want to make, then apply it to a JSON document.
In other words, optics provide a way to separate the description of a JSON traversal from its execution. Consequently we can reuse the same traversal against many different documents, compose traversals together, and so on.
Let’s look at a more complex example. This time we want to get the descriptions of all the items in the order. Using a cursor it might look like this:
val items: Vector[Json] = json.hcursor.
downField("order").
downField("items").
focus.
flatMap(_.asArray).
getOrElse(Vector.empty)
val descriptions: Vector[String] =
items.flatMap(_.hcursor.get[String]("description").toOption)
// descriptions: Vector[String] = Vector("banana", "apple")
And with optics:
val items: List[String] = root.order.items.each.description.string.getAll(json)
items should be(res0)
Optics can also be used for making modifications to JSON.
In this example we will try modifiying the quantities. Let´s try your answer, modifiedQuantities
would be...
val doubleQuantities: Json => Json = root.order.items.each.quantity.int.modify(_ * 2)
val modifiedJson = doubleQuantities(json)
val modifiedQuantities: List[Int] = root.order.items.each.quantity.int.getAll(modifiedJson)
modifiedQuantities should be(res0)
Sometimes you may need to recursively modify JSON. Let assume you need to transform all numbers into strings in the example JSON:
import io.circe.optics.JsonOptics._
import monocle.function.Plated
val recursiveModifiedJson = Plated.transform[Json] { j =>
j.asNumber match {
case Some(n) => Json.fromString(n.toString)
case None => j
}
}(json)
root.order.total.string.getOption(recursiveModifiedJson) shouldBe res0
JsonPath
relies on a feature of Scala called Dynamic
. Using Dynamic
you can call methods that don´t actually exist.
When you do so, the selectDynamic
method is called, and the name of the method you wanted to call is passed as an argument.
WARNING: The use of Dynamic
means that your code is not "typo-safe". So be careful when you are typing
val doubleQuantities: Json => Json =
root.order.itemss.each.quantity.int.modify(_ * 2) // Note the "itemss" typo
val modifiedJson = doubleQuantities(json)
Let´s see the result for the last affirmation
val modifiedQuantitiesDynamic: List[Int] =
root.order.items.each.quantity.int.getAll(modifiedJson)
modifiedQuantitiesDynamic == List(2, 4) should be(res0)