Skip to content

Instantly share code, notes, and snippets.

@crypticmind
Last active August 29, 2015 14:06
Show Gist options
  • Save crypticmind/f7d14fe1c0b50494a524 to your computer and use it in GitHub Desktop.
Save crypticmind/f7d14fe1c0b50494a524 to your computer and use it in GitHub Desktop.
JSON lenses - Get/Set JsValues, copy immutable objects

Macro to generate lenses for objects that take/return JSON values (using spray-json).

package tests
import spray.json._
object ApplyChanges extends App {
case class Area(country: String)
case class Address(street: String, city: String, area: Area)
case class Person(name: String, age: Int, address: Address)
val area = Area("Argentolandia")
val address = Address("Carabobo 4545", "Laferrere", area)
val person = Person("Juan", 33, address)
/////////////////////////////////////////////////////////////////////////////
import DefaultJsonProtocol._
import DefaultJsonLenses._
implicit val areaJF = jsonFormat1(Area)
implicit val addressJF = jsonFormat3(Address)
implicit val personJF = jsonFormat3(Person)
implicit val areaLens = JsonLens.of[Area]
implicit val addressLens = JsonLens.of[Address]
val personLens = JsonLens.of[Person]
/////////////////////////////////////////////////////////////////////////////
println(s"Accepted update fields: ${personLens.fields.keys.toList.sorted}")
val json =
"""
|{
| "name": "Juan Pérez",
| "age": 44,
| "address": {
| "street": "Carabobo 6767",
| "city": "Villa Ortuzar",
| "area": {
| "country": "Qwghlm"
| }
| }
|}
""".stripMargin
val updateFields = "address.area.country, name, address.street"
val input = JsonParser(json)
var mutation = person
println(s"Before ==> $mutation")
def getNestedProperty(jo: JsObject, path: List[String]): JsValue = path match {
case Nil => throw new RuntimeException("Unexpected end of list")
case element :: Nil => jo.fields.getOrElse(element, throw new RuntimeException(s"Attribute '$element' not found"))
case element :: rest => getNestedProperty(jo.fields.getOrElse(element, throw new RuntimeException(s"Attribute '$element' not found")).asJsObject, rest)
}
updateFields.split(",").map(_.trim).foreach { updateField =>
val lens = personLens.fields(updateField)
val inputValue = getNestedProperty(input.asJsObject, updateField.split("\\.").toList)
mutation = lens.set(mutation, inputValue)
}
println(s"After ==> $mutation")
}
package tests
import spray.json.{JsonFormat, JsValue, DefaultJsonProtocol}
object DefaultJsonLenses {
import DefaultJsonProtocol._
implicit val stringLens = new JsonLens[String] {
def get(obj: String): JsValue = implicitly[JsonFormat[String]].write(obj)
def set(obj: String, value: JsValue): String = implicitly[JsonFormat[String]].read(value)
}
implicit val intLens = new JsonLens[Int] {
def get(obj: Int): JsValue = implicitly[JsonFormat[Int]].write(obj)
def set(obj: Int, value: JsValue): Int = implicitly[JsonFormat[Int]].read(value)
}
implicit val longLens = new JsonLens[Long] {
def get(obj: Long): JsValue = implicitly[JsonFormat[Long]].write(obj)
def set(obj: Long, value: JsValue): Long = implicitly[JsonFormat[Long]].read(value)
}
implicit val booleanLens = new JsonLens[Boolean] {
def get(obj: Boolean): JsValue = implicitly[JsonFormat[Boolean]].write(obj)
def set(obj: Boolean, value: JsValue): Boolean = implicitly[JsonFormat[Boolean]].read(value)
}
}
package tests
import spray.json.JsValue
import language.experimental.macros
import reflect.macros.whitebox.Context
abstract class JsonLens[T] {
def get(obj: T): JsValue
def set(obj: T, value: JsValue): T
def fields: Map[String, JsonLens[T]] = Map()
}
object JsonLens {
def of[T]: JsonLens[T] = macro lensMacro[T]
def lensMacro[T : c.WeakTypeTag](c: Context): c.Expr[JsonLens[T]] = {
import c.universe._
val lensType = weakTypeOf[T]
val primaryCtor = lensType.decls.collectFirst {
case m: MethodSymbol if m.isPrimaryConstructor => m
}
val fields = primaryCtor match {
case Some(pc) => pc.paramLists.head
case None => Nil
}
val fieldLenses = fields.map { field =>
val fieldName = field.name.decodedName
val fieldType = field.typeSignature
q"""
Map(
${fieldName.toString} -> new JsonLens[$lensType] {
def get(obj: $lensType) = implicitly[JsonLens[$fieldType]].get(obj.${fieldName.toTermName})
def set(obj: $lensType, value: JsValue) = obj.copy(${fieldName.toTermName} = implicitly[JsonLens[$fieldType]].set(obj.${fieldName.toTermName}, value))
}) ++ implicitly[JsonLens[$fieldType]].fields.map({ kv =>
(${fieldName.toString} + "." + kv._1 -> new JsonLens[$lensType] {
def get(obj: $lensType): JsValue = kv._2.get(obj.${fieldName.toTermName})
def set(obj: $lensType, value: JsValue): $lensType = obj.copy(${fieldName.toTermName} = kv._2.set(obj.${fieldName.toTermName}, value))
})
})
"""
}
val fieldLensMap = fieldLenses.fold(q"Map[String, JsonLens[$lensType]]()")((fieldLens1, fieldLens2) => q"$fieldLens1 ++ $fieldLens2")
c.Expr[JsonLens[T]] {
q"""
new JsonLens[$lensType] {
def get(obj: $lensType): JsValue = implicitly[JsonFormat[$lensType]].write(obj)
def set(obj: $lensType, value: JsValue): $lensType = implicitly[JsonFormat[$lensType]].read(value)
override def fields = $fieldLensMap
}
"""
}
}
}
package tests
import org.scalatest.{ShouldMatchers, WordSpec}
class JsonLensTest extends WordSpec with ShouldMatchers {
import spray.json._
import DefaultJsonProtocol._
import DefaultJsonLenses._
"The macro" when {
"asked to generate lenser for a type" should {
case class TestClass(strProp: String, intProp: Int, boolProp: Boolean)
implicit val personJsonFormat = jsonFormat3(TestClass)
val lenser = JsonLens.of[TestClass]
"include all fields" in {
lenser.fields.keySet should equal(Set("strProp", "intProp", "boolProp"))
}
val john = TestClass(strProp = "initial", intProp = 0, boolProp = false)
"provide lenses for fields" which {
"can output JSON representations" in {
lenser.fields("strProp").get(john) should equal(JsString("initial"))
lenser.fields("intProp").get(john) should equal(JsNumber(0))
lenser.fields("boolProp").get(john) should equal(JsBoolean(x = false))
}
"can take JSON input in" in {
var updated = john
updated = lenser.fields("strProp").set(updated, JsString("updated"))
updated = lenser.fields("intProp").set(updated, JsNumber(1))
updated = lenser.fields("boolProp").set(updated, JsBoolean(x = true))
updated should have (
'strProp ("updated"),
'intProp (1),
'boolProp (true)
)
}
"always return a copy" in {
john should have (
'strProp ("initial"),
'intProp (0),
'boolProp (false)
)
}
}
}
"given an complex type" should {
case class Area(provinceState: String, country: String)
case class Address(street: String, city: String, area: Area)
case class Person(name: String, age: Int, address: Address)
val area = Area("Outer Qwghlm", "Qwghlm")
val address = Address("123 2nd st", "Frozen Plain", area)
val person = Person("Eberhard Föhr", 40, address)
implicit val areaJF = jsonFormat2(Area)
implicit val addressJF = jsonFormat3(Address)
implicit val personJF = jsonFormat3(Person)
implicit val areaLens = JsonLens.of[Area]
implicit val addressLens = JsonLens.of[Address]
val personLens = JsonLens.of[Person]
"provide lenses for fields" which {
"include nested fields" in {
personLens.fields.keySet should equal(Set(
"name", "age", "address",
"address.street", "address.city", "address.area",
"address.area.provinceState", "address.area.country"))
}
"can provide JSON representations of nested values" in {
personLens.fields("name").get(person) should equal(JsString("Eberhard Föhr"))
personLens.fields("age").get(person) should equal(JsNumber(40))
personLens.fields("address.street").get(person) should equal(JsString("123 2nd st"))
personLens.fields("address.city").get(person) should equal(JsString("Frozen Plain"))
personLens.fields("address.area.provinceState").get(person) should equal(JsString("Outer Qwghlm"))
personLens.fields("address.area.country").get(person) should equal(JsString("Qwghlm"))
}
"copy the entire graph on each single value change" in {
personLens.fields("name").set(person, JsString("John Cantrell")) should not equal person
personLens.fields("age").set(person, JsNumber(41)) should not equal person
personLens.fields("address.street").set(person, JsString("456 3rd st")) should not equal person
personLens.fields("address.city").set(person, JsString("Icy Valley")) should not equal person
personLens.fields("address.area.provinceState").set(person, JsString("Inner Qwghlm")) should not equal person
personLens.fields("address.area.country").set(person, JsString("Kinakuta")) should not equal person
}
"allow changing complex properties" in {
val eberMoved = personLens.fields("address").set(person, Address("789 4th st", "Valley", Area("Capitol", "Kinakuta")).toJson)
eberMoved should have(
'name ("Eberhard Föhr"),
'age (40))
eberMoved.address should have(
'street ("789 4th st"),
'city ("Valley"))
eberMoved.address.area should have(
'provinceState ("Capitol"),
'country ("Kinakuta"))
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment