aboutsummaryrefslogtreecommitdiff
path: root/shared
diff options
context:
space:
mode:
Diffstat (limited to 'shared')
-rw-r--r--shared/src/main/scala/DerivedFormats.scala108
-rw-r--r--shared/src/main/scala/annotations.scala22
-rw-r--r--shared/src/test/scala/CoproductTypeFormatTests.scala79
-rw-r--r--shared/src/test/scala/FormatTests.scala19
-rw-r--r--shared/src/test/scala/ImplicitDerivedFormatTests.scala24
-rw-r--r--shared/src/test/scala/ProductTypeFormatTests.scala77
6 files changed, 329 insertions, 0 deletions
diff --git a/shared/src/main/scala/DerivedFormats.scala b/shared/src/main/scala/DerivedFormats.scala
new file mode 100644
index 0000000..d0cac38
--- /dev/null
+++ b/shared/src/main/scala/DerivedFormats.scala
@@ -0,0 +1,108 @@
+package spray.json
+
+import magnolia._
+
+import scala.language.experimental.macros
+
+/** Mixin that enables derivation of JSON formats for any product
+ * (case classes) or coproduct (sealed traits) types. */
+trait DerivedFormats { self: BasicFormats =>
+ type Typeclass[T] = JsonFormat[T]
+
+ def combine[T](ctx: CaseClass[JsonFormat, T]): JsonFormat[T] =
+ new JsonFormat[T] {
+ override def write(value: T): JsValue = {
+ val fields: Seq[(String, JsValue)] = ctx.parameters.map { param =>
+ param.label -> param.typeclass.write(param.dereference(value))
+ }
+ JsObject(fields: _*)
+ }
+
+ override def read(value: JsValue): T = value match {
+ case obj: JsObject =>
+ if (ctx.isObject) {
+ ctx.rawConstruct(Seq.empty)
+ } else {
+ ctx.construct { param =>
+ param.typeclass.read(obj.fields(param.label))
+ }
+ }
+ case js =>
+ deserializationError(
+ s"Cannot read JSON '$js' as a ${ctx.typeName.full}")
+ }
+ }
+
+ def dispatch[T](ctx: SealedTrait[JsonFormat, T]): JsonFormat[T] = {
+ val typeFieldName = ctx.annotations
+ .collectFirst {
+ case g: gadt => g.typeFieldName
+ }
+ .getOrElse("type")
+
+ new JsonFormat[T] {
+ override def write(value: T): JsValue = ctx.dispatch(value) { sub =>
+ sub.typeclass.write(sub.cast(value)) match {
+ case obj: JsObject =>
+ JsObject(
+ (Map(typeFieldName -> JsString(sub.typeName.short)) ++
+ obj.fields).toSeq: _*)
+ case js => js
+ }
+ }
+
+ override def read(js: JsValue): T = {
+ val typeName: String = js match {
+ case obj: JsObject if obj.fields.contains(typeFieldName) =>
+ obj.fields(typeFieldName).convertTo[String]
+ case JsString(str) =>
+ str
+ case _ =>
+ deserializationError(
+ s"Cannot deserialize JSON to ${ctx.typeName.full} " +
+ "because serialized type cannot be determined.")
+ }
+
+ ctx.subtypes.find(_.typeName.short == typeName) match {
+ case Some(tpe) => tpe.typeclass.read(js)
+ case None =>
+ deserializationError(
+ s"Cannot deserialize JSON to ${ctx.typeName.full} " +
+ s"because type '${typeName}' is unsupported.")
+ }
+ }
+ }
+ }
+
+ def jsonFormat[T]: RootJsonFormat[T] =
+ macro DerivedFormatMacros.derivedFormat[T]
+
+}
+object DerivedFormats extends DerivedFormats with DefaultJsonProtocol
+
+trait ImplicitDerivedFormats extends DerivedFormats { self: BasicFormats =>
+ implicit def implicitJsonFormat[T]: RootJsonFormat[T] =
+ macro DerivedFormatMacros.derivedFormat[T]
+}
+object ImplicitDerivedFormats
+ extends ImplicitDerivedFormats
+ with DefaultJsonProtocol
+
+object DerivedFormatMacros {
+ import scala.reflect.macros.whitebox._
+
+ /** Utility that converts a magnolia-generated JsonFormat to a RootJsonFormat. */
+ def derivedFormat[T: c.WeakTypeTag](c: Context): c.Tree = {
+ import c.universe._
+ val tpe = weakTypeOf[T].typeSymbol.asType
+ val sprayPkg = c.mirror.staticPackage("spray.json")
+ val valName = TermName(c.freshName("format"))
+ q"""{
+ val $valName = ${Magnolia.gen[T](c)}
+ new $sprayPkg.RootJsonFormat[$tpe] {
+ def write(value: $tpe) = $valName.write(value)
+ def read(value: $sprayPkg.JsValue) = $valName.read(value)
+ }
+ }"""
+ }
+}
diff --git a/shared/src/main/scala/annotations.scala b/shared/src/main/scala/annotations.scala
new file mode 100644
index 0000000..9d35d16
--- /dev/null
+++ b/shared/src/main/scala/annotations.scala
@@ -0,0 +1,22 @@
+package spray.json
+
+import scala.annotation.StaticAnnotation
+
+/** An annotation that designates that a sealed trait is a generalized algebraic
+ * datatype (GADT), and that a type field containing the serialized childrens'
+ * types should be added to the final JSON objects.
+ *
+ * Note that by default all sealed traits are treated as GADTs, with a type
+ * field called `type`. This annotation enables overriding the name of that
+ * field and is really only useful if a child itself has a field called `type`
+ * that would result in a conflict.
+ *
+ * Example:
+ * {{{
+ * // the JSON field "kind" will contain the actual type of the serialized child
+ * @gadt("kind") sealed abstract class Keyword(`type`: String)
+ * case class If(`type`: String) extends Keyword(`type`)
+ * }}}
+ * @param typeFieldName the name of the field to inject into a serialized JSON
+ * object */
+final class gadt(val typeFieldName: String = "type") extends StaticAnnotation
diff --git a/shared/src/test/scala/CoproductTypeFormatTests.scala b/shared/src/test/scala/CoproductTypeFormatTests.scala
new file mode 100644
index 0000000..de73967
--- /dev/null
+++ b/shared/src/test/scala/CoproductTypeFormatTests.scala
@@ -0,0 +1,79 @@
+package spray.json
+
+import org.scalatest._
+
+class CoproductTypeFormatTests
+ extends FlatSpec
+ with FormatTests
+ with DefaultJsonProtocol
+ with DerivedFormats {
+
+ sealed trait Expr
+ case class Zero() extends Expr
+ case class Value(x: Int) extends Expr
+ case class Plus(lhs: Expr, rhs: Expr) extends Expr
+ case object One extends Expr
+
+ implicit val exprFormat: RootJsonFormat[Expr] = jsonFormat[Expr]
+
+ "No-parameter case class child" should behave like checkRoundtrip[Expr](
+ Zero(),
+ """{"type":"Zero"}"""
+ )
+
+ "Simple parameter case class child" should behave like checkRoundtrip[Expr](
+ Value(42),
+ """{"type":"Value","x":42}"""
+ )
+
+ "Nested parameter case class child" should behave like checkRoundtrip[Expr](
+ Plus(Value(42), One),
+ """{"type":"Plus","lhs":{"type":"Value","x":42},"rhs":{"type":"One"}}"""
+ )
+
+ "Case object child" should behave like checkRoundtrip[Expr](
+ One,
+ """{"type": "One"}"""
+ )
+
+ @gadt("kind")
+ sealed abstract class Keyword(`type`: String)
+ case class If(`type`: String) extends Keyword(`type`)
+
+ implicit val keywordFormat: RootJsonFormat[Keyword] = jsonFormat[Keyword]
+
+ "GADT with type field alias" should behave like checkRoundtrip[Keyword](
+ If("class"),
+ """{"kind":"If","type":"class"}"""
+ )
+
+ @gadt("""_`crazy type!`"""")
+ sealed abstract trait Crazy
+ case class CrazyType() extends Crazy
+
+ implicit val crazyFormat: RootJsonFormat[Crazy] = jsonFormat[Crazy]
+
+ "GADT with special characters in type field" should behave like checkRoundtrip[
+ Crazy](
+ CrazyType(),
+ """{"_`crazy type!`\"": "CrazyType"}"""
+ )
+
+ sealed trait Enum
+ case object A extends Enum
+ case object B extends Enum
+
+ implicit val enumFormat: RootJsonFormat[Enum] = jsonFormat[Enum]
+
+ "Enum" should behave like checkRoundtrip[List[Enum]](
+ A :: B :: Nil,
+ """[{"type":"A"}, {"type":"B"}]"""
+ )
+
+ "Serializing as sealed trait and deserializing as child" should "work" in {
+ implicit val plusFormat: RootJsonFormat[Plus] = jsonFormat[Plus]
+ val expr: Expr = Plus(Value(42), Plus(Zero(), One))
+ assert(expr.toJson.convertTo[Plus] == expr)
+ }
+
+}
diff --git a/shared/src/test/scala/FormatTests.scala b/shared/src/test/scala/FormatTests.scala
new file mode 100644
index 0000000..0f56091
--- /dev/null
+++ b/shared/src/test/scala/FormatTests.scala
@@ -0,0 +1,19 @@
+package spray.json
+
+import org.scalatest._
+
+trait FormatTests { self: FlatSpec =>
+
+ def checkRoundtrip[A: RootJsonFormat](a: A, expectedJson: String) = {
+ it should "serialize to the expected JSON value" in {
+ val expected: JsValue = expectedJson.parseJson
+ assert(a.toJson == expected)
+ }
+
+ it should "serialize then deserialize back to itself" in {
+ val reread = a.toJson.convertTo[A]
+ assert(reread == a)
+ }
+ }
+
+}
diff --git a/shared/src/test/scala/ImplicitDerivedFormatTests.scala b/shared/src/test/scala/ImplicitDerivedFormatTests.scala
new file mode 100644
index 0000000..1d54ae9
--- /dev/null
+++ b/shared/src/test/scala/ImplicitDerivedFormatTests.scala
@@ -0,0 +1,24 @@
+package spray.json
+
+import org.scalatest._
+
+class ImplicitDerivedFormatTests
+ extends FlatSpec
+ with FormatTests
+ with ImplicitDerivedFormats
+ with DefaultJsonProtocol {
+
+ case class B(x: Int, b: String, mp: Map[String, Int])
+ case class C(b: B)
+
+ "Simple parameter product" should behave like checkRoundtrip(
+ B(42, "Hello World", Map("a" -> 1, "b" -> -1024)),
+ """{ "x": 42, "b": "Hello World", "mp": { "a": 1, "b": -1024 } }"""
+ )
+
+ "Nested parameter product with custom child format" should behave like checkRoundtrip(
+ C(B(42, "Hello World", Map("a" -> 1, "b" -> -1024))),
+ """{"b" :{ "x": 42, "b": "Hello World", "mp": { "a": 1, "b": -1024 } } }"""
+ )
+
+}
diff --git a/shared/src/test/scala/ProductTypeFormatTests.scala b/shared/src/test/scala/ProductTypeFormatTests.scala
new file mode 100644
index 0000000..ce05000
--- /dev/null
+++ b/shared/src/test/scala/ProductTypeFormatTests.scala
@@ -0,0 +1,77 @@
+package spray.json
+
+import org.scalatest._
+
+class ProductTypeFormatTests
+ extends FlatSpec
+ with FormatTests
+ with DerivedFormats
+ with DefaultJsonProtocol {
+
+ case class A()
+ case class B(x: Int, b: String, mp: Map[String, Int])
+ case class C(b: B)
+ case object D
+ case class E(d: D.type)
+ case class F(x: Int)
+ case class G(f: F)
+
+ implicit val aFormat: RootJsonFormat[A] = jsonFormat[A]
+ implicit val bFormat: RootJsonFormat[B] = jsonFormat[B]
+ implicit val cFormat: RootJsonFormat[C] = jsonFormat[C]
+ implicit val dFormat: RootJsonFormat[D.type] = jsonFormat[D.type]
+ implicit val eFormat: RootJsonFormat[E] = jsonFormat[E]
+
+ "No-parameter product" should behave like checkRoundtrip(A(), "{}")
+
+ "Simple parameter product" should behave like checkRoundtrip(
+ B(42, "Hello World", Map("a" -> 1, "b" -> -1024)),
+ """{ "x": 42, "b": "Hello World", "mp": { "a": 1, "b": -1024 } }"""
+ )
+
+ "Nested parameter product" should behave like checkRoundtrip(
+ C(B(42, "Hello World", Map("a" -> 1, "b" -> -1024))),
+ """{"b" :{ "x": 42, "b": "Hello World", "mp": { "a": 1, "b": -1024 } } }"""
+ )
+
+ "Case object" should behave like checkRoundtrip(
+ D,
+ "{}"
+ )
+
+ "Case object as parameter" should behave like checkRoundtrip(
+ E(D),
+ """{"d":{}}"""
+ )
+
+ // custom format for F, that inverts the value of parameter x
+ implicit val fFormat: RootJsonFormat[F] = new RootJsonFormat[F] {
+ override def write(f: F): JsValue = JsObject("y" -> f.x.toJson)
+ override def read(js: JsValue): F =
+ F(js.asJsObject.fields("y").convertTo[Int])
+ }
+
+ "Overriding with a custom format" should behave like checkRoundtrip(
+ F(2),
+ """{"y":2}"""
+ )
+
+ implicit val gFormat: RootJsonFormat[G] = jsonFormat[G]
+
+ "Derving a format with a custom child format" should behave like checkRoundtrip(
+ G(F(2)),
+ """{"f": {"y":2}}"""
+ )
+
+ case class H(x: Boolean)
+ case class I(h: H)
+
+ // there is no format defined for H, Magnolia will generate one automatically
+ implicit val iFormat: RootJsonFormat[I] = jsonFormat[I]
+
+ "Deriving a format that has no implicit child formats available" should behave like checkRoundtrip(
+ I(H(true)),
+ """{"h": {"x":true}}"""
+ )
+
+}