Skip to content

Commit

Permalink
Added functionality to publish a created contract on disk to a broker…
Browse files Browse the repository at this point in the history
…. Verified with both pactflow as open source pact broker
  • Loading branch information
markozz committed Aug 26, 2020
1 parent 517c4cf commit db8edb2
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 3 deletions.
19 changes: 19 additions & 0 deletions pact-jvm-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -40,6 +41,10 @@ Usage: pact-jvm-server [options] [port]
Keystore password
-s <value> | --ssl-port <value>
Ssl port the mock server should run on. lower and upper bounds are ignored
-b <value> | --broker <value>
The baseUrl of the broker to publish contracts to (for example https://organization.broker.com
-t <value | --token <value>
API token for authentication to the pact broker
--debug
run with debug logging
```
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pact-jvm-server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
107 changes: 107 additions & 0 deletions pact-jvm-server/src/main/scala/au/com/dius/pact/server/Publish.scala
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit db8edb2

Please sign in to comment.