Macro to generate lenses for objects that take/return JSON values (using spray-json).
Last active
August 29, 2015 14:06
-
-
Save crypticmind/f7d14fe1c0b50494a524 to your computer and use it in GitHub Desktop.
JSON lenses - Get/Set JsValues, copy immutable objects
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} | |
""" | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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