Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 🚀 Provide Play-JSON body encoder #114

Merged
merged 2 commits into from
Dec 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably make this % Provided, otherwise we lock the user into a version

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't lock user in a version, they can still use another version in their codebase (direct dependency or using dependencyOverrides) as long as the version is compatible.

By the way, recent versions of sbt will let users know if they are pulling 2 incompatible (according to semver) versions.

If we put it to scope Provided and users use a version incompatible with what we expected in pact4s, it would break as well.

Copy link

@solarmosaic-kflorence solarmosaic-kflorence Dec 10, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gaeljw right -- it's just that this is what the Provided scope is for, it requires the user to include this library and include a compatible version of play JSON -- rather than including one by default which needs to be excluded if the user wants to use a different version (or being evicted if they include a version in addition to the default one). It's just cleaner, IMO, and also a pattern we already use in this library.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see any downside of using the Compile scope: on the contrary it will let sbt highlight potential incompatibilities. I might be wrong or partial but Provided scope is more for context like Tomcat or Spark where the runtime server bring the dependencies itself. If your code needs play-json it must declare it needs it IMHO.

Anyway I'll adapt to the standards/pattern of this library if needed :)

Though it's already merged and circe module is setup the same way: https://github.com/jbwheatley/pact4s/blob/main/project/Dependencies.scala#L50

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone is bringing this library into their project, they will already have a version of play-json defined. Since you include it in the Compile scope this means:

  • there are now potentially two versions included, one being evicted
  • they will need to remove their existing inclusion (may not be feasible depending on project setup)
  • they will need to exclude the version included by the pact4s library

I don't see any benefit to including it in Compile scope, personally. In fact, your example is the only other place where we don't include the dependency as Provided. Not a hill I'm going to die on, maybe @jbwheatley can weigh in.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PS, I don't think there really is a right answer to this, it just depends on the dependency strategy we want to take for this library. We can either include everything in Compile scope and let the users handle potential evictions/conflicts, or we can use Provided scope and force users to explicitly include a compatible version of the dependency, which I prefer simply because I think it's less complicated.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I totally understand your point. I guess it's a matter of habits/taste as well. I'll be fine with any choice :)

"org.scalameta" %% "munit" % _munit % Test
)

}