Skip to content

Commit 7027da8

Browse files
author
Ronald Holshausen
committedMar 22, 2020
feat: implemented pact broker client support for provider-pacts-for-verification endpoint #942
1 parent 85c3bdb commit 7027da8

File tree

7 files changed

+250
-12
lines changed

7 files changed

+250
-12
lines changed
 

‎core/pact-broker/build.gradle

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ dependencies {
55
compile 'com.github.salomonbrys.kotson:kotson:2.5.0'
66
compile "com.google.guava:guava:${project.guavaVersion}"
77
compile 'org.dmfs:rfc3986-uri:0.8'
8-
98
compile('io.github.microutils:kotlin-logging:1.6.26') {
109
exclude group: 'org.jetbrains.kotlin'
1110
}
1211
implementation "org.slf4j:slf4j-api:${project.slf4jVersion}"
12+
api 'io.arrow-kt:arrow-core-extensions:0.9.0'
1313

1414
testRuntime "org.junit.vintage:junit-vintage-engine:${project.junit5Version}"
1515
testCompile "ch.qos.logback:logback-classic:${project.logbackVersion}"

‎core/pact-broker/src/main/kotlin/au/com/dius/pact/core/pactbroker/HalClient.kt

+55-11
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import java.net.URI
4040
import java.net.URLDecoder
4141
import java.util.function.BiFunction
4242
import java.util.function.Consumer
43+
import arrow.core.Either
44+
import au.com.dius.pact.core.support.handleWith
4345

4446
/**
4547
* Interface to a HAL Client
@@ -136,12 +138,31 @@ interface IHalClient {
136138
*/
137139
fun withDocContext(docAttributes: Map<String, Any?>): IHalClient
138140

141+
/**
142+
* Sets the starting context from a previous broker interaction (Pact document)
143+
*/
144+
fun withDocContext(docAttributes: JsonElement): IHalClient
145+
139146
/**
140147
* Upload a JSON document to the current path link, using a PUT request
141148
*/
142149
fun putJson(link: String, options: Map<String, Any>, json: String): Result<Boolean, Exception>
143150

151+
/**
152+
* Upload a JSON document to the current path link, using a POST request
153+
*/
154+
fun postJson(link: String, options: Map<String, Any>, json: String): Either<Exception, JsonElement>
155+
156+
/**
157+
* Get JSON from the provided path
158+
*/
144159
fun getJson(path: String): Result<JsonElement, Exception>
160+
161+
/**
162+
* Get JSON from the provided path
163+
* @param path Path to fetch the JSON document from
164+
* @param encodePath If the path should be encoded
165+
*/
145166
fun getJson(path: String, encodePath: Boolean): Result<JsonElement, Exception>
146167
}
147168

@@ -263,6 +284,11 @@ open class HalClient @JvmOverloads constructor(
263284
return this
264285
}
265286

287+
override fun withDocContext(json: JsonElement): IHalClient {
288+
pathInfo = json
289+
return this
290+
}
291+
266292
override fun getJson(path: String) = getJson(path, true)
267293

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

275301
val response = httpClient!!.execute(httpGet, httpContext)
276-
if (response.statusLine.statusCode < 300) {
277-
val contentType = ContentType.getOrDefault(response.entity)
278-
if (isJsonResponse(contentType)) {
279-
return@of JsonParser.parseString(EntityUtils.toString(response.entity))
280-
} else {
281-
throw InvalidHalResponse("Expected a HAL+JSON response from the pact broker, but got '$contentType'")
282-
}
302+
return@of handleHalResponse(response, path)
303+
}
304+
}
305+
306+
private fun handleHalResponse(response: CloseableHttpResponse, path: String): JsonElement {
307+
if (response.statusLine.statusCode < 300) {
308+
val contentType = ContentType.getOrDefault(response.entity)
309+
if (isJsonResponse(contentType)) {
310+
return JsonParser.parseString(EntityUtils.toString(response.entity))
283311
} else {
284-
when (response.statusLine.statusCode) {
285-
404 -> throw NotFoundHalResponse("No HAL document found at path '$path'")
286-
else -> throw RequestFailedException("Request to path '$path' failed with response '${response.statusLine}'")
287-
}
312+
throw InvalidHalResponse("Expected a HAL+JSON response from the pact broker, but got '$contentType'")
313+
}
314+
} else {
315+
when (response.statusLine.statusCode) {
316+
404 -> throw NotFoundHalResponse("No HAL document found at path '$path'")
317+
else -> throw RequestFailedException("Request to path '$path' failed with response '${response.statusLine}'")
288318
}
289319
}
290320
}
@@ -483,6 +513,20 @@ open class HalClient @JvmOverloads constructor(
483513
}
484514
}
485515

516+
override fun postJson(link: String, options: Map<String, Any>, json: String): Either<Exception, JsonElement> {
517+
val href = hrefForLink(link, options)
518+
val http = initialiseRequest(HttpPost(buildUrl(baseUrl, href.first, href.second)))
519+
http.addHeader("Content-Type", ContentType.APPLICATION_JSON.toString())
520+
http.addHeader("Accept", "application/hal+json, application/json")
521+
http.entity = StringEntity(json, ContentType.APPLICATION_JSON)
522+
523+
return handleWith {
524+
httpClient!!.execute(http, httpContext).use {
525+
return@handleWith handleHalResponse(it, href.first)
526+
}
527+
}
528+
}
529+
486530
companion object : KLogging() {
487531
const val ROOT = "/"
488532
const val LINKS = "_links"

‎core/pact-broker/src/main/kotlin/au/com/dius/pact/core/pactbroker/PactBrokerClient.kt

+59
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package au.com.dius.pact.core.pactbroker
22

3+
import arrow.core.Either
34
import au.com.dius.pact.com.github.michaelbull.result.Err
45
import au.com.dius.pact.com.github.michaelbull.result.Ok
56
import au.com.dius.pact.com.github.michaelbull.result.Result
67
import au.com.dius.pact.core.support.Json
8+
import au.com.dius.pact.core.support.handleWith
79
import au.com.dius.pact.core.support.isNotEmpty
810
import com.github.salomonbrys.kotson.get
911
import com.github.salomonbrys.kotson.jsonArray
@@ -66,6 +68,13 @@ sealed class Latest {
6668

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

71+
/**
72+
* Consumer version selector. See https://docs.pact.io/pact_broker/advanced_topics/selectors
73+
*/
74+
data class ConsumerVersionSelector(val tag: String, val latest: Boolean = true) {
75+
fun toJson() = jsonObject("tag" to tag, "latest" to latest)
76+
}
77+
6978
/**
7079
* Client for the pact broker service
7180
*/
@@ -76,6 +85,8 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map<String,
7685
/**
7786
* Fetches all consumers for the given provider
7887
*/
88+
@Deprecated(message = "Use the version that takes selectors instead",
89+
replaceWith = ReplaceWith("fetchConsumersWithSelectors"))
7990
open fun fetchConsumers(provider: String): List<PactBrokerConsumer> {
8091
return try {
8192
val halClient = newHalClient()
@@ -99,6 +110,8 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map<String,
99110
/**
100111
* Fetches all consumers for the given provider and tag
101112
*/
113+
@Deprecated(message = "Use the version that takes selectors instead",
114+
replaceWith = ReplaceWith("fetchConsumersWithSelectors"))
102115
open fun fetchConsumersWithTag(provider: String, tag: String): List<PactBrokerConsumer> {
103116
return try {
104117
val halClient = newHalClient()
@@ -120,6 +133,48 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map<String,
120133
}
121134
}
122135

136+
/**
137+
* Fetches all consumers for the given provider and selectors
138+
*/
139+
open fun fetchConsumersWithSelectors(provider: String, consumerVersionSelectors: List<ConsumerVersionSelector>): Either<Exception, List<PactResult>> {
140+
val halClient = newHalClient()
141+
val pactsForVerification = when {
142+
halClient.linkUrl(PROVIDER_PACTS_FOR_VERIFICATION) != null -> PROVIDER_PACTS_FOR_VERIFICATION
143+
halClient.linkUrl(BETA_PROVIDER_PACTS_FOR_VERIFICATION) != null -> BETA_PROVIDER_PACTS_FOR_VERIFICATION
144+
else -> null
145+
}
146+
if (pactsForVerification != null) {
147+
val body = jsonObject(
148+
"consumerVersionSelectors" to jsonArray(consumerVersionSelectors.map { it.toJson() })
149+
)
150+
return handleWith {
151+
halClient.postJson(pactsForVerification, mapOf("provider" to provider), body.toString()).map { result ->
152+
result["_embedded"]["pacts"].asJsonArray.map { pactJson ->
153+
val selfLink = pactJson["_links"]["self"]
154+
val href = Precoded(Json.toString(selfLink["href"])).decoded().toString()
155+
val name = Json.toString(selfLink["name"])
156+
val notices = pactJson["verificationProperties"]["notices"].asJsonArray
157+
.map { VerificationNotice.fromJson(it) }
158+
if (options.containsKey("authentication")) {
159+
PactResult(name, href, pactBrokerUrl, options["authentication"] as List<String>, notices)
160+
} else {
161+
PactResult(name, href, pactBrokerUrl, emptyList(), notices)
162+
}
163+
}
164+
}
165+
}
166+
} else {
167+
return handleWith {
168+
if (consumerVersionSelectors.isEmpty()) {
169+
fetchConsumers(provider).map { PactResult.fromConsumer(it) }
170+
} else {
171+
fetchConsumersWithTag(provider, consumerVersionSelectors.first().tag)
172+
.map { PactResult.fromConsumer(it) }
173+
}
174+
}
175+
}
176+
}
177+
123178
/**
124179
* Uploads the given pact file to the broker, and optionally applies any tags
125180
*/
@@ -268,6 +323,8 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map<String,
268323
/**
269324
* Fetches the consumers of the provider that have no associated tag
270325
*/
326+
@Deprecated(message = "Use the version that takes selectors instead",
327+
replaceWith = ReplaceWith("fetchConsumersWithSelectors"))
271328
open fun fetchLatestConsumersWithNoTag(provider: String): List<PactBrokerConsumer> {
272329
return try {
273330
val halClient = newHalClient()
@@ -342,6 +399,8 @@ open class PactBrokerClient(val pactBrokerUrl: String, val options: Map<String,
342399
const val LATEST_PROVIDER_PACTS_WITH_NO_TAG = "pb:latest-untagged-pact-version"
343400
const val LATEST_PROVIDER_PACTS = "pb:latest-provider-pacts"
344401
const val LATEST_PROVIDER_PACTS_WITH_TAG = "pb:latest-provider-pacts-with-tag"
402+
const val PROVIDER_PACTS_FOR_VERIFICATION = "pb:provider-pacts-for-verification"
403+
const val BETA_PROVIDER_PACTS_FOR_VERIFICATION = "beta:provider-pacts-for-verification"
345404
const val PROVIDER = "pb:provider"
346405
const val PROVIDER_TAG_VERSION = "pb:version-tag"
347406
const val PACTS = "pb:pacts"
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,38 @@
11
package au.com.dius.pact.core.pactbroker
22

3+
import au.com.dius.pact.core.support.Json
4+
import com.google.gson.JsonElement
5+
6+
@Deprecated(message = "Use PactResult instead", replaceWith = ReplaceWith("PactResult"))
37
data class PactBrokerConsumer @JvmOverloads constructor (
48
val name: String,
59
val source: String,
610
val pactBrokerUrl: String,
711
val pactFileAuthentication: List<String> = listOf(),
812
val tag: String? = null
913
)
14+
15+
data class PactResult(
16+
val name: String,
17+
val source: String,
18+
val pactBrokerUrl: String,
19+
val pactFileAuthentication: List<String> = listOf(),
20+
val notices: List<VerificationNotice>
21+
) {
22+
companion object {
23+
fun fromConsumer(consumer: PactBrokerConsumer) =
24+
PactResult(consumer.name, consumer.source, consumer.pactBrokerUrl, consumer.pactFileAuthentication, emptyList())
25+
}
26+
}
27+
28+
data class VerificationNotice(
29+
val `when`: String,
30+
val text: String
31+
) {
32+
companion object {
33+
fun fromJson(json: JsonElement): VerificationNotice {
34+
val jsonObj = json.asJsonObject
35+
return VerificationNotice(Json.toString(jsonObj["when"]), Json.toString(jsonObj["text"]))
36+
}
37+
}
38+
}

‎core/pact-broker/src/test/groovy/au/com/dius/pact/core/pactbroker/PactBrokerClientSpec.groovy

+95
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package au.com.dius.pact.core.pactbroker
22

3+
import arrow.core.Either
34
import au.com.dius.pact.com.github.michaelbull.result.Err
45
import au.com.dius.pact.com.github.michaelbull.result.Ok
56
import au.com.dius.pact.core.support.Json
67
import com.google.gson.JsonArray
78
import com.google.gson.JsonObject
9+
import com.google.gson.JsonParser
810
import kotlin.Pair
911
import kotlin.collections.MapsKt
1012
import spock.lang.Issue
@@ -310,4 +312,97 @@ class PactBrokerClientSpec extends Specification {
310312
expect:
311313
client.publishVerificationResults(doc, result, '0', null) == uploadResult
312314
}
315+
316+
@SuppressWarnings('LineLength')
317+
def 'fetching pacts with selectors uses the provider-pacts-for-verification link and returns a list of results'() {
318+
given:
319+
def halClient = Mock(IHalClient)
320+
PactBrokerClient client = Spy(PactBrokerClient, constructorArgs: ['baseUrl']) {
321+
newHalClient() >> halClient
322+
}
323+
def selectors = [ new ConsumerVersionSelector('DEV', true) ]
324+
def json = '{"consumerVersionSelectors":[{"tag":"DEV","latest":true}]}'
325+
def jsonResult = JsonParser.parseString('''
326+
{
327+
"_embedded": {
328+
"pacts": [
329+
{
330+
"shortDescription": "latest DEV",
331+
"verificationProperties": {
332+
"notices": [
333+
{
334+
"when": "before_verification",
335+
"text": "The pact at ... is being verified because it matches the following configured selection criterion: latest pact for a consumer version tagged 'DEV'"
336+
}
337+
]
338+
},
339+
"_links": {
340+
"self": {
341+
"href": "https://test.pact.dius.com.au/pacts/provider/Activity%20Service/consumer/Foo%20Web%20Client/pact-version/384826ff3a2856e28dfae553efab302863dcd727",
342+
"name": "Pact between Foo Web Client (1.0.2) and Activity Service"
343+
}
344+
}
345+
}
346+
]
347+
}
348+
}
349+
''')
350+
351+
when:
352+
def result = client.fetchConsumersWithSelectors('provider', selectors)
353+
354+
then:
355+
1 * halClient.linkUrl('pb:provider-pacts-for-verification') >> 'URL'
356+
1 * halClient.postJson('pb:provider-pacts-for-verification', [provider: 'provider'], json) >> new Either.Right(jsonResult)
357+
result.right
358+
result.b.first() == new PactResult('Pact between Foo Web Client (1.0.2) and Activity Service',
359+
'https://test.pact.dius.com.au/pacts/provider/Activity Service/consumer/Foo Web Client/pact-version/384826ff3a2856e28dfae553efab302863dcd727',
360+
'baseUrl', [], [
361+
new VerificationNotice('before_verification',
362+
'The pact at ... is being verified because it matches the following configured selection criterion: latest pact for a consumer version tagged \'DEV\'')
363+
])
364+
}
365+
366+
def 'fetching pacts with selectors falls back to the beta provider-pacts-for-verification link'() {
367+
given:
368+
def halClient = Mock(IHalClient)
369+
PactBrokerClient client = Spy(PactBrokerClient, constructorArgs: ['baseUrl']) {
370+
newHalClient() >> halClient
371+
}
372+
def jsonResult = JsonParser.parseString('''
373+
{
374+
"_embedded": {
375+
"pacts": [
376+
]
377+
}
378+
}
379+
''')
380+
381+
when:
382+
def result = client.fetchConsumersWithSelectors('provider', [])
383+
384+
then:
385+
1 * halClient.linkUrl('pb:provider-pacts-for-verification') >> null
386+
1 * halClient.linkUrl('beta:provider-pacts-for-verification') >> 'URL'
387+
1 * halClient.postJson('beta:provider-pacts-for-verification', _, _) >> new Either.Right(jsonResult)
388+
result.right
389+
}
390+
391+
def 'fetching pacts with selectors falls back to the previous implementation if no link is available'() {
392+
given:
393+
def halClient = Mock(IHalClient)
394+
PactBrokerClient client = Spy(PactBrokerClient, constructorArgs: ['baseUrl']) {
395+
newHalClient() >> halClient
396+
}
397+
398+
when:
399+
def result = client.fetchConsumersWithSelectors('provider', [])
400+
401+
then:
402+
1 * halClient.linkUrl('pb:provider-pacts-for-verification') >> null
403+
1 * halClient.linkUrl('beta:provider-pacts-for-verification') >> null
404+
0 * halClient.postJson(_, _, _)
405+
1 * client.fetchConsumers('provider') >> []
406+
result.right
407+
}
313408
}

‎core/support/build.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ dependencies {
1313
exclude group: 'org.jetbrains.kotlin'
1414
}
1515
api "org.apache.httpcomponents:httpclient:${project.httpClientVersion}"
16+
api 'io.arrow-kt:arrow-core-extensions:0.9.0'
1617

1718
testCompile "org.codehaus.groovy:groovy:${project.groovyVersion}"
1819
testRuntime "org.junit.vintage:junit-vintage-engine:${project.junit5Version}"

‎core/support/src/main/kotlin/au/com/dius/pact/core/support/KotlinLanguageSupport.kt

+10
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import java.lang.Integer.max
44
import java.net.URL
55
import kotlin.reflect.KProperty1
66
import kotlin.reflect.full.memberProperties
7+
import arrow.core.Either
78

89
public fun String?.isNotEmpty(): Boolean = !this.isNullOrEmpty()
910

@@ -26,3 +27,12 @@ public fun String?.toUrl() = if (this.isNullOrEmpty()) {
2627
} else {
2728
URL(this)
2829
}
30+
31+
public fun <F> handleWith(f: () -> Any): Either<Exception, F> {
32+
return try {
33+
val result = f()
34+
if (result is Either<*, *>) result as Either<Exception, F> else Either.right(result as F)
35+
} catch (ex: Exception) {
36+
Either.left(ex)
37+
}
38+
}

0 commit comments

Comments
 (0)
Please sign in to comment.