Skip to content

Commit

Permalink
feat: 🚀 Provide Play-Json body encoder (#114)
Browse files Browse the repository at this point in the history
Co-authored-by: jbwheatley <jackbwheatley@gmail.com>
  • Loading branch information
gaeljw and jbwheatley authored Dec 9, 2021
1 parent 8571095 commit 5fad42c
Show file tree
Hide file tree
Showing 7 changed files with 303 additions and 14 deletions.
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
27 changes: 25 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -154,7 +176,8 @@ lazy val pact4s = (projectMatrix in file("."))
scalaTest,
weaver,
shared,
circe
circe,
playJson
)

addCommandAlias(
Expand Down
51 changes: 51 additions & 0 deletions play-json/src/main/scala/pact4s/playjson/JsonConversion.scala
Original file line number Diff line number Diff line change
@@ -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)
}
}
22 changes: 22 additions & 0 deletions play-json/src/main/scala/pact4s/playjson/implicits.scala
Original file line number Diff line number Diff line change
@@ -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]
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
82 changes: 82 additions & 0 deletions play-json/src/test/java8/pact4s/playjson/JsonConversionTests.scala
Original file line number Diff line number Diff line change
@@ -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)
}
}
30 changes: 19 additions & 11 deletions project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.9"
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(
Expand All @@ -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
)

}

0 comments on commit 5fad42c

Please sign in to comment.