From 5072509482dc1d60de4291ad30e9f3c74e43c9a8 Mon Sep 17 00:00:00 2001 From: PJ Fanning Date: Fri, 28 Dec 2018 16:16:30 +0100 Subject: [PATCH] wip --- build.sbt | 4 + .../json/circe/CirceJsonapiDecoders.scala | 220 ++++++++++++++++++ .../json/circe/CirceJsonapiEncoders.scala | 170 ++++++++++++++ .../json/circe/CirceJsonapiFormatSpec.scala | 117 ++++++++++ 4 files changed, 511 insertions(+) create mode 100644 src/main/scala/org/zalando/jsonapi/json/circe/CirceJsonapiDecoders.scala create mode 100644 src/main/scala/org/zalando/jsonapi/json/circe/CirceJsonapiEncoders.scala create mode 100644 src/test/scala/org/zalando/jsonapi/json/circe/CirceJsonapiFormatSpec.scala diff --git a/build.sbt b/build.sbt index ea4da6f..8b53224 100644 --- a/build.sbt +++ b/build.sbt @@ -9,10 +9,14 @@ crossScalaVersions := Seq("2.11.12", scalaVersion.value) scalacOptions ++= Seq("-feature", "-unchecked", "-deprecation") libraryDependencies ++= { + val circeVersion = "0.11.0" Seq( "io.spray" %% "spray-json" % "1.3.5" % "provided", "com.typesafe.play" %% "play-json" % "2.6.13" % "provided", + "io.circe" %% "circe-core" % circeVersion % "provided", + "io.circe" %% "circe-generic" % circeVersion % "provided", + "io.circe" %% "circe-parser" % circeVersion % "provided", "org.scalatest" %% "scalatest" % "3.0.6-SNAP5" % Test ) } diff --git a/src/main/scala/org/zalando/jsonapi/json/circe/CirceJsonapiDecoders.scala b/src/main/scala/org/zalando/jsonapi/json/circe/CirceJsonapiDecoders.scala new file mode 100644 index 0000000..c5b8c9e --- /dev/null +++ b/src/main/scala/org/zalando/jsonapi/json/circe/CirceJsonapiDecoders.scala @@ -0,0 +1,220 @@ +package org.zalando.jsonapi.json.circe + +import io.circe._ +import org.zalando.jsonapi.json.FieldNames +import org.zalando.jsonapi.model.JsonApiObject._ +import org.zalando.jsonapi.model.RootObject._ +import org.zalando.jsonapi.model.{Errors, Error, _} + +trait CirceJsonapiDecoders { + def jsonToValue(json: Json): Value = json.fold[Value]( + NullValue, + BooleanValue.apply, + value ⇒ NumberValue(value.toBigDecimal.get), + StringValue.apply, + values ⇒ JsArrayValue(values.map(jsonToValue)), + values ⇒ + JsObjectValue(values.toMap.map { + case (key, value) ⇒ Attribute(key, jsonToValue(value)) + }.toList) + ) + + implicit val valueDecoder = Decoder.instance[Value](_.as[Json].right.map(jsonToValue)) + + implicit val attributesDecoder = Decoder.instance[Attributes](hcursor ⇒ { + hcursor.as[Value].right.flatMap { + case JsObjectValue(value) ⇒ + Right(value) + case _ ⇒ + Left(DecodingFailure("only an object can be decoded to Attributes", hcursor.history)) + } + }) + + implicit val attributeDecoder = Decoder.instance[Attribute](_.as[Attributes].right.map(_.head)) + + implicit val linksDecoder = Decoder.instance[Links](hcursor ⇒ { + hcursor.as[Value].right.flatMap { + case JsObjectValue(attributes) ⇒ + Right(attributes.map { + case Attribute(FieldNames.`self`, StringValue(url)) ⇒ Links.Self(url, None) + case Attribute(FieldNames.`self`, JsObjectValue(linkAttributes)) => + val linkValues = attributesToLinkValues(linkAttributes) + Links.Self(linkValues._1, linkValues._2) + + case Attribute(FieldNames.`about`, StringValue(url)) ⇒ + Links.About(url, None) + case Attribute(FieldNames.`about`, JsObjectValue(linkAttributes)) => + val linkValues = attributesToLinkValues(linkAttributes) + Links.About(linkValues._1, linkValues._2) + + case Attribute(FieldNames.`first`, StringValue(url)) ⇒ + Links.First(url, None) + case Attribute(FieldNames.`first`, JsObjectValue(linkAttributes)) => + val linkValues = attributesToLinkValues(linkAttributes) + Links.First(linkValues._1, linkValues._2) + + case Attribute(FieldNames.`last`, StringValue(url)) ⇒ Links.Last(url, None) + case Attribute(FieldNames.`last`, JsObjectValue(linkAttributes)) => + val linkValues = attributesToLinkValues(linkAttributes) + Links.Last(linkValues._1, linkValues._2) + + case Attribute(FieldNames.`next`, StringValue(url)) ⇒ Links.Next(url, None) + case Attribute(FieldNames.`next`, JsObjectValue(linkAttributes)) => + val linkValues = attributesToLinkValues(linkAttributes) + Links.Next(linkValues._1, linkValues._2) + + case Attribute(FieldNames.`prev`, StringValue(url)) ⇒ Links.Prev(url, None) + case Attribute(FieldNames.`prev`, JsObjectValue(linkAttributes)) => + val linkValues = attributesToLinkValues(linkAttributes) + Links.Prev(linkValues._1, linkValues._2) + + case Attribute(FieldNames.`related`, StringValue(url)) ⇒ + Links.Related(url, None) + case Attribute(FieldNames.`related`, JsObjectValue(linkAttributes)) => + val linkValues = attributesToLinkValues(linkAttributes) + Links.Related(linkValues._1, linkValues._2) + }) + case _ ⇒ + Left(DecodingFailure("only an object can be decoded to Links", hcursor.history)) + } + }) + + def attributesToLinkValues(linkObjectAttributes: Attributes): (String, Option[Meta]) = { + (linkObjectAttributes.find(_.name == "href"), linkObjectAttributes.find(_.name == "meta")) match { + case (Some(hrefAttribute), Some(metaAttribute)) => + val href = hrefAttribute match { + case Attribute("href", StringValue(url)) => url + } + val meta: Map[String, JsonApiObject.Value] = metaAttribute match { + case Attribute("meta", JsObjectValue(metaAttributes)) => + metaAttributes.map { + case Attribute(name, value) ⇒ name -> value + }.toMap + } + (href, Some(meta)) + } + } + + implicit val dataDecoder = Decoder.instance[Data](_.as[Json].right.flatMap(jsonToData)) + + implicit val relationshipDecoder = Decoder.instance[Relationship](hcursor ⇒ { + for { + links ← hcursor.downField(FieldNames.`links`).as[Option[Links]].right + data ← hcursor.downField(FieldNames.`data`).as[Option[Data]].right + } yield + Relationship( + links = links, + data = data + ) + }) + + implicit val relationshipsDecoder = Decoder.instance[Relationships](_.as[Map[String, Relationship]]) + + implicit val jsonApiDecoder = Decoder.instance[JsonApi](hcursor ⇒ { + hcursor.as[Value].right.flatMap { + case JsObjectValue(attributes) ⇒ + Right(attributes.map { + case Attribute(name, value) ⇒ JsonApiProperty(name, value) + }) + case _ ⇒ + Left(DecodingFailure("only an object can be decoded to JsonApi", hcursor.history)) + } + }) + + implicit val metaDecoder = Decoder.instance[Meta](hcursor ⇒ { + hcursor.as[Value].right.flatMap { + case JsObjectValue(attributes) ⇒ + Right(attributes.map { + case Attribute(name, value) ⇒ name -> value + }.toMap) + case _ ⇒ + Left(DecodingFailure("only an object can be decoded to Meta", hcursor.history)) + } + }) + + implicit val errorSourceDecoder = Decoder.instance[ErrorSource](hcursor ⇒ { + for { + pointer ← hcursor.downField(FieldNames.`pointer`).as[Option[String]].right + parameter ← hcursor.downField(FieldNames.`parameter`).as[Option[String]].right + } yield + ErrorSource( + pointer = pointer, + parameter = parameter + ) + }) + + implicit val errorDecoder = Decoder.instance[Error](hcursor ⇒ { + for { + id ← hcursor.downField(FieldNames.`id`).as[Option[String]].right + status ← hcursor.downField(FieldNames.`status`).as[Option[String]].right + code ← hcursor.downField(FieldNames.`code`).as[Option[String]].right + title ← hcursor.downField(FieldNames.`title`).as[Option[String]].right + detail ← hcursor.downField(FieldNames.`detail`).as[Option[String]].right + links ← hcursor.downField(FieldNames.`links`).as[Option[Links]].right + meta ← hcursor.downField(FieldNames.`meta`).as[Option[Meta]].right + source ← hcursor.downField(FieldNames.`source`).as[Option[ErrorSource]].right + } yield + Error( + id = id, + status = status, + code = code, + title = title, + detail = detail, + links = links, + meta = meta, + source = source + ) + }) + + implicit val resourceObjectDecoder = Decoder.instance[ResourceObject](hcursor ⇒ { + for { + id ← hcursor.downField(FieldNames.`id`).as[Option[String]].right + `type` ← hcursor.downField(FieldNames.`type`).as[String].right + attributes ← hcursor.downField(FieldNames.`attributes`).as[Option[Attributes]].right + relationships ← hcursor.downField(FieldNames.`relationships`).as[Option[Relationships]].right + links ← hcursor.downField(FieldNames.`links`).as[Option[Links]].right + meta ← hcursor.downField(FieldNames.`meta`).as[Option[Meta]].right + } yield + ResourceObject( + id = id, + `type` = `type`, + attributes = attributes, + relationships = relationships, + links = links, + meta = meta + ) + }) + + implicit val resourceObjectsDecoder = + Decoder.instance[ResourceObjects](_.as[List[ResourceObject]].right.map(ResourceObjects)) + + implicit val includedDecoder = Decoder.instance[Included](_.as[ResourceObjects].right.map(Included.apply)) + + implicit val rootObjectDecoder = Decoder.instance[RootObject](hcursor ⇒ { + for { + data ← hcursor.downField(FieldNames.`data`).as[Option[Data]].right + links ← hcursor.downField(FieldNames.`links`).as[Option[Links]].right + errors ← hcursor.downField(FieldNames.`errors`).as[Option[Errors]].right + meta ← hcursor.downField(FieldNames.`meta`).as[Option[Meta]].right + included ← hcursor.downField(FieldNames.`included`).as[Option[Included]].right + jsonapi ← hcursor.downField(FieldNames.`jsonapi`).as[Option[JsonApi]].right + } yield + RootObject( + data = data, + links = links, + errors = errors, + meta = meta, + included = included, + jsonApi = jsonapi + ) + }) + + def jsonToData(json: Json): Either[DecodingFailure, Data] = json match { + case json: Json if json.isArray ⇒ + json.as[ResourceObjects] + case json: Json if json.isObject ⇒ + json.as[ResourceObject] + } +} + +object CirceJsonapiDecoders extends CirceJsonapiDecoders diff --git a/src/main/scala/org/zalando/jsonapi/json/circe/CirceJsonapiEncoders.scala b/src/main/scala/org/zalando/jsonapi/json/circe/CirceJsonapiEncoders.scala new file mode 100644 index 0000000..3dd92d2 --- /dev/null +++ b/src/main/scala/org/zalando/jsonapi/json/circe/CirceJsonapiEncoders.scala @@ -0,0 +1,170 @@ +package org.zalando.jsonapi.json.circe + +import io.circe._ +import io.circe.generic.auto._ +import io.circe.syntax._ +import org.zalando.jsonapi.json.FieldNames +import org.zalando.jsonapi.model.JsonApiObject._ +import org.zalando.jsonapi.model.Links.Link +import org.zalando.jsonapi.model.RootObject.{Data, ResourceObject, ResourceObjects} +import org.zalando.jsonapi.model.{Error, _} + +// scalastyle:off public.methods.have.type +trait CirceJsonapiEncoders { + def valueToJson(generalValue: Value): Json = generalValue match { + case NullValue => + Json.Null + case StringValue(value) => + Json.fromString(value) + case BooleanValue(value) => + Json.fromBoolean(value) + case NumberValue(value) => + Json.fromBigDecimal(value) + case JsArrayValue(values) => + Json.fromValues(values.map(valueToJson)) + case JsObjectValue(values) => + Json.fromFields(values.map { + case Attribute(name, value) => + name -> valueToJson(value) + }) + } + + def jsonFromOptionalFields(entries: (String, Option[Json])*): Json = { + Json.fromFields(entries.flatMap { + case (name, valueOption) => valueOption.map(name -> _) + }) + } + + implicit def valueEncoder[V <: Value] = Encoder.instance[V](valueToJson) + + implicit val attributeEncoder = Encoder.instance[Attribute] { + case Attribute(name, value) => + Json.fromFields(Seq(name -> value.asJson)) + } + implicit val attributesEncoder = Encoder.instance[Attributes] { + case Seq(attributes @ _ *) => + attributes.map(_.asJson).reduce(_.deepMerge(_)) + } + + implicit val linkEncoder = Encoder.instance[Link] { link => + val (name: String, href: String, metaOpt: Option[Meta]) = link match { + case Links.Self(url, None) => (FieldNames.`self`, url, None) + case Links.Self(url, Some(meta)) => (FieldNames.`self`, url, Some(meta)) + + case Links.About(url, None) => (FieldNames.`about`, url, None) + case Links.About(url, Some(meta)) => (FieldNames.`about`, url, Some(meta)) + + case Links.First(url, None) => (FieldNames.`first`, url, None) + case Links.First(url, Some(meta)) => (FieldNames.`first`, url, Some(meta)) + + case Links.Last(url, None) => (FieldNames.`last`, url, None) + case Links.Last(url, Some(meta)) => (FieldNames.`last`, url, Some(meta)) + + case Links.Next(url, None) => (FieldNames.`next`, url, None) + case Links.Next(url, Some(meta)) => (FieldNames.`next`, url, Some(meta)) + + case Links.Prev(url, None) => (FieldNames.`prev`, url, None) + case Links.Prev(url, Some(meta)) => (FieldNames.`prev`, url, Some(meta)) + + case Links.Related(url, None) => (FieldNames.`related`, url, None) + case Links.Related(url, Some(meta)) => (FieldNames.`related`, url, Some(meta)) + } + metaOpt match { + case None => Json.fromFields(Seq(name -> Json.fromString(href))) + case Some(meta) => + val linkObjectJson = Json.fromFields(Seq("href" -> Json.fromString(href), "meta" -> meta.asJson)) + Json.fromFields(Seq(name -> linkObjectJson)) + } + } + + implicit val linksEncoder = Encoder.instance[Links](_.map(_.asJson).reduce(_.deepMerge(_))) + + def dataToJson(data: Data): Json = { + data match { + case ro: ResourceObject => + ro.asJson + case ros: ResourceObjects => + ros.asJson + } + } + + lazy implicit val relationshipEncoder = Encoder.instance[Relationship](relationship => { + jsonFromOptionalFields( + FieldNames.`links` -> relationship.links.map(_.asJson), + // TODO: there's prolly a cleaner way here. there's a circular dependency Data -> ResourceObject(s) -> Relationship(s) -> Data that's giving circe problems + FieldNames.`data` -> relationship.data.map(dataToJson) + ) + }) + + implicit val relationshipsEncoder = Encoder.instance[Relationships](relationships => + Json.fromFields(relationships.map { + case (name, value) => name -> value.asJson + })) + + implicit val jsonApiEncoder = Encoder.instance[JsonApi] { + case Seq(jsonApiPropertys @ _ *) => + Json.fromFields(jsonApiPropertys.map { + case JsonApiProperty(name, value) => + name -> value.asJson + }) + } + + implicit val metaEncoder = Encoder.instance[Meta](meta => { + Json.fromFields(meta.toSeq.map { + case (name, value) => name -> value.asJson + }) + }) + + implicit val errorSourceEncoder = Encoder.instance[ErrorSource](errorSource => { + jsonFromOptionalFields( + FieldNames.`pointer` -> errorSource.pointer.map(Json.fromString), + FieldNames.`parameter` -> errorSource.parameter.map(Json.fromString) + ) + }) + + implicit val errorEncoder = Encoder.instance[Error](error => { + jsonFromOptionalFields( + FieldNames.`id` -> error.id.map(Json.fromString), + FieldNames.`status` -> error.status.map(Json.fromString), + FieldNames.`code` -> error.code.map(Json.fromString), + FieldNames.`title` -> error.title.map(Json.fromString), + FieldNames.`detail` -> error.detail.map(Json.fromString), + FieldNames.`links` -> error.links.map(_.asJson), + FieldNames.`meta` -> error.meta.map(_.asJson), + FieldNames.`source` -> error.source.map(_.asJson) + ) + }) + + implicit val resourceObjectEncoder = Encoder.instance[ResourceObject](resourceObject => { + jsonFromOptionalFields( + FieldNames.`type` -> Option(Json.fromString(resourceObject.`type`)), + FieldNames.`id` -> resourceObject.id.map(Json.fromString), + FieldNames.`attributes` -> resourceObject.attributes.map(_.asJson), + FieldNames.`relationships` -> resourceObject.relationships.map(_.asJson), + FieldNames.`links` -> resourceObject.links.map(_.asJson), + FieldNames.`meta` -> resourceObject.meta.map(_.asJson) + ) + }) + + implicit val resourceObjectsEncoder = Encoder.instance[ResourceObjects] { + case ResourceObjects(resourceObjects) => + Json.fromValues(resourceObjects.map(_.asJson)) + } + + lazy implicit val dataEncoder = Encoder.instance[Data](dataToJson) + + implicit val includedEncoder = Encoder.instance[Included](_.resourceObjects.asJson) + + implicit val rootObjectEncoder = Encoder.instance[RootObject](rootObject => { + jsonFromOptionalFields( + FieldNames.`data` -> rootObject.data.map(_.asJson), + FieldNames.`links` -> rootObject.links.map(_.asJson), + FieldNames.`errors` -> rootObject.errors.map(_.asJson), + FieldNames.`meta` -> rootObject.meta.map(_.asJson), + FieldNames.`included` -> rootObject.included.map(_.asJson), + FieldNames.`jsonapi` -> rootObject.jsonApi.map(_.asJson) + ) + }) +} + +object CirceJsonapiEncoders extends CirceJsonapiEncoders \ No newline at end of file diff --git a/src/test/scala/org/zalando/jsonapi/json/circe/CirceJsonapiFormatSpec.scala b/src/test/scala/org/zalando/jsonapi/json/circe/CirceJsonapiFormatSpec.scala new file mode 100644 index 0000000..d24e3de --- /dev/null +++ b/src/test/scala/org/zalando/jsonapi/json/circe/CirceJsonapiFormatSpec.scala @@ -0,0 +1,117 @@ +package org.zalando.jsonapi.json.circe + +import io.circe.Json +import io.circe.parser.parse +import io.circe.syntax._ +import org.scalatest.MustMatchers +import org.zalando.jsonapi.json.JsonBaseSpec +import org.zalando.jsonapi.model._ + +class CirceJsonapiFormatSpec extends JsonBaseSpec[Json] with MustMatchers with CirceJsonapiEncoders with CirceJsonapiDecoders { + + override protected def parseJson(jsonString: String): Json = parse(jsonString).right.get + protected def decodeJson[T](json: Json)(implicit d: io.circe.Decoder[T]): T = json.as[T].right.get + + "CirceJsonapiFormat" when { + "serializing Jsonapi" must { + "transform attributes correctly" in { + attributes.asJson mustEqual attributesJson + } + "transform resource object correctly" in { + rootObjectWithResourceObjectWithoutAttributes.asJson mustEqual rootObjectWithResourceObjectWithoutAttributesJson + } + "transform a list of resource objects correctly" in { + rootObjectWithResourceObjects.asJson mustEqual rootObjectWithResourceObjectsJson + } + "transform resource identifier object correctly" in { + rootObjectWithResourceIdentifierObject.asJson mustEqual rootObjectWithResourceIdentifierObjectJson + } + "transform a list of resource identifier objects correctly" in { + rootObjectWithResourceIdentifierObjects.asJson mustEqual rootObjectWithResourceIdentifierObjectsJson + } + "transform all string link types correctly" in { + rootObjectWithResourceObjectsWithAllLinksAsStrings.asJson mustEqual rootObjectWithResourceObjectsWithAllLinksAsStringsJson + } + "transform all object link types correctly" in { + rootObjectWithResourceObjectsWithAllLinksAsObjects.asJson mustEqual rootObjectWithResourceObjectsWithAllLinksAsObjectsJson + } + "transform all string and object link types correctly" in { + rootObjectWithResourceObjectsWithAllLinksAsStringsAndObjects.asJson mustEqual rootObjectWithResourceObjectsWithAllLinksAsStringsAndObjectsJson + } + "transform all meta object inside resource object correctly" in { + rootObjectWithResourceObjectsWithMeta.asJson mustEqual rootObjectWithResourceObjectsWithMetaJson + } + "transform a meta object in root object correctly" in { + rootObjectWithMeta.asJson mustEqual rootObjectWithMetaJson + } + "transform error object correctly" in { + rootObjectWithErrorsObject.asJson mustEqual rootObjectWithErrorsJson + } + "transform error object without links and meta and source correctly" in { + rootObjectWithErrorsNoneObject.asJson mustEqual rootObjectWithErrorsNoneJson + } + "transform included object correctly" in { + rootObjectWithIncluded.asJson mustEqual rootObjectWithIncludedJson + } + "transform jsonapi object correctly" in { + rootObjectWithJsonApiObject.asJson mustEqual rootObjectWithJsonApiObjectJson + } + "transform relationship object correctly" in { + RootObjectWithRelationships.asJson mustEqual rootObjectWithRelationshipsJson + } + "transform empty relationship object correctly" in { + resourceObjectWithEmptyRelationshipsObject.asJson mustEqual resourceObjectWithEmptyRelationshipsJson + } + } + "deserializing Jsonapi" must { + "transform attributes correctly" in { + decodeJson[Attributes](attributesJson) === attributes + } + "transform resource object correctly" in { + decodeJson[RootObject](rootObjectWithResourceObjectWithoutAttributesJson) === rootObjectWithResourceObjectWithoutAttributes + } + "transform a list of resource objects correctly" in { + decodeJson[RootObject](rootObjectWithResourceObjectsJson) === rootObjectWithResourceObjects + } + "transform resource identifier object correctly" in { + decodeJson[RootObject](rootObjectWithResourceIdentifierObjectJson) === rootObjectWithResourceIdentifierObject + } + "transform a list of resource identifier objects correctly" in { + decodeJson[RootObject](rootObjectWithResourceIdentifierObjectsJson) === rootObjectWithResourceIdentifierObjects + } + "transform all string link types correctly" in { + decodeJson[RootObject](rootObjectWithResourceObjectsWithAllLinksAsStringsJson) === rootObjectWithResourceObjectsWithAllLinksAsStrings + } + "transform all object link types correctly" in { + decodeJson[RootObject](rootObjectWithResourceObjectsWithAllLinksAsObjectsJson) === rootObjectWithResourceObjectsWithAllLinksAsObjects + } + "transform all string and object link types correctly" in { + decodeJson[RootObject](rootObjectWithResourceObjectsWithAllLinksAsStringsAndObjectsJson) === rootObjectWithResourceObjectsWithAllLinksAsStringsAndObjects + } + "transform all meta object inside resource object correctly" in { + decodeJson[RootObject](rootObjectWithResourceObjectsWithMetaJson) === rootObjectWithResourceObjectsWithMeta + } + "transform a meta object in root object correctly" in { + decodeJson[RootObject](rootObjectWithMetaJson) === rootObjectWithMeta + } + "transform error object correctly" in { + decodeJson[RootObject](rootObjectWithErrorsJson) === rootObjectWithErrorsObject + } + "transform error object without links and meta and source correctly" in { + decodeJson[RootObject](rootObjectWithErrorsNoneJson) === rootObjectWithErrorsNoneObject + } + "transform included object correctly" in { + decodeJson[RootObject](rootObjectWithIncludedJson) === rootObjectWithIncluded + } + "transform jsonapi object correctly" in { + decodeJson[RootObject](rootObjectWithJsonApiObjectJson) === rootObjectWithJsonApiObject + } + "transform relationship object correctly" in { + decodeJson[RootObject](rootObjectWithRelationshipsJson) === RootObjectWithRelationships + } + "transform empty relationship object correctly" in { + decodeJson[RootObject](resourceObjectWithEmptyRelationshipsJson) === resourceObjectWithEmptyRelationshipsObject + } + } + } +} \ No newline at end of file