From db8edb2391585a59c00d2c161d3eab1b5f3bab5c Mon Sep 17 00:00:00 2001 From: Mark Abrahams Date: Wed, 26 Aug 2020 13:14:47 +0200 Subject: [PATCH] Added functionality to publish a created contract on disk to a broker. Verified with both pactflow as open source pact broker --- pact-jvm-server/README.md | 19 ++++ pact-jvm-server/build.gradle | 1 + .../au/com/dius/pact/server/Publish.scala | 107 ++++++++++++++++++ .../com/dius/pact/server/RequestRouter.scala | 1 + .../au/com/dius/pact/server/Server.scala | 7 +- .../au/com/dius/pact/server/CreateSpec.groovy | 4 +- .../com/dius/pact/server/PublishSpec.groovy | 58 ++++++++++ 7 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 pact-jvm-server/src/main/scala/au/com/dius/pact/server/Publish.scala create mode 100644 pact-jvm-server/src/test/groovy/au/com/dius/pact/server/PublishSpec.groovy diff --git a/pact-jvm-server/README.md b/pact-jvm-server/README.md index 096d5d14cc..f97222f378 100644 --- a/pact-jvm-server/README.md +++ b/pact-jvm-server/README.md @@ -12,6 +12,7 @@ The server implements a `JSON` `REST` Admin API with the following endpoints. / -> For diagnostics, currently returns a list of ports of the running mock servers. /create -> For initialising a test server and submitting the JSON interactions. It returns a port /complete -> For finalising and verifying the interactions with the server. It writes the `JSON` pact file to disk. + /publish -> For publishing contracts. It takes a contract from disk and publishes it to the configured broker ## Running the server @@ -40,6 +41,10 @@ Usage: pact-jvm-server [options] [port] Keystore password -s | --ssl-port Ssl port the mock server should run on. lower and upper bounds are ignored + -b | --broker + The baseUrl of the broker to publish contracts to (for example https://organization.broker.com + -t + API token for authentication to the pact broker --debug run with debug logging ``` @@ -87,6 +92,7 @@ The following actions are expected to occur * Once finished, the client will call `/complete' on the Admin API, posting the port number * The pact server will verify the interactions and write the `JSON` `pact` file to disk under `/target` * The mock server running on the supplied port will be shutdown. + * The client will call `/publish` to publish the created contract to the configured pact broker ## Endpoints @@ -120,6 +126,19 @@ For example: This will cause the Pact server to verify the interactions, shutdown the mock server running on that port and writing the pact `JSON` file to disk under the `target` directory. +### /publish + +Once all interactions have been tested the `/publish` endpoint can be called to publish the created pact to the pact broker +For this it is required to run the pact-jvm-server with the -b parameter to configure the pact broker to publish the pacts to. +Optionaly an authentication token can be used for authentication against the broker. + +For example: + + POST http://localhost:29999/publish '{ "consumer": "Zoo", "consumerVersion": "0.0.1", "provider": "Animal_Service" }' + +This will cause the Pact server to check for the pact `Zoo-Animal_Service.json` on disk under `target` and publish it to +the configured pact broker. After a successful publish the pact will be removed from disk. + ### / The `/` endpoint is for diagnostics and to check that the pact server is running. It will return all the currently diff --git a/pact-jvm-server/build.gradle b/pact-jvm-server/build.gradle index 5cbb27e388..f16c04616b 100644 --- a/pact-jvm-server/build.gradle +++ b/pact-jvm-server/build.gradle @@ -25,6 +25,7 @@ dependencies { compile("com.typesafe.scala-logging:scala-logging_2.12:3.7.2") { exclude group: 'org.scala-lang' } + compile "com.lihaoyi:requests_2.12:0.6.5" compile "ws.unfiltered:unfiltered-netty-server_2.12:0.9.1" implementation 'org.apache.commons:commons-io:1.3.2' diff --git a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Publish.scala b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Publish.scala new file mode 100644 index 0000000000..fb39754b61 --- /dev/null +++ b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Publish.scala @@ -0,0 +1,107 @@ +package au.com.dius.pact.server + +import au.com.dius.pact.core.model.{OptionalBody, Request, Response} +import com.typesafe.scalalogging.StrictLogging + +import scala.collection.JavaConverters._ +import scala.io.Source +import java.io.{File, IOException} + +import requests.{RequestAuth, RequestFailedException, headers} + +object Publish extends StrictLogging { + + def apply(request: Request, oldState: ServerState, config: Config): Result = { + def jsonBody = JsonUtils.parseJsonString(request.getBody.valueAsString()) + def consumer: Option[String] = getVarFromJson("consumer", jsonBody) + def consumerVersion: Option[String] = getVarFromJson("consumerVersion", jsonBody) + def provider: Option[String] = getVarFromJson("provider", jsonBody) + def broker: Option[String] = getBrokerUrlFromConfig(config) + def authToken: Option[String] = getVarFromConfig(config.authToken) + + var response = new Response(500, ResponseUtils.CrossSiteHeaders.asJava) + if (broker.isDefined) { + if (consumer.isDefined && consumerVersion.isDefined && provider.isDefined) { + response = publishPact(consumer.get, consumerVersion.get, provider.get, broker.get, authToken) + } else { + def errorJson: String = "{\"error\": \"body should contain consumer, consumerVersion and provider.\"}" + def body: OptionalBody = OptionalBody.body(errorJson.getBytes()) + response = new Response(400, ResponseUtils.CrossSiteHeaders.asJava, body) + } + } else { + def errorJson: String = "{\"error\" : \"Broker url not correctly configured please run server with -b or --broker 'http://pact-broker.adomain.com' option\" }" + def body: OptionalBody = OptionalBody.body(errorJson.getBytes()) + response = new Response(500, ResponseUtils.CrossSiteHeaders.asJava, body) + } + Result(response, oldState) + } + + private def publishPact(consumer: String, consumerVersion: String, provider: String, broker: String, authToken: Option[String]) = { + def fileName: String = s"${consumer}-${provider}.json" + + logger.debug("Publishing pact with following details: ") + logger.debug("Consumer: " + consumer) + logger.debug("ConsumerVersion: " + consumerVersion) + logger.debug("Provider: " + provider) + logger.debug("Pact Broker: " + broker) + + try { + val content = readContract(fileName) + def url = s"${broker}/pacts/provider/${provider}/consumer/${consumer}/version/${consumerVersion}" + var auth: RequestAuth = RequestAuth.Empty + if (authToken.isDefined) { + auth = RequestAuth.Bearer(authToken.get) + } + def response = requests.put( + url, + auth, + headers = Map("Content-Type" -> "application/json", "Accept" -> "application/hal+json, application/json, */*; q=0.01"), + data = content + ) + logger.debug("Statuscode from broker: " + response.statusCode) + if(response.statusCode > 199 && response.statusCode < 400) { + logger.debug("Pact succesfully shared. deleting file..") + removePact(fileName) + } + new Response(response.statusCode, ResponseUtils.CrossSiteHeaders.asJava, OptionalBody.body(response.data.array)) + } catch { + case e: IOException => new Response(500, ResponseUtils.CrossSiteHeaders.asJava, OptionalBody.body(s"""{"error": "Got IO Exception while reading file. ${e.getMessage}"}""".getBytes())) + case e: RequestFailedException => new Response(e.response.statusCode, ResponseUtils.CrossSiteHeaders.asJava, OptionalBody.body(e.response.data.array)) + case _ => new Response(500, ResponseUtils.CrossSiteHeaders.asJava, OptionalBody.body("Something unknown happened..".getBytes())) + } + } + + private def removePact(fileName: String): Unit = { + def file = new File(s"${System.getProperty("pact.rootDir", "target/pacts")}/$fileName") + if (file.exists()) { + file.delete() + } + } + + private def getVarFromConfig(variable: String) = { + if (!variable.isEmpty) Some(variable) + else None + } + + def getBrokerUrlFromConfig(config: Config): Option[String] = { + if (!config.broker.isEmpty && config.broker.startsWith("http")) Some(config.broker) + else None + } + + private def getVarFromJson(variable: String, json: Any): Option[String] = json match { + case map: Map[AnyRef, AnyRef] => { + if (map.contains(variable)) Some(map(variable).toString) + else None + } + case _ => None + } + + def readContract(fileName: String): String = { + def filePath = s"${System.getProperty("pact.rootDir", "target/pacts")}/$fileName" + def fileReader = Source.fromFile(filePath) + def content = fileReader.getLines().mkString + fileReader.close() + logger.debug(content) + content + } +} diff --git a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/RequestRouter.scala b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/RequestRouter.scala index 79512b2976..ae46021f98 100644 --- a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/RequestRouter.scala +++ b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/RequestRouter.scala @@ -34,6 +34,7 @@ object RequestRouter { action match { case "create" => Create(request, oldState, config) case "complete" => Complete(request, oldState) + case "publish" => Publish(request, oldState, config) case "" => ListServers(oldState) case _ => Result(pactDispatch(request, oldState), oldState) } diff --git a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Server.scala b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Server.scala index 9fd0e9b626..576ab924b4 100644 --- a/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Server.scala +++ b/pact-jvm-server/src/main/scala/au/com/dius/pact/server/Server.scala @@ -27,7 +27,10 @@ case class Config(port: Int = 29999, pactVersion: Int = 2, keystorePath: String = "", keystorePassword: String = "", - sslPort : Int = 8443) + sslPort : Int = 8443, + broker: String = "", + authToken: String = "" + ) object Server extends App { @@ -43,6 +46,8 @@ object Server extends App { opt[String]('k', "keystore-path") action { (x, c) => c.copy(keystorePath = x) } text("Path to keystore") opt[String]('p', "keystore-password") action { (x, c) => c.copy(keystorePassword = x) } text("Keystore password") opt[Int]('s', "ssl-port") action { (x, c) => c.copy(sslPort = x) } text("Ssl port the mock server should run on. lower and upper bounds are ignored") + opt[String]('b', "broker") action {(x, c) => c.copy(broker = x)} text("URL of broker where to publish contracts to") + opt[String]('t', "token") action {(x, c) => c.copy(authToken = x)} text("Auth token for publishing the pact to broker") } parser.parse(args, Config()) match { diff --git a/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/CreateSpec.groovy b/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/CreateSpec.groovy index 3a36257d02..8e0c7e2ec8 100644 --- a/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/CreateSpec.groovy +++ b/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/CreateSpec.groovy @@ -17,7 +17,7 @@ class CreateSpec extends Specification { JavaConverters.asScalaBuffer(['/data']).toList(), pact, new scala.collection.immutable.HashMap(), new au.com.dius.pact.server.Config(4444, 'localhost', false, 20000, 40000, true, - 2, '', '', 8444)) + 2, '', '', 8444, '', '')) then: result.response().status == 201 @@ -44,7 +44,7 @@ class CreateSpec extends Specification { JavaConverters.asScalaBuffer([]).toList(), pact, new scala.collection.immutable.HashMap(), new au.com.dius.pact.server.Config(4444, 'localhost', false, 20000, 40000, true, - 2, keystorePath, password, 8444)) + 2, keystorePath, password, 8444, '', '')) then: result.response().status == 201 diff --git a/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/PublishSpec.groovy b/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/PublishSpec.groovy new file mode 100644 index 0000000000..bcc8eb3a2e --- /dev/null +++ b/pact-jvm-server/src/test/groovy/au/com/dius/pact/server/PublishSpec.groovy @@ -0,0 +1,58 @@ +package au.com.dius.pact.server + +import au.com.dius.pact.server.Config +import au.com.dius.pact.server.Publish +import spock.lang.Specification + +class PublishSpec extends Specification { + + def 'invalid broker url in config will not set broker'() { + given: + def config = new Config(80, '0.0.0.0', false, 100, 200, false, 3, '', '', 0, 'invalid', 'abc#3') +// def pact = PublishSpec.getResourceAsStream('/create-pact.json').text + + when: + def result = Publish.getBrokerUrlFromConfig(config) + + then: + !result.isDefined() + } + + def 'valid broker url will set broker'() { + given: + def config = new Config(80, '0.0.0.0', false, 100, 200, false, 3, '', '', 0, 'https://valid.broker.com', 'abc#3') + + when: + def result = Publish.getBrokerUrlFromConfig(config) + + then: + result.isDefined() + } + + def 'successful read on valid file'() { + given: + def content = """ {"consumer": "testconsumer", "provider": "testprovider"} """ + def fileName = "test.json" + def rootDir = System.getProperty("pact.rootDir", "target/pacts") + def file = new File("${rootDir}/$fileName") + new File(rootDir).mkdirs() + file.write(content) + + when: + def result = Publish.readContract(fileName) + + then: + content == result + + cleanup: + new File(System.getProperty("pact.rootDir", "target")).deleteDir() + } + + def 'unsuccessful read on invalid file'() { + when: + Publish.readContract("invalid") + + then: + thrown(IOException) + } +}