From bf44950ec63decaa6179e2c9d69aeb3a3de0a4da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Jourdan-Weil?= Date: Fri, 26 Nov 2021 18:33:34 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=F0=9F=9A=80=20Provide=20Play-Json=20bo?= =?UTF-8?q?dy=20encoder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 23 +++++- build.sbt | 27 +++++- .../pact4s/playjson/JsonConversion.scala | 51 ++++++++++++ .../scala/pact4s/playjson/implicits.scala | 22 +++++ .../pact4s/playjson/JsonConversionTests.scala | 82 +++++++++++++++++++ .../pact4s/playjson/JsonConversionTests.scala | 82 +++++++++++++++++++ project/Dependencies.scala | 30 ++++--- 7 files changed, 303 insertions(+), 14 deletions(-) create mode 100644 play-json/src/main/scala/pact4s/playjson/JsonConversion.scala create mode 100644 play-json/src/main/scala/pact4s/playjson/implicits.scala create mode 100644 play-json/src/test/java11+/pact4s/playjson/JsonConversionTests.scala create mode 100644 play-json/src/test/java8/pact4s/playjson/JsonConversionTests.scala diff --git a/README.md b/README.md index 5aa50965..27ad12d4 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,9 @@ Pacts are constructed using the pact-jvm DSL, but with additional helpers for ea If you want to construct simple pacts with bodies that do not use the pact-jvm matching dsl, (`PactDslJsonBody`), a scala data type `A` can be passed to `.body` directly, provided there is an implicit instance of `pact4s.PactBodyEncoder[A]` provided. -Instances of `pact4s.PactBodyEncoder` are provided for any type that has a `circe.Encoder` by adding the additional dependency: ```io.github.jbwheatley %% pact4s-circe % xxx```. +Instances of `pact4s.PactBodyEncoder` are provided for: +- any type that has a `circe.Encoder` by adding the additional dependency: ```io.github.jbwheatley %% pact4s-circe % xxx``` +- any type that has a `play.api.libs.json.Writes` by adding the additional dependency: ```io.github.jbwheatley %% pact4s-play-json % xxx``` This allows the following when using the import `pact4s.circe.implicits._`: ```scala @@ -76,6 +78,25 @@ val pact: RequestResponsePact = // ... ``` +Or the following when using the import `pact4s.playjson.implicits._`: +```scala +import pact4s.playjson.implicits._ + +final case class Foo(a: String) + +implicit val reads: Writes[Foo] = ??? + +val pact: RequestResponsePact = + ConsumerPactBuilder + .consumer("Consumer") + .hasPactWith("Provider") + .uponReceiving("a request to say Hello") + .path("/hello") + .method("POST") + .body(Foo("abcde"), "application/json") + // ... +``` + ### Request/Response Pacts Request/response pacts use the `RequestResponsePactForger` trait. This trait requires that you provide a `RequestResponsePact`, which will be used to stand up a stub of the provider server. Each interaction in the pact should then run against the stub server using client the consumer application uses to interact with the real provider. This ensures that the client, and thus the application, is compatible with the pact being defined. diff --git a/build.sbt b/build.sbt index 84e49c9c..9f82b0b7 100644 --- a/build.sbt +++ b/build.sbt @@ -33,7 +33,7 @@ inThisBuild( scalaVersion := scala213, commands ++= CrossCommand.single( "test", - matrices = Seq(shared, circe, munit, scalaTest, weaver), + matrices = Seq(shared, circe, playJson, munit, scalaTest, weaver), dimensions = Seq( javaVersionDimension, Dimension.scala("2.13"), @@ -114,6 +114,28 @@ lazy val circe = ) .dependsOn(shared) +lazy val playJson = + withStandardSettings(projectMatrix in file("play-json")) + .settings( + name := moduleName("pact4s-play-json", virtualAxes.value), + libraryDependencies ++= Dependencies.playJson, + Test / unmanagedSourceDirectories ++= { + val version = virtualAxes.value.collectFirst { case c: PactJvmAxis => c.version }.get + version match { + case Dependencies.pactJvmJava11 => + Seq( + moduleBase.value / s"src" / "test" / "java11+" + ) + case Dependencies.pactJvmJava8 => + Seq( + moduleBase.value / s"src" / "test" / "java8" + ) + case _ => Nil + } + } + ) + .dependsOn(shared) + lazy val munit = withStandardSettings(projectMatrix in file("munit-cats-effect-pact")) .settings( @@ -154,7 +176,8 @@ lazy val pact4s = (projectMatrix in file(".")) scalaTest, weaver, shared, - circe + circe, + playJson ) addCommandAlias( diff --git a/play-json/src/main/scala/pact4s/playjson/JsonConversion.scala b/play-json/src/main/scala/pact4s/playjson/JsonConversion.scala new file mode 100644 index 00000000..f750ab7e --- /dev/null +++ b/play-json/src/main/scala/pact4s/playjson/JsonConversion.scala @@ -0,0 +1,51 @@ +package pact4s.playjson + +import au.com.dius.pact.consumer.dsl.{DslPart, PactDslJsonArray, PactDslJsonBody, PactDslJsonRootValue} +import play.api.libs.json._ + +private[playjson] object JsonConversion { + + private def addFieldToBuilder(builder: PactDslJsonBody, fieldName: String, json: JsValue): PactDslJsonBody = + json match { + case JsNull => builder.nullValue(fieldName) + case JsTrue => builder.booleanValue(fieldName, true) + case JsFalse => builder.booleanValue(fieldName, false) + case JsNumber(num) => builder.numberValue(fieldName, num) + case JsString(str) => builder.stringValue(fieldName, str) + case JsArray(array) => addArrayToJsonBody(builder, fieldName, array.toSeq) + case jsonObject: JsObject => builder.`object`(fieldName, addJsonObjToBuilder(new PactDslJsonBody(), jsonObject)) + } + + private def addJsonObjToBuilder(builder: PactDslJsonBody, jsonObj: JsObject): PactDslJsonBody = + jsonObj.value.foldLeft(builder) { case (b, (s, j)) => + addFieldToBuilder(b, s, j) + } + + private def addArrayToJsonBody(builder: PactDslJsonBody, fieldName: String, array: Seq[JsValue]): PactDslJsonBody = + addArrayValuesToArray(builder.array(fieldName), array).closeArray().asBody() + + private def addArrayValuesToArray(builder: PactDslJsonArray, array: Seq[JsValue]): PactDslJsonArray = + array + .foldLeft(builder) { (arrayBody, json) => + json match { + case JsNull => arrayBody.nullValue() + case JsTrue => arrayBody.booleanValue(true) + case JsFalse => arrayBody.booleanValue(false) + case JsNumber(num) => arrayBody.numberValue(num) + case JsString(str) => arrayBody.stringValue(str) + case JsArray(arr) => addArrayValuesToArray(arrayBody.array(), arr.toSeq).closeArray().asArray() + case jsonObj: JsObject => addJsonObjToBuilder(arrayBody.`object`(), jsonObj).closeObject().asArray() + } + } + + def jsonToPactDslJsonBody(json: JsValue): DslPart = + json match { + case JsNull => throw new IllegalArgumentException("Content cannot be null json value if set") + case JsFalse => PactDslJsonRootValue.booleanType(false) + case JsTrue => PactDslJsonRootValue.booleanType(true) + case JsNumber(num) => PactDslJsonRootValue.numberType(num) + case JsString(str) => PactDslJsonRootValue.stringType(str) + case JsArray(arr) => addArrayValuesToArray(new PactDslJsonArray(), arr.toSeq) + case jsonObj: JsObject => addJsonObjToBuilder(new PactDslJsonBody(), jsonObj) + } +} diff --git a/play-json/src/main/scala/pact4s/playjson/implicits.scala b/play-json/src/main/scala/pact4s/playjson/implicits.scala new file mode 100644 index 00000000..9aab8d1b --- /dev/null +++ b/play-json/src/main/scala/pact4s/playjson/implicits.scala @@ -0,0 +1,22 @@ +package pact4s.playjson + +import au.com.dius.pact.core.model.messaging.Message +import pact4s.algebras.{MessagePactDecoder, PactBodyJsonEncoder, PactDslJsonBodyEncoder} +import pact4s.playjson.JsonConversion.jsonToPactDslJsonBody +import pact4s.provider.ProviderState +import play.api.libs.json.{Format, Json, Reads, Writes} + +import scala.util.Try + +object implicits { + implicit def pactBodyEncoder[A](implicit writes: Writes[A]): PactBodyJsonEncoder[A] = + (a: A) => Json.toJson(a).toString() + + implicit def pactDslJsonBodyConverter[A](implicit writes: Writes[A]): PactDslJsonBodyEncoder[A] = (a: A) => + jsonToPactDslJsonBody(Json.toJson(a)) + + implicit def messagePactDecoder[A](implicit reads: Reads[A]): MessagePactDecoder[A] = (message: Message) => + Try(Json.parse(message.contentsAsString()).as[A]).toEither + + implicit val providerStateFormat: Format[ProviderState] = Json.format[ProviderState] +} diff --git a/play-json/src/test/java11+/pact4s/playjson/JsonConversionTests.scala b/play-json/src/test/java11+/pact4s/playjson/JsonConversionTests.scala new file mode 100644 index 00000000..f8ed77a0 --- /dev/null +++ b/play-json/src/test/java11+/pact4s/playjson/JsonConversionTests.scala @@ -0,0 +1,82 @@ +package pact4s.playjson + +import munit.FunSuite +import pact4s.playjson.JsonConversion.jsonToPactDslJsonBody +import play.api.libs.json.{JsNull, JsValue, Json} + +class JsonConversionTests extends FunSuite { + + def testRoundTrip(json: JsValue): Unit = + assertEquals(Json.parse(jsonToPactDslJsonBody(json).getBody.toString), json) + + test("array-less JSON should round-trip with PactDslJsonBody") { + val json = Json.obj( + "key1" -> Json.toJson("value1"), + "key2" -> Json.obj( + "key2.1" -> Json.toJson(true), + "key2.2" -> JsNull, + "key2.3" -> Json.obj() + ), + "key3" -> Json.toJson(1), + "key4" -> Json.toJson(2.34) + ) + + testRoundTrip(json) + } + + test("should raise exception if json is a top-level array") { + val json = Json.arr( + Json.toJson(1), + Json.toJson(2), + Json.toJson(3) + ) + testRoundTrip(json) + } + + test("should roundtrip an empty json object") { + testRoundTrip(Json.obj()) + } + + test("should work if JSON object contains a nested simple array") { + val json = Json.obj( + "array" -> Json.toJson(List(1, 2, 3)) + ) + testRoundTrip(json) + } + + test("should work if JSON object contains a nested array of objects") { + val json = Json.obj( + "array" -> Json.toJson( + List( + Json.obj("f" -> Json.toJson("g")), + Json.obj("f" -> Json.toJson("h")) + ) + ) + ) + testRoundTrip(json) + } + + test("should work if JSON object contains an array of array") { + val json = Json.obj( + "array" -> Json.toJson( + List( + Json.toJson(List(1, 2, 3)), + Json.toJson(List(4, 5, 6)) + ) + ) + ) + testRoundTrip(json) + } + + test("should encode top level string") { + assertEquals(jsonToPactDslJsonBody(Json.toJson("pact4s")).getBody.asString(), "pact4s") + } + + test("should encode top level boolean") { + assertEquals(jsonToPactDslJsonBody(Json.toJson(true)).getBody.asBoolean().booleanValue(), true) + } + + test("should encode top level number") { + assertEquals(jsonToPactDslJsonBody(Json.toJson(12)).getBody.asNumber().intValue(), 12) + } +} diff --git a/play-json/src/test/java8/pact4s/playjson/JsonConversionTests.scala b/play-json/src/test/java8/pact4s/playjson/JsonConversionTests.scala new file mode 100644 index 00000000..dc5b7c59 --- /dev/null +++ b/play-json/src/test/java8/pact4s/playjson/JsonConversionTests.scala @@ -0,0 +1,82 @@ +package pact4s.playjson + +import munit.FunSuite +import pact4s.playjson.JsonConversion.jsonToPactDslJsonBody +import play.api.libs.json.{JsNull, JsValue, Json} + +class JsonConversionTests extends FunSuite { + + def testRoundTrip(json: JsValue): Unit = + assertEquals(Json.parse(jsonToPactDslJsonBody(json).getBody.toString), json) + + test("array-less JSON should round-trip with PactDslJsonBody") { + val json = Json.obj( + "key1" -> Json.toJson("value1"), + "key2" -> Json.obj( + "key2.1" -> Json.toJson(true), + "key2.2" -> JsNull, + "key2.3" -> Json.obj() + ), + "key3" -> Json.toJson(1), + "key4" -> Json.toJson(2.34) + ) + + testRoundTrip(json) + } + + test("should raise exception if json is a top-level array") { + val json = Json.arr( + Json.toJson(1), + Json.toJson(2), + Json.toJson(3) + ) + testRoundTrip(json) + } + + test("should roundtrip an empty json object") { + testRoundTrip(Json.obj()) + } + + test("should work if JSON object contains a nested simple array") { + val json = Json.obj( + "array" -> Json.toJson(List(1, 2, 3)) + ) + testRoundTrip(json) + } + + test("should work if JSON object contains a nested array of objects") { + val json = Json.obj( + "array" -> Json.toJson( + List( + Json.obj("f" -> Json.toJson("g")), + Json.obj("f" -> Json.toJson("h")) + ) + ) + ) + testRoundTrip(json) + } + + test("should work if JSON object contains an array of array") { + val json = Json.obj( + "array" -> Json.toJson( + List( + Json.toJson(List(1, 2, 3)), + Json.toJson(List(4, 5, 6)) + ) + ) + ) + testRoundTrip(json) + } + + test("should encode top level string") { + assertEquals(jsonToPactDslJsonBody(Json.toJson("pact4s")).getBody.asInstanceOf[String], "pact4s") + } + + test("should encode top level boolean") { + assertEquals(jsonToPactDslJsonBody(Json.toJson(true)).getBody.asInstanceOf[Boolean], true) + } + + test("should encode top level number") { + assertEquals(jsonToPactDslJsonBody(Json.toJson(12)).getBody.asInstanceOf[BigDecimal].toInt, 12) + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 587ff454..b2082ff3 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -10,29 +10,31 @@ object Dependencies { val collectionCompat = "2.6.0" val sourcecode = "0.2.7" val _circe = "0.14.1" + val _playJson = "2.9.2" val _weaver = "0.7.7" val _scalatest = "3.2.10" - val _munit = "1.0.6" + val _munit = "0.7.29" + val _munitCatsEffect = "1.0.6" def shared(pactJvmVersion: String): Seq[ModuleID] = Seq( "au.com.dius.pact" % "consumer" % pactJvmVersion, "au.com.dius.pact" % "provider" % pactJvmVersion, "org.log4s" %% "log4s" % log4s, - "ch.qos.logback" % "logback-classic" % logback % Runtime, + "ch.qos.logback" % "logback-classic" % logback % Runtime, "org.scala-lang.modules" %% "scala-collection-compat" % collectionCompat, "com.lihaoyi" %% "sourcecode" % sourcecode, - "org.http4s" %% "http4s-ember-client" % http4s % Test, - "org.http4s" %% "http4s-dsl" % http4s % Test, - "org.http4s" %% "http4s-ember-server" % http4s % Test, - "org.http4s" %% "http4s-circe" % http4s % Test, - "io.circe" %% "circe-core" % _circe % Test, - "org.mockito" %% "mockito-scala" % mockitoScala % Test, - "org.typelevel" %% "munit-cats-effect-3" % _munit % Test + "org.http4s" %% "http4s-ember-client" % http4s % Test, + "org.http4s" %% "http4s-dsl" % http4s % Test, + "org.http4s" %% "http4s-ember-server" % http4s % Test, + "org.http4s" %% "http4s-circe" % http4s % Test, + "io.circe" %% "circe-core" % _circe % Test, + "org.mockito" %% "mockito-scala" % mockitoScala % Test, + "org.typelevel" %% "munit-cats-effect-3" % _munitCatsEffect % Test ) val munit: Seq[ModuleID] = Seq( - "org.typelevel" %% "munit-cats-effect-3" % _munit % Provided + "org.typelevel" %% "munit-cats-effect-3" % _munitCatsEffect % Provided ) val scalatest: Seq[ModuleID] = Seq( @@ -47,6 +49,12 @@ object Dependencies { val circe: Seq[ModuleID] = Seq( "io.circe" %% "circe-core" % _circe, "io.circe" %% "circe-parser" % _circe, - "org.typelevel" %% "munit-cats-effect-3" % _munit % Test + "org.typelevel" %% "munit-cats-effect-3" % _munitCatsEffect % Test ) + + val playJson: Seq[ModuleID] = Seq( + "com.typesafe.play" %% "play-json" % _playJson, + "org.scalameta" %% "munit" % _munit % Test + ) + }