From af6845cc08ae223ad67ebad61559d8264fd3346b Mon Sep 17 00:00:00 2001 From: Jakob Odersky Date: Tue, 13 Feb 2018 15:09:31 -0800 Subject: Initial commit --- .gitignore | 1 + build.sbt | 4 +++ project/build.properties | 1 + src/main/scala/CustomFormats.scala | 19 ++++++++++++ src/main/scala/DerivedFormats.scala | 55 +++++++++++++++++++++++++++++++++++ src/main/scala/main.scala | 58 +++++++++++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+) create mode 100644 .gitignore create mode 100644 build.sbt create mode 100644 project/build.properties create mode 100644 src/main/scala/CustomFormats.scala create mode 100644 src/main/scala/DerivedFormats.scala create mode 100644 src/main/scala/main.scala diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f97022 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ \ No newline at end of file diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..750289a --- /dev/null +++ b/build.sbt @@ -0,0 +1,4 @@ + +libraryDependencies += "io.spray" %% "spray-json" % "1.3.4" + +libraryDependencies += "com.propensive" %% "magnolia" % "0.6.1" diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..31334bb --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.1.1 diff --git a/src/main/scala/CustomFormats.scala b/src/main/scala/CustomFormats.scala new file mode 100644 index 0000000..3656e3b --- /dev/null +++ b/src/main/scala/CustomFormats.scala @@ -0,0 +1,19 @@ + +import spray.json._ + +trait CustomFormats extends DefaultJsonProtocol { + + implicit val fooFormat: JsonFormat[Foo] = new JsonFormat[Foo] { + def read(number: JsValue) = number match { + case JsNumber(x) => Foo(-x.toInt) + case tpe => sys.error(s"no way I'm reading that type $tpe!") + } + def write(number: Foo) = JsNumber(-number.x) + } + + implicit val z: JsonFormat[B] = new JsonFormat[B] { + def read(x: JsValue) = B("gone") + def write(x: B) = JsObject("a" -> JsString("A")) + } + +} diff --git a/src/main/scala/DerivedFormats.scala b/src/main/scala/DerivedFormats.scala new file mode 100644 index 0000000..6c2396e --- /dev/null +++ b/src/main/scala/DerivedFormats.scala @@ -0,0 +1,55 @@ +import magnolia._ +import spray.json._ + +import scala.language.experimental.macros + +trait JsonFormatDerivation extends DefaultJsonProtocol { + 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 => + ctx.construct { param => + param.typeclass.read(obj.fields(param.label)) + } + case js => + deserializationError(s"Cannot read JSON '$js' as a ${ctx.typeName}") + } + } + + def dispatch[T](ctx: SealedTrait[JsonFormat, T]): JsonFormat[T] = new JsonFormat[T] { + override def write(value: T): JsValue = { + ctx.dispatch(value) { sub => + val obj = sub.typeclass.write(sub.cast(value)).asJsObject + JsObject((obj.fields ++ Map("type" -> JsString(sub.label))).toSeq: _*) + } + } + override def read(value: JsValue): T = value match { + case obj: JsObject if obj.fields.contains("type") => + val fieldName = obj.fields("type").convertTo[String] + + ctx.subtypes.find(_.label == fieldName) match { + case Some(tpe) => tpe.typeclass.read(obj) + case None => + deserializationError( + s"Cannot deserialize JSON to ${ctx.typeName} because type field '${fieldName}' has an unsupported value.") + } + + case js => + deserializationError(s"Cannot read JSON '$js' as a ${ctx.typeName}") + } + + } + + implicit def gen[T]: JsonFormat[T] = macro Magnolia.gen[T] + +} +object JsonFormatDerivation extends JsonFormatDerivation + +trait DerivedFormats extends JsonFormatDerivation diff --git a/src/main/scala/main.scala b/src/main/scala/main.scala new file mode 100644 index 0000000..223ac1c --- /dev/null +++ b/src/main/scala/main.scala @@ -0,0 +1,58 @@ +import spray.json._ + +// product type +case class Foo(x: Int) +case class Bar(foo: Foo, str: String) + +// coproduct +sealed trait T +case object A extends T +case class B(a: String) extends T +case class C(x: T) extends T // inception! + +object Main extends App with DefaultJsonProtocol with DerivedFormats { + + println("//////////\nProducts:") + + { + val product = Bar(Foo(42), "hello world") + val js = product.toJson + println(js.prettyPrint) + println(js.convertTo[Bar]) + } + + println("//////////\nCoproducts:") + + { + val coproduct: T = B("hello wordld") //Seq(C(B("What's up?")), B("a"), A) + val js = coproduct.toJson + println(js.prettyPrint) + println(js.convertTo[T]) + } + +} + +/* +A potentital danger: + +Overriding generated formats is possible (see CustomFormats), however it can be +easy to forget to include the custom formats. +=> In that case, the program will still compile, however it won't use the + correct format! + +Possible workarounds? + + - Require explicit format declarations, i.e. remove implicit from `implicit def + gen[T] = macro Magnolia.gen[T]` and add `def myFormat = gen[Foo]` to every + format trait. + => requires manual code, thereby mostly defeats the advantages of automatic derivation + => (advantage, no more code duplication since macro is expanded only once) + + - Avoid custom formats. + => entities become "API objects", which will be hard to upgrade in a backwards-compatible, yet idiomatic way + (E.g. new fields could be made optional so that they won't be required in json, however the business logic + may not require them to be optional. We lose some typesafety.) + => we'd likely have an additional layer of indirection, that will convert "api objects" to "business objects" + implemented by services + => Is that a good or bad thing? +*/ -- cgit v1.2.3