From 7027da832458d7a9b2d8bde4351cf64bdc34eaac Mon Sep 17 00:00:00 2001 From: Ronald Holshausen Date: Sun, 22 Mar 2020 14:43:26 +1100 Subject: [PATCH] feat: implemented pact broker client support for provider-pacts-for-verification endpoint #942 --- core/pact-broker/build.gradle | 2 +- .../dius/pact/core/pactbroker/HalClient.kt | 66 ++++++++++--- .../pact/core/pactbroker/PactBrokerClient.kt | 59 ++++++++++++ .../core/pactbroker/PactBrokerConsumer.kt | 29 ++++++ .../pactbroker/PactBrokerClientSpec.groovy | 95 +++++++++++++++++++ core/support/build.gradle | 1 + .../core/support/KotlinLanguageSupport.kt | 10 ++ 7 files changed, 250 insertions(+), 12 deletions(-) diff --git a/core/pact-broker/build.gradle b/core/pact-broker/build.gradle index e468d2c81b..ff7d19743d 100644 --- a/core/pact-broker/build.gradle +++ b/core/pact-broker/build.gradle @@ -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}" diff --git a/core/pact-broker/src/main/kotlin/au/com/dius/pact/core/pactbroker/HalClient.kt b/core/pact-broker/src/main/kotlin/au/com/dius/pact/core/pactbroker/HalClient.kt index 0f9023be9d..01a86fb183 100644 --- a/core/pact-broker/src/main/kotlin/au/com/dius/pact/core/pactbroker/HalClient.kt +++ b/core/pact-broker/src/main/kotlin/au/com/dius/pact/core/pactbroker/HalClient.kt @@ -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 @@ -136,12 +138,31 @@ interface IHalClient { */ fun withDocContext(docAttributes: Map): 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, json: String): Result + /** + * Upload a JSON document to the current path link, using a POST request + */ + fun postJson(link: String, options: Map, json: String): Either + + /** + * Get JSON from the provided path + */ fun getJson(path: String): Result + + /** + * 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 } @@ -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 { @@ -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}'") } } } @@ -483,6 +513,20 @@ open class HalClient @JvmOverloads constructor( } } + override fun postJson(link: String, options: Map, json: String): Either { + 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" diff --git a/core/pact-broker/src/main/kotlin/au/com/dius/pact/core/pactbroker/PactBrokerClient.kt b/core/pact-broker/src/main/kotlin/au/com/dius/pact/core/pactbroker/PactBrokerClient.kt index 7828ef6150..b88ba6f01e 100644 --- a/core/pact-broker/src/main/kotlin/au/com/dius/pact/core/pactbroker/PactBrokerClient.kt +++ b/core/pact-broker/src/main/kotlin/au/com/dius/pact/core/pactbroker/PactBrokerClient.kt @@ -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 @@ -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 */ @@ -76,6 +85,8 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map { return try { val halClient = newHalClient() @@ -99,6 +110,8 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map { return try { val halClient = newHalClient() @@ -120,6 +133,48 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map): Either> { + 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, 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 */ @@ -268,6 +323,8 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map { return try { val halClient = newHalClient() @@ -342,6 +399,8 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map = listOf(), val tag: String? = null ) + +data class PactResult( + val name: String, + val source: String, + val pactBrokerUrl: String, + val pactFileAuthentication: List = listOf(), + val notices: List +) { + 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"])) + } + } +} diff --git a/core/pact-broker/src/test/groovy/au/com/dius/pact/core/pactbroker/PactBrokerClientSpec.groovy b/core/pact-broker/src/test/groovy/au/com/dius/pact/core/pactbroker/PactBrokerClientSpec.groovy index 2b64afd04b..b4a154d956 100644 --- a/core/pact-broker/src/test/groovy/au/com/dius/pact/core/pactbroker/PactBrokerClientSpec.groovy +++ b/core/pact-broker/src/test/groovy/au/com/dius/pact/core/pactbroker/PactBrokerClientSpec.groovy @@ -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 @@ -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 + } } diff --git a/core/support/build.gradle b/core/support/build.gradle index d262e645b8..e82276b950 100644 --- a/core/support/build.gradle +++ b/core/support/build.gradle @@ -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}" diff --git a/core/support/src/main/kotlin/au/com/dius/pact/core/support/KotlinLanguageSupport.kt b/core/support/src/main/kotlin/au/com/dius/pact/core/support/KotlinLanguageSupport.kt index a2e0e326db..5d02399f02 100644 --- a/core/support/src/main/kotlin/au/com/dius/pact/core/support/KotlinLanguageSupport.kt +++ b/core/support/src/main/kotlin/au/com/dius/pact/core/support/KotlinLanguageSupport.kt @@ -4,6 +4,7 @@ import java.lang.Integer.max import java.net.URL import kotlin.reflect.KProperty1 import kotlin.reflect.full.memberProperties +import arrow.core.Either public fun String?.isNotEmpty(): Boolean = !this.isNullOrEmpty() @@ -26,3 +27,12 @@ public fun String?.toUrl() = if (this.isNullOrEmpty()) { } else { URL(this) } + +public fun handleWith(f: () -> Any): Either { + return try { + val result = f() + if (result is Either<*, *>) result as Either else Either.right(result as F) + } catch (ex: Exception) { + Either.left(ex) + } +}