Skip to content

Commit

Permalink
feat: implemented pact broker client support for provider-pacts-for-v…
Browse files Browse the repository at this point in the history
…erification endpoint #942
  • Loading branch information
Ronald Holshausen committed Mar 22, 2020
1 parent 85c3bdb commit 7027da8
Show file tree
Hide file tree
Showing 7 changed files with 250 additions and 12 deletions.
2 changes: 1 addition & 1 deletion core/pact-broker/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ dependencies {
compile 'com.github.salomonbrys.kotson:kotson:2.5.0'
compile "com.google.guava:guava:${project.guavaVersion}"
compile 'org.dmfs:rfc3986-uri:0.8'

compile('io.github.microutils:kotlin-logging:1.6.26') {
exclude group: 'org.jetbrains.kotlin'
}
implementation "org.slf4j:slf4j-api:${project.slf4jVersion}"
api 'io.arrow-kt:arrow-core-extensions:0.9.0'

testRuntime "org.junit.vintage:junit-vintage-engine:${project.junit5Version}"
testCompile "ch.qos.logback:logback-classic:${project.logbackVersion}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import java.net.URI
import java.net.URLDecoder
import java.util.function.BiFunction
import java.util.function.Consumer
import arrow.core.Either
import au.com.dius.pact.core.support.handleWith

/**
* Interface to a HAL Client
Expand Down Expand Up @@ -136,12 +138,31 @@ interface IHalClient {
*/
fun withDocContext(docAttributes: Map<String, Any?>): IHalClient

/**
* Sets the starting context from a previous broker interaction (Pact document)
*/
fun withDocContext(docAttributes: JsonElement): IHalClient

/**
* Upload a JSON document to the current path link, using a PUT request
*/
fun putJson(link: String, options: Map<String, Any>, json: String): Result<Boolean, Exception>

/**
* Upload a JSON document to the current path link, using a POST request
*/
fun postJson(link: String, options: Map<String, Any>, json: String): Either<Exception, JsonElement>

/**
* Get JSON from the provided path
*/
fun getJson(path: String): Result<JsonElement, Exception>

/**
* Get JSON from the provided path
* @param path Path to fetch the JSON document from
* @param encodePath If the path should be encoded
*/
fun getJson(path: String, encodePath: Boolean): Result<JsonElement, Exception>
}

Expand Down Expand Up @@ -263,6 +284,11 @@ open class HalClient @JvmOverloads constructor(
return this
}

override fun withDocContext(json: JsonElement): IHalClient {
pathInfo = json
return this
}

override fun getJson(path: String) = getJson(path, true)

override fun getJson(path: String, encodePath: Boolean): Result<JsonElement, Exception> {
Expand All @@ -273,18 +299,22 @@ open class HalClient @JvmOverloads constructor(
httpGet.addHeader("Accept", "application/hal+json, application/json")

val response = httpClient!!.execute(httpGet, httpContext)
if (response.statusLine.statusCode < 300) {
val contentType = ContentType.getOrDefault(response.entity)
if (isJsonResponse(contentType)) {
return@of JsonParser.parseString(EntityUtils.toString(response.entity))
} else {
throw InvalidHalResponse("Expected a HAL+JSON response from the pact broker, but got '$contentType'")
}
return@of handleHalResponse(response, path)
}
}

private fun handleHalResponse(response: CloseableHttpResponse, path: String): JsonElement {
if (response.statusLine.statusCode < 300) {
val contentType = ContentType.getOrDefault(response.entity)
if (isJsonResponse(contentType)) {
return JsonParser.parseString(EntityUtils.toString(response.entity))
} else {
when (response.statusLine.statusCode) {
404 -> throw NotFoundHalResponse("No HAL document found at path '$path'")
else -> throw RequestFailedException("Request to path '$path' failed with response '${response.statusLine}'")
}
throw InvalidHalResponse("Expected a HAL+JSON response from the pact broker, but got '$contentType'")
}
} else {
when (response.statusLine.statusCode) {
404 -> throw NotFoundHalResponse("No HAL document found at path '$path'")
else -> throw RequestFailedException("Request to path '$path' failed with response '${response.statusLine}'")
}
}
}
Expand Down Expand Up @@ -483,6 +513,20 @@ open class HalClient @JvmOverloads constructor(
}
}

override fun postJson(link: String, options: Map<String, Any>, json: String): Either<Exception, JsonElement> {
val href = hrefForLink(link, options)
val http = initialiseRequest(HttpPost(buildUrl(baseUrl, href.first, href.second)))
http.addHeader("Content-Type", ContentType.APPLICATION_JSON.toString())
http.addHeader("Accept", "application/hal+json, application/json")
http.entity = StringEntity(json, ContentType.APPLICATION_JSON)

return handleWith {
httpClient!!.execute(http, httpContext).use {
return@handleWith handleHalResponse(it, href.first)
}
}
}

companion object : KLogging() {
const val ROOT = "/"
const val LINKS = "_links"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package au.com.dius.pact.core.pactbroker

import arrow.core.Either
import au.com.dius.pact.com.github.michaelbull.result.Err
import au.com.dius.pact.com.github.michaelbull.result.Ok
import au.com.dius.pact.com.github.michaelbull.result.Result
import au.com.dius.pact.core.support.Json
import au.com.dius.pact.core.support.handleWith
import au.com.dius.pact.core.support.isNotEmpty
import com.github.salomonbrys.kotson.get
import com.github.salomonbrys.kotson.jsonArray
Expand Down Expand Up @@ -66,6 +68,13 @@ sealed class Latest {

data class CanIDeployResult(val ok: Boolean, val message: String, val reason: String)

/**
* Consumer version selector. See https://docs.pact.io/pact_broker/advanced_topics/selectors
*/
data class ConsumerVersionSelector(val tag: String, val latest: Boolean = true) {
fun toJson() = jsonObject("tag" to tag, "latest" to latest)
}

/**
* Client for the pact broker service
*/
Expand All @@ -76,6 +85,8 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map<String,
/**
* Fetches all consumers for the given provider
*/
@Deprecated(message = "Use the version that takes selectors instead",
replaceWith = ReplaceWith("fetchConsumersWithSelectors"))
open fun fetchConsumers(provider: String): List<PactBrokerConsumer> {
return try {
val halClient = newHalClient()
Expand All @@ -99,6 +110,8 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map<String,
/**
* Fetches all consumers for the given provider and tag
*/
@Deprecated(message = "Use the version that takes selectors instead",
replaceWith = ReplaceWith("fetchConsumersWithSelectors"))
open fun fetchConsumersWithTag(provider: String, tag: String): List<PactBrokerConsumer> {
return try {
val halClient = newHalClient()
Expand All @@ -120,6 +133,48 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map<String,
}
}

/**
* Fetches all consumers for the given provider and selectors
*/
open fun fetchConsumersWithSelectors(provider: String, consumerVersionSelectors: List<ConsumerVersionSelector>): Either<Exception, List<PactResult>> {
val halClient = newHalClient()
val pactsForVerification = when {
halClient.linkUrl(PROVIDER_PACTS_FOR_VERIFICATION) != null -> PROVIDER_PACTS_FOR_VERIFICATION
halClient.linkUrl(BETA_PROVIDER_PACTS_FOR_VERIFICATION) != null -> BETA_PROVIDER_PACTS_FOR_VERIFICATION
else -> null
}
if (pactsForVerification != null) {
val body = jsonObject(
"consumerVersionSelectors" to jsonArray(consumerVersionSelectors.map { it.toJson() })
)
return handleWith {
halClient.postJson(pactsForVerification, mapOf("provider" to provider), body.toString()).map { result ->
result["_embedded"]["pacts"].asJsonArray.map { pactJson ->
val selfLink = pactJson["_links"]["self"]
val href = Precoded(Json.toString(selfLink["href"])).decoded().toString()
val name = Json.toString(selfLink["name"])
val notices = pactJson["verificationProperties"]["notices"].asJsonArray
.map { VerificationNotice.fromJson(it) }
if (options.containsKey("authentication")) {
PactResult(name, href, pactBrokerUrl, options["authentication"] as List<String>, notices)
} else {
PactResult(name, href, pactBrokerUrl, emptyList(), notices)
}
}
}
}
} else {
return handleWith {
if (consumerVersionSelectors.isEmpty()) {
fetchConsumers(provider).map { PactResult.fromConsumer(it) }
} else {
fetchConsumersWithTag(provider, consumerVersionSelectors.first().tag)
.map { PactResult.fromConsumer(it) }
}
}
}
}

/**
* Uploads the given pact file to the broker, and optionally applies any tags
*/
Expand Down Expand Up @@ -268,6 +323,8 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map<String,
/**
* Fetches the consumers of the provider that have no associated tag
*/
@Deprecated(message = "Use the version that takes selectors instead",
replaceWith = ReplaceWith("fetchConsumersWithSelectors"))
open fun fetchLatestConsumersWithNoTag(provider: String): List<PactBrokerConsumer> {
return try {
val halClient = newHalClient()
Expand Down Expand Up @@ -342,6 +399,8 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map<String,
const val LATEST_PROVIDER_PACTS_WITH_NO_TAG = "pb:latest-untagged-pact-version"
const val LATEST_PROVIDER_PACTS = "pb:latest-provider-pacts"
const val LATEST_PROVIDER_PACTS_WITH_TAG = "pb:latest-provider-pacts-with-tag"
const val PROVIDER_PACTS_FOR_VERIFICATION = "pb:provider-pacts-for-verification"
const val BETA_PROVIDER_PACTS_FOR_VERIFICATION = "beta:provider-pacts-for-verification"
const val PROVIDER = "pb:provider"
const val PROVIDER_TAG_VERSION = "pb:version-tag"
const val PACTS = "pb:pacts"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,38 @@
package au.com.dius.pact.core.pactbroker

import au.com.dius.pact.core.support.Json
import com.google.gson.JsonElement

@Deprecated(message = "Use PactResult instead", replaceWith = ReplaceWith("PactResult"))
data class PactBrokerConsumer @JvmOverloads constructor (
val name: String,
val source: String,
val pactBrokerUrl: String,
val pactFileAuthentication: List<String> = listOf(),
val tag: String? = null
)

data class PactResult(
val name: String,
val source: String,
val pactBrokerUrl: String,
val pactFileAuthentication: List<String> = listOf(),
val notices: List<VerificationNotice>
) {
companion object {
fun fromConsumer(consumer: PactBrokerConsumer) =
PactResult(consumer.name, consumer.source, consumer.pactBrokerUrl, consumer.pactFileAuthentication, emptyList())
}
}

data class VerificationNotice(
val `when`: String,
val text: String
) {
companion object {
fun fromJson(json: JsonElement): VerificationNotice {
val jsonObj = json.asJsonObject
return VerificationNotice(Json.toString(jsonObj["when"]), Json.toString(jsonObj["text"]))
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package au.com.dius.pact.core.pactbroker

import arrow.core.Either
import au.com.dius.pact.com.github.michaelbull.result.Err
import au.com.dius.pact.com.github.michaelbull.result.Ok
import au.com.dius.pact.core.support.Json
import com.google.gson.JsonArray
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import kotlin.Pair
import kotlin.collections.MapsKt
import spock.lang.Issue
Expand Down Expand Up @@ -310,4 +312,97 @@ class PactBrokerClientSpec extends Specification {
expect:
client.publishVerificationResults(doc, result, '0', null) == uploadResult
}

@SuppressWarnings('LineLength')
def 'fetching pacts with selectors uses the provider-pacts-for-verification link and returns a list of results'() {
given:
def halClient = Mock(IHalClient)
PactBrokerClient client = Spy(PactBrokerClient, constructorArgs: ['baseUrl']) {
newHalClient() >> halClient
}
def selectors = [ new ConsumerVersionSelector('DEV', true) ]
def json = '{"consumerVersionSelectors":[{"tag":"DEV","latest":true}]}'
def jsonResult = JsonParser.parseString('''
{
"_embedded": {
"pacts": [
{
"shortDescription": "latest DEV",
"verificationProperties": {
"notices": [
{
"when": "before_verification",
"text": "The pact at ... is being verified because it matches the following configured selection criterion: latest pact for a consumer version tagged 'DEV'"
}
]
},
"_links": {
"self": {
"href": "https://test.pact.dius.com.au/pacts/provider/Activity%20Service/consumer/Foo%20Web%20Client/pact-version/384826ff3a2856e28dfae553efab302863dcd727",
"name": "Pact between Foo Web Client (1.0.2) and Activity Service"
}
}
}
]
}
}
''')

when:
def result = client.fetchConsumersWithSelectors('provider', selectors)

then:
1 * halClient.linkUrl('pb:provider-pacts-for-verification') >> 'URL'
1 * halClient.postJson('pb:provider-pacts-for-verification', [provider: 'provider'], json) >> new Either.Right(jsonResult)
result.right
result.b.first() == new PactResult('Pact between Foo Web Client (1.0.2) and Activity Service',
'https://test.pact.dius.com.au/pacts/provider/Activity Service/consumer/Foo Web Client/pact-version/384826ff3a2856e28dfae553efab302863dcd727',
'baseUrl', [], [
new VerificationNotice('before_verification',
'The pact at ... is being verified because it matches the following configured selection criterion: latest pact for a consumer version tagged \'DEV\'')
])
}

def 'fetching pacts with selectors falls back to the beta provider-pacts-for-verification link'() {
given:
def halClient = Mock(IHalClient)
PactBrokerClient client = Spy(PactBrokerClient, constructorArgs: ['baseUrl']) {
newHalClient() >> halClient
}
def jsonResult = JsonParser.parseString('''
{
"_embedded": {
"pacts": [
]
}
}
''')

when:
def result = client.fetchConsumersWithSelectors('provider', [])

then:
1 * halClient.linkUrl('pb:provider-pacts-for-verification') >> null
1 * halClient.linkUrl('beta:provider-pacts-for-verification') >> 'URL'
1 * halClient.postJson('beta:provider-pacts-for-verification', _, _) >> new Either.Right(jsonResult)
result.right
}

def 'fetching pacts with selectors falls back to the previous implementation if no link is available'() {
given:
def halClient = Mock(IHalClient)
PactBrokerClient client = Spy(PactBrokerClient, constructorArgs: ['baseUrl']) {
newHalClient() >> halClient
}

when:
def result = client.fetchConsumersWithSelectors('provider', [])

then:
1 * halClient.linkUrl('pb:provider-pacts-for-verification') >> null
1 * halClient.linkUrl('beta:provider-pacts-for-verification') >> null
0 * halClient.postJson(_, _, _)
1 * client.fetchConsumers('provider') >> []
result.right
}
}
1 change: 1 addition & 0 deletions core/support/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies {
exclude group: 'org.jetbrains.kotlin'
}
api "org.apache.httpcomponents:httpclient:${project.httpClientVersion}"
api 'io.arrow-kt:arrow-core-extensions:0.9.0'

testCompile "org.codehaus.groovy:groovy:${project.groovyVersion}"
testRuntime "org.junit.vintage:junit-vintage-engine:${project.junit5Version}"
Expand Down
Loading

0 comments on commit 7027da8

Please sign in to comment.