Pact Contract (#188)

with this change we're providing support for Pact based contracts. No longer do you have to set up your contracts using the Groovy DSL. In the same way as with the DSL you can use the Pact contracts to generate tests and the stubs on the producer side.

fixes #96
This commit is contained in:
Marcin Grzejszczak
2017-01-13 18:06:32 +01:00
committed by GitHub
parent da6046f342
commit 22a5e44471
114 changed files with 4301 additions and 71 deletions

View File

@@ -19,6 +19,7 @@
<modules>
<module>spring-cloud-contract-converters</module>
<module>spring-cloud-contract-spec-pact</module>
<module>spring-cloud-contract-maven-plugin</module>
<module>spring-cloud-contract-gradle-plugin</module>
</modules>

View File

@@ -24,6 +24,7 @@ import org.springframework.cloud.contract.verifier.config.ContractVerifierConfig
import org.springframework.cloud.contract.verifier.file.ContractFileScanner
import org.springframework.cloud.contract.verifier.file.ContractMetadata
import org.springframework.cloud.contract.verifier.util.NamesUtil
import org.springframework.cloud.contract.verifier.wiremock.DslToWireMockClientConverter
import java.nio.charset.StandardCharsets
import java.nio.file.Files
@@ -64,8 +65,12 @@ class RecursiveFilesConverter {
}
contracts.asMap().entrySet().each { entry ->
entry.value.each { ContractMetadata contract ->
if (log.isDebugEnabled()) {
log.debug("Will create a stub for contract [${contract}]")
}
File sourceFile = contract.path.toFile()
StubGenerator stubGenerator = holder.converterForName(sourceFile.name);
StubGenerator stubGenerator = contract.convertedContract ? holder.firstOrDefault(new DslToWireMockClientConverter()) :
holder.converterForName(sourceFile.name)
try {
String path = sourceFile.path
if (properties.isExcludeBuildFolders() && (matchesPath(path, "target") || matchesPath(path, "build"))) {
@@ -78,7 +83,11 @@ class RecursiveFilesConverter {
return
}
int contractsSize = contract.convertedContract.size()
Map<Contract, String> convertedContent = stubGenerator.convertContents(entry.key.last().toString(), contract)
def entryKey = entry.key
if (log.isDebugEnabled()) {
log.debug("Stub Generator [${stubGenerator}] will convert contents of [${entryKey}]")
}
Map<Contract, String> convertedContent = stubGenerator.convertContents(entryKey.last().toString(), contract)
if (!convertedContent) {
return
}

View File

@@ -25,4 +25,8 @@ class StubGeneratorProvider {
StubGenerator converterForName(String fileName) {
return this.converters.find { it.canHandleFileName(fileName) }
}
StubGenerator firstOrDefault(StubGenerator defaultStubGenerator) {
return this.converters.empty ? defaultStubGenerator : this.converters.first()
}
}

View File

@@ -369,6 +369,16 @@
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-spec-pact</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>au.com.dius</groupId>
<artifactId>pact-jvm-model</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

View File

@@ -16,20 +16,21 @@
*/
package org.springframework.cloud.contract.maven.verifier;
import static io.takari.maven.testing.TestMavenRuntime.newParameter;
import static io.takari.maven.testing.TestResources.assertFilesNotPresent;
import static io.takari.maven.testing.TestResources.assertFilesPresent;
import static org.assertj.core.api.BDDAssertions.then;
import java.io.File;
import org.apache.commons.io.FileUtils;
import org.junit.Rule;
import org.junit.Test;
import org.springframework.util.StringUtils;
import io.takari.maven.testing.TestMavenRuntime;
import io.takari.maven.testing.TestResources;
import static io.takari.maven.testing.TestMavenRuntime.newParameter;
import static io.takari.maven.testing.TestResources.assertFilesNotPresent;
import static io.takari.maven.testing.TestResources.assertFilesPresent;
import static org.assertj.core.api.BDDAssertions.then;
public class PluginUnitTest {
@Rule
@@ -256,4 +257,19 @@ public class PluginUnitTest {
assertFilesPresent(basedir, "target/stubs/contracts/consumer1/Messaging.groovy");
assertFilesPresent(basedir, "target/stubs/contracts/pom.xml");
}
@Test
public void shouldGenerateContractTestsForPactAndMaintainIndents() throws Exception {
File basedir = this.resources.getBasedir("pact");
this.maven.executeMojo(basedir, "generateTests");
assertFilesPresent(basedir,
"target/generated-test-sources/contracts/org/springframework/cloud/contract/verifier/tests/ContractVerifierTest.java");
File test = new File(basedir, "target/generated-test-sources/contracts/org/springframework/cloud/contract/verifier/tests/ContractVerifierTest.java");
String testContents = FileUtils.readFileToString(test);
int countOccurrencesOf = StringUtils
.countOccurrencesOf(testContents, "\t\tMockMvcRequestSpecification");
then(countOccurrencesOf).isEqualTo(4);
}
}

View File

@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2013-2016 the original author or authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframework.cloud.verifier.sample</groupId>
<artifactId>sample-pact-project</artifactId>
<version>0.1</version>
<properties>
<spring.cloud.contract.version>1.1.0.BUILD-SNAPSHOT</spring.cloud.contract.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<configuration>
<baseClassForTests>com.example.FooBase</baseClassForTests>
<baseClassMappings>
<baseClassMapping>
<contractPackageRegex>.*com.*</contractPackageRegex>
<baseClassFQN>com.example.TestBase</baseClassFQN>
</baseClassMapping>
</baseClassMappings>
</configuration>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-spec-pact</artifactId>
<version>${spring.cloud.contract.version}</version>
</dependency>
<dependency>
<groupId>au.com.dius</groupId>
<artifactId>pact-jvm-model</artifactId>
<version>2.4.18</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,42 @@
{
"provider": {
"name": "Provider"
},
"consumer": {
"name": "Consumer"
},
"interactions": [
{
"description": "",
"request": {
"method": "PUT",
"path": "/fraudcheck",
"headers": {
"Content-Type": "application/vnd.fraud.v1+json"
},
"body": {
"clientId": "1234567890",
"loanAmount": 99999
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/vnd.fraud.v1+json;charset=UTF-8"
},
"body": {
"fraudCheckStatus": "FRAUD",
"rejectionReason": "Amount too high"
}
}
}
],
"metadata": {
"pact-specification": {
"version": "2.0.0"
},
"pact-jvm": {
"version": "2.4.18"
}
}
}

View File

@@ -0,0 +1,42 @@
{
"provider": {
"name": "Provider"
},
"consumer": {
"name": "Consumer"
},
"interactions": [
{
"description": "",
"request": {
"method": "PUT",
"path": "/fraudcheck",
"headers": {
"Content-Type": "application/vnd.fraud.v1+json"
},
"body": {
"clientId": "1234567890",
"loanAmount": 123.123
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/vnd.fraud.v1+json;charset=UTF-8"
},
"body": {
"fraudCheckStatus": "OK",
"rejectionReason": null
}
}
}
],
"metadata": {
"pact-specification": {
"version": "2.0.0"
},
"pact-jvm": {
"version": "2.4.18"
}
}
}

View File

@@ -0,0 +1,34 @@
{
"provider": {
"name": "Provider"
},
"consumer": {
"name": "Consumer"
},
"interactions": [
{
"description": "",
"request": {
"method": "GET",
"path": "/drunks"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/vnd.fraud.v1+json;charset=UTF-8"
},
"body": {
"count": 100
}
}
}
],
"metadata": {
"pact-specification": {
"version": "2.0.0"
},
"pact-jvm": {
"version": "2.4.18"
}
}
}

View File

@@ -0,0 +1,34 @@
{
"provider": {
"name": "Provider"
},
"consumer": {
"name": "Consumer"
},
"interactions": [
{
"description": "",
"request": {
"method": "GET",
"path": "/frauds"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/vnd.fraud.v1+json;charset=UTF-8"
},
"body": {
"count": 200
}
}
}
],
"metadata": {
"pact-specification": {
"version": "2.0.0"
},
"pact-jvm": {
"version": "2.4.18"
}
}
}

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-tools</artifactId>
<version>1.1.0.BUILD-SNAPSHOT</version>
<relativePath>..</relativePath>
</parent>
<artifactId>spring-cloud-contract-spec-pact</artifactId>
<packaging>jar</packaging>
<name>Spring Cloud Contract Spec Pact</name>
<description>Spring Cloud Contract Spec Pact</description>
<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-verifier</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy</artifactId>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-nio</artifactId>
</dependency>
<dependency>
<groupId>au.com.dius</groupId>
<artifactId>pact-jvm-model</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>info.solidsoft.spock</groupId>
<artifactId>spock-global-unroll</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>addSources</goal>
<goal>compile</goal>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,318 @@
package org.springframework.cloud.contract.verifier.spec.pact
import au.com.dius.pact.model.BasePact
import au.com.dius.pact.model.Consumer
import au.com.dius.pact.model.Interaction
import au.com.dius.pact.model.OptionalBody
import au.com.dius.pact.model.Pact
import au.com.dius.pact.model.PactReader
import au.com.dius.pact.model.Provider
import au.com.dius.pact.model.Request
import au.com.dius.pact.model.RequestResponseInteraction
import au.com.dius.pact.model.RequestResponsePact
import au.com.dius.pact.model.Response
import groovy.json.JsonOutput
import groovy.transform.CompileStatic
import org.springframework.cloud.contract.spec.Contract
import org.springframework.cloud.contract.spec.ContractConverter
import org.springframework.cloud.contract.spec.internal.BodyMatchers
import org.springframework.cloud.contract.spec.internal.DslProperty
import org.springframework.cloud.contract.spec.internal.ExecutionProperty
import org.springframework.cloud.contract.spec.internal.Headers
import org.springframework.cloud.contract.spec.internal.MatchingType
import org.springframework.cloud.contract.spec.internal.QueryParameters
import org.springframework.cloud.contract.verifier.util.MapConverter
/**
* Converter of JSON PACT file
*
* @author Marcin Grzejszczak
* @since 1.1.0
*/
@CompileStatic
class PactContractConverter implements ContractConverter<Pact> {
private static final String MATCH_KEY = "match"
private static final String REGEX_KEY = "regex"
private static final String MAX_KEY = "max"
private static final String MIN_KEY = "min"
@Override
boolean isAccepted(File file) {
try {
PactReader.loadPact(file)
return true
} catch (Exception e) {
return false
}
}
@Override
Collection<Contract> convertFrom(File file) {
Pact pact = PactReader.loadPact(file)
List<Interaction> interactions = pact.interactions
return interactions.collect { Interaction interaction ->
Contract.make {
if (interaction instanceof RequestResponseInteraction) {
RequestResponseInteraction requestResponseInteraction = (RequestResponseInteraction) interaction
description("$requestResponseInteraction.description${providerState(interaction)}")
request {
method(requestResponseInteraction.request.method)
if (requestResponseInteraction.request.query) {
url(requestResponseInteraction.request.path) {
queryParameters {
requestResponseInteraction.request.query.each { String key, List<String> value ->
value.each { String singleValue ->
parameter(key, singleValue)
}
}
}
}
} else {
url(requestResponseInteraction.request.path)
}
if (requestResponseInteraction.request.headers) {
headers {
requestResponseInteraction.request.headers.each { String key, String value ->
header(key, value)
}
}
}
if (requestResponseInteraction.request.body.state == OptionalBody.State.PRESENT) {
def parsedBody = BasePact.parseBody(requestResponseInteraction.request)
if (parsedBody instanceof Map) {
body(parsedBody as Map)
} else if (parsedBody instanceof List) {
body(parsedBody as List)
} else {
body(parsedBody.toString())
}
}
if (requestResponseInteraction.request?.matchingRules) {
stubMatchers {
requestResponseInteraction.request.matchingRules.each { String key, Map<String, Object> value ->
String keyFromBody = toKeyStartingFromBody(key)
if (value.containsKey(MATCH_KEY)) {
MatchingType matchingType = MatchingType.valueOf((value.get(MATCH_KEY) as String).toUpperCase())
switch (matchingType) {
case MatchingType.EQUALITY:
// equality is checked by default in the standard way
break
case MatchingType.DATE:
jsonPath(keyFromBody, byDate())
break
case MatchingType.TIME:
jsonPath(keyFromBody, byTime())
break
case MatchingType.TIMESTAMP:
jsonPath(keyFromBody, byTimestamp())
break
case MatchingType.REGEX:
jsonPath(keyFromBody, byRegex(value.get(REGEX_KEY) as String))
break
}
} else if (value.containsKey(REGEX_KEY)) {
jsonPath(keyFromBody, byRegex(value.get(REGEX_KEY) as String))
}
}
}
}
}
response {
status(requestResponseInteraction.response.status)
if (requestResponseInteraction.response.body.state == OptionalBody.State.PRESENT) {
def parsedBody = BasePact.parseBody(requestResponseInteraction.response)
if (parsedBody instanceof Map) {
body(parsedBody as Map)
} else if (parsedBody instanceof List) {
body(parsedBody as List)
} else {
body(parsedBody.toString())
}
}
if (requestResponseInteraction.response?.matchingRules) {
testMatchers {
requestResponseInteraction.response.matchingRules.each { String key, Map<String, Object> value ->
String keyFromBody = toKeyStartingFromBody(key)
if (value.containsKey(MATCH_KEY)) {
MatchingType matchingType = MatchingType.valueOf((value.get(MATCH_KEY) as String).toUpperCase())
switch (matchingType) {
case MatchingType.EQUALITY:
// equality is checked by default in the standard way
break
case MatchingType.DATE:
jsonPath(keyFromBody, byDate())
break
case MatchingType.TIME:
jsonPath(keyFromBody, byTime())
break
case MatchingType.TIMESTAMP:
jsonPath(keyFromBody, byTimestamp())
break
case MatchingType.REGEX:
jsonPath(keyFromBody, byRegex(value.get(REGEX_KEY) as String))
break
case MatchingType.TYPE:
jsonPath(keyFromBody, byType() {
if (value.containsKey(MIN_KEY)) {
minOccurrence(value.get(MIN_KEY) as Integer)
}
if (value.containsKey(MAX_KEY)) {
maxOccurrence(value.get(MAX_KEY) as Integer)
}
})
break
}
} else if (value.containsKey(REGEX_KEY)) {
jsonPath(keyFromBody, byRegex(value.get(REGEX_KEY) as String))
}
}
}
}
requestResponseInteraction.response.headers?.each { String key, String value ->
headers {
header(key, value)
}
}
}
}
}
}
}
protected String providerState(Interaction interaction) {
return interaction.providerState ? " ${interaction.providerState}" : ""
}
protected String toKeyStartingFromBody(String key) {
return key.replace('$.body', '$')
}
@Override
Pact convertTo(Collection<Contract> contract) {
Provider provider = new Provider()
provider.name = "Provider"
Consumer consumer = new Consumer()
consumer.name = "Consumer"
List<RequestResponseInteraction> interactions = contract.find { it.request }.collect { Contract dsl ->
RequestResponseInteraction interaction = new RequestResponseInteraction()
interaction.description = dsl.description ?: ""
Request request = new Request().with {
method = dsl.request.method.serverValue.toString()
path = url(dsl)
QueryParameters params = queryParams(dsl)
if (params) {
query = params.parameters.collectEntries {
String name = it.name
String value = it.serverValue
return [(name) : [value]]
}
}
if (dsl.request.headers) {
headers = headers(dsl.request.headers, { DslProperty property -> property.serverValue })
}
if (dsl.request.body) {
assertInputContract(dsl.request.body.serverValue)
def json = MapConverter.getTestSideValues(dsl.request.body.serverValue)
String jsonBody = JsonOutput.toJson(json)
body = new OptionalBody(OptionalBody.State.PRESENT, jsonBody)
}
if (dsl.request.matchers && dsl.request.matchers.hasMatchers()) {
matchingRules = matchingRules(dsl.request.matchers)
}
return it
}
Response response = new Response().with {
status = dsl.response.status.clientValue as Integer
if (dsl.response.headers) {
headers = headers(dsl.response.headers, { DslProperty property -> property.clientValue })
}
if (dsl.response.body) {
assertInputContract(dsl.response.body.clientValue)
def json = MapConverter.getStubSideValues(dsl.response.body.clientValue)
String jsonBody = JsonOutput.toJson(json)
body = new OptionalBody(OptionalBody.State.PRESENT, jsonBody)
}
if (dsl.response.matchers && dsl.response.matchers.hasMatchers()) {
matchingRules = matchingRules(dsl.response.matchers)
}
return it
}
interaction.request = request
interaction.response = response
return interaction
}
return new RequestResponsePact(provider, consumer, interactions)
}
protected void assertInputContract(parsedJson) {
boolean hasExecutionProp = false
MapConverter.transformValues(parsedJson, {
if (it instanceof ExecutionProperty) {
hasExecutionProp = true
}
return it
})
if (hasExecutionProp) {
throw new UnsupportedOperationException("We can't convert a contract that has execution property")
}
}
protected Map<String, String> headers(Headers headers, Closure closure) {
return headers.entries.collectEntries {
String name = it.name
String value = closure(it)
return [(name) : value]
}
}
protected Map<String, Map<String, Object>> matchingRules(BodyMatchers bodyMatchers) {
return bodyMatchers.jsonPathMatchers().collectEntries {
MatchingType matchingType = it.matchingType()
String key = it.path()
Object value = it.value()
Integer minTypeOccurrence = it.minTypeOccurrence()
Integer maxTypeOccurrence = it.maxTypeOccurrence()
Map<String, Object> matchingRule = [:]
switch (matchingType) {
case MatchingType.EQUALITY:
matchingRule << [(MATCH_KEY) : MatchingType.EQUALITY.toString().toLowerCase() as Object]
break
case MatchingType.TYPE:
Map<String, Object> map = [(MATCH_KEY) : MatchingType.TYPE.toString().toLowerCase() as Object]
if (minTypeOccurrence) map.put(MIN_KEY, minTypeOccurrence)
if (maxTypeOccurrence) map.put(MAX_KEY, maxTypeOccurrence)
matchingRule << map
break
case MatchingType.DATE:
case MatchingType.TIME:
case MatchingType.TIMESTAMP:
case MatchingType.REGEX:
matchingRule << [
(MATCH_KEY) : MatchingType.REGEX.toString().toLowerCase() as Object,
(REGEX_KEY) : value
]
break
}
return [(key) : matchingRule]
}
}
protected String url(Contract dsl) {
if (dsl.request.urlPath) {
return dsl.request.urlPath.serverValue.toString()
} else if (dsl.request.url) {
return dsl.request.url.serverValue.toString()
}
throw new IllegalStateException("No url provided")
}
protected QueryParameters queryParams(Contract dsl) {
if (dsl.request.urlPath) {
return dsl.request.urlPath.queryParameters
} else if (dsl.request.url) {
return dsl.request.url.queryParameters
}
throw new IllegalStateException("No url provided")
}
}

View File

@@ -0,0 +1,2 @@
org.springframework.cloud.contract.spec.ContractConverter=\
org.springframework.cloud.contract.verifier.spec.pact.PactContractConverter

View File

@@ -0,0 +1,290 @@
package org.springframework.cloud.contract.verifier.spec.pact
import au.com.dius.pact.model.Pact
import au.com.dius.pact.model.PactSpecVersion
import groovy.json.JsonOutput
import org.skyscreamer.jsonassert.JSONAssert
import org.springframework.cloud.contract.spec.Contract
import org.springframework.cloud.contract.verifier.util.ContractVerifierDslConverter
import org.springframework.core.io.Resource
import org.springframework.core.io.support.PathMatchingResourcePatternResolver
import spock.lang.Specification
import spock.lang.Subject
/**
* @author Marcin Grzejszczak
*/
class PactContractConverterSpec extends Specification {
File pactJson = new File(PactContractConverterSpec.getResource("/pact/pact.json").toURI())
@Subject PactContractConverter converter = new PactContractConverter()
def "should accept json files that are pact files"() {
expect:
converter.isAccepted(pactJson)
}
def "should reject json files that are pact files"() {
given:
File invalidPact = new File(PactContractConverterSpec.getResource("/pact/invalid_pact.json").toURI())
expect:
converter.isAccepted(invalidPact)
}
def "should convert from pact to contract"() {
given:
Contract expectedContract = Contract.make {
description("a retrieve Mallory request a user with username 'username' and password 'password' exists")
request {
method(GET())
url("/mallory") {
queryParameters {
parameter("name", "ron")
parameter("status", "good")
}
}
headers {
contentType(applicationJson())
}
body(id: "123", method: "create")
stubMatchers {
jsonPath('$.id', byRegex("[0-9]{3}"))
}
}
response {
status(200)
headers {
contentType(applicationJson())
}
body([[
[email: "rddtGwwWMEhnkAPEmsyE",
id: "eb0f8c17-c06a-479e-9204-14f7c95b63a6",
userName: "AJQrokEGPAVdOHprQpKP"]
]])
testMatchers {
jsonPath('$[0][*].email', byType())
jsonPath('$[0][*].id', byRegex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))
jsonPath('$[0]', byType() {
maxOccurrence(5)
})
jsonPath('$[0][*].userName', byType())
}
}
}
when:
Collection<Contract> contracts = converter.convertFrom(pactJson)
then:
contracts == [expectedContract]
}
def "should convert from contract to pact"() {
given:
Collection<Contract> inputContracts = [
Contract.make {
description("a retrieve Mallory request")
request {
method(GET())
url("/mallory") {
queryParameters {
parameter("name", "ron")
parameter("status", "good")
}
}
headers {
contentType(applicationJson())
}
body(
id: 123,
method: $(stub(regex("[0][1][2]"))),
something: "foo"
)
stubMatchers {
jsonPath('$.id', byRegex("[0-9]{3}"))
jsonPath('$.something', byEquality())
}
}
response {
status(200)
headers {
contentType(applicationJson())
}
body([[
[email: "rddtGwwWMEhnkAPEmsyE",
id: "eb0f8c17-c06a-479e-9204-14f7c95b63a6",
number: $(producer(regex("[0-9]{3}")), consumer(923)),
something: "foo",
userName: "AJQrokEGPAVdOHprQpKP"]
]])
testMatchers {
jsonPath('$[0][*].email', byType())
jsonPath('$[0][*].id', byRegex("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"))
jsonPath('$[0]', byType() {
minOccurrence(1)
maxOccurrence(5)
})
jsonPath('$[0][*].userName', byType())
jsonPath('$[0][*].something', byEquality())
}
}
}
]
String expectedJson = '''
{
"provider": {
"name": "Provider"
},
"consumer": {
"name": "Consumer"
},
"interactions": [
{
"description": "a retrieve Mallory request",
"request": {
"method": "GET",
"path": "\\/mallory",
"query": "name=ron&status=good",
"headers": {
"Content-Type": "application\\/json"
},
"body": {
"id": 123,
"method": "012",
"something": "foo"
},
"matchingRules": {
"$.id": {
"match": "regex",
"regex": "[0-9]{3}"
},
"$.something": {
"match": "equality"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application\\/json"
},
"body": [
[
{
"email": "rddtGwwWMEhnkAPEmsyE",
"id": "eb0f8c17-c06a-479e-9204-14f7c95b63a6",
"number": 923,
"userName": "AJQrokEGPAVdOHprQpKP"
}
]
],
"matchingRules": {
"$[0][*].email": {
"match": "type"
},
"$[0][*].id": {
"match": "regex",
"regex": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
},
"$[0]": {
"match": "type",
"min": 1,
"max": 5
},
"$[0][*].userName": {
"match": "type"
},
"$[0][*].something": {
"match": "equality"
}
}
}
}
],
"metadata": {
"pact-specification": {
"version": "2.0.0"
},
"pact-jvm": {
"version": "2.4.18"
}
}
}
'''
when:
Pact pact = converter.convertTo(inputContracts)
then:
String actual = JsonOutput.toJson(pact.toMap(PactSpecVersion.V2))
JSONAssert.assertEquals(expectedJson, actual, false)
}
def "should fail to convert from contract to pact when contract has execution property in request"() {
given:
Collection<Contract> inputContracts = [
Contract.make {
request {
method(GET())
url("/mallory")
body(
id: $(c("foo"), p(execute("foo")))
)
}
response {
status(200)
}
}
]
when:
converter.convertTo(inputContracts)
then:
def e = thrown(UnsupportedOperationException)
e.message.contains("execution property")
}
def "should fail to convert from contract to pact when contract has execution property in response"() {
given:
Collection<Contract> inputContracts = [
Contract.make {
request {
method(GET())
url("/mallory")
}
response {
status(200)
body(
id: $(c(execute("foo")), p("foo"))
)
}
}
]
when:
converter.convertTo(inputContracts)
then:
def e = thrown(UnsupportedOperationException)
e.message.contains("execution property")
}
def "should convert contracts from samples to pacts"() {
given:
Resource[] contractResources = new PathMatchingResourcePatternResolver().getResources("contracts/*.groovy")
Resource[] pactResources = new PathMatchingResourcePatternResolver().getResources("contracts/*.json")
Map<String, Collection<Contract>> contracts = contractResources.collectEntries { [(it.filename) : ContractVerifierDslConverter.convertAsCollection(it.file)] }
Map<String, String> jsonPacts = pactResources.collectEntries { [(it.filename) : it.file.text] }
when:
Map<String, Pact> pacts = contracts.entrySet().collectEntries { [(it.key) : converter.convertTo(it.value)] }
then:
pacts.entrySet().each {
String convertedPactAsText = JsonOutput.toJson(it.value.toMap(PactSpecVersion.V2))
String pactFileName = it.key.replace("groovy", "json")
println "File name [${it.key}]"
JSONAssert.assertEquals(jsonPacts.get(pactFileName), convertedPactAsText, false)
}
}
}
// file creator
/*
pacts.entrySet().each {
new File("target/${it.key.replace("groovy", "json")}").text = JsonOutput.toJson(it.value.toMap(PactSpecVersion.V2))
}
*/

View File

@@ -0,0 +1,66 @@
package contracts
org.springframework.cloud.contract.spec.Contract.make {
request { // (1)
method 'PUT' // (2)
url '/fraudcheck' // (3)
body([ // (4)
clientId: $(c(regex('[0-9]{10}')), p("8532032713")),
loanAmount: 99999
])
headers { // (5)
contentType('application/vnd.fraud.v1+json')
}
}
response { // (6)
status 200 // (7)
body([ // (8)
fraudCheckStatus: "FRAUD",
rejectionReason: "Amount too high"
])
headers { // (9)
contentType('application/vnd.fraud.v1+json')
}
}
}
/*
Since we don't want to force on the user to hardcode values of fields that are dynamic
(timestamps, database ids etc.), one can parametrize those entries. If you wrap your field's
value in a `$(...)` or `value(...)` and provide a dynamic value of a field then
the concrete value will be generated for you. If you want to be really explicit about
which side gets which value you can do that by using the `value(consumer(...), producer(...))` notation.
That way what's present in the `consumer` section will end up in the produced stub. What's
there in the `producer` will end up in the autogenerated test. If you provide only the
regular expression side without the concrete value then Spring Cloud Contract will generate one for you.
From the Consumer perspective, when shooting a request in the integration test:
(1) - If the consumer sends a request
(2) - With the "PUT" method
(3) - to the URL "/fraudcheck"
(4) - with the JSON body that
* has a field `clientId` that matches a regular expression `[0-9]{10}`
* has a field `loanAmount` that is equal to `99999`
(5) - with header `Content-Type` equal to `application/vnd.fraud.v1+json`
(6) - then the response will be sent with
(7) - status equal `200`
(8) - and JSON body equal to
{ "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
(9) - with header `Content-Type` equal to `application/vnd.fraud.v1+json`
From the Producer perspective, in the autogenerated producer-side test:
(1) - A request will be sent to the producer
(2) - With the "PUT" method
(3) - to the URL "/fraudcheck"
(4) - with the JSON body that
* has a field `clientId` that will have a generated value that matches a regular expression `[0-9]{10}`
* has a field `loanAmount` that is equal to `99999`
(5) - with header `Content-Type` equal to `application/vnd.fraud.v1+json`
(6) - then the test will assert if the response has been sent with
(7) - status equal `200`
(8) - and JSON body equal to
{ "fraudCheckStatus": "FRAUD", "rejectionReason": "Amount too high" }
(9) - with header `Content-Type` matching `application/vnd.fraud.v1+json.*`
*/

View File

@@ -0,0 +1,42 @@
{
"provider": {
"name": "Provider"
},
"consumer": {
"name": "Consumer"
},
"interactions": [
{
"description": "",
"request": {
"method": "PUT",
"path": "/fraudcheck",
"headers": {
"Content-Type": "application/vnd.fraud.v1+json"
},
"body": {
"clientId": "8532032713",
"loanAmount": 99999
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/vnd.fraud.v1+json"
},
"body": {
"fraudCheckStatus": "FRAUD",
"rejectionReason": "Amount too high"
}
}
}
],
"metadata": {
"pact-specification": {
"version": "2.0.0"
},
"pact-jvm": {
"version": "2.4.18"
}
}
}

View File

@@ -0,0 +1,30 @@
package contracts
org.springframework.cloud.contract.spec.Contract.make {
request {
method 'PUT'
url '/fraudcheck'
body("""
{
"clientId":"${value(consumer(regex('[0-9]{10}')), producer('1234567890'))}",
"loanAmount":123.123
}
"""
)
headers {
contentType("application/vnd.fraud.v1+json")
}
}
response {
status 200
body(
fraudCheckStatus: "OK",
rejectionReason: $(consumer(null), producer(execute('assertThatRejectionReasonIsNull($it)')))
)
headers {
contentType("application/vnd.fraud.v1+json")
}
}
}

View File

@@ -0,0 +1,42 @@
{
"provider": {
"name": "Provider"
},
"consumer": {
"name": "Consumer"
},
"interactions": [
{
"description": "",
"request": {
"method": "PUT",
"path": "/fraudcheck",
"headers": {
"Content-Type": "application/vnd.fraud.v1+json"
},
"body": {
"clientId": "1234567890",
"loanAmount": 123.123
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/vnd.fraud.v1+json"
},
"body": {
"fraudCheckStatus": "OK",
"rejectionReason": null
}
}
}
],
"metadata": {
"pact-specification": {
"version": "2.0.0"
},
"pact-jvm": {
"version": "2.4.18"
}
}
}

View File

@@ -0,0 +1,37 @@
package contracts
import org.springframework.cloud.contract.spec.Contract
[
Contract.make {
request {
name "should count all frauds"
method GET()
url '/frauds'
}
response {
status 200
body([
count: 200
])
headers {
contentType("application/vnd.fraud.v1+json")
}
}
},
Contract.make {
request {
method GET()
url '/drunks'
}
response {
status 200
body([
count: 100
])
headers {
contentType("application/vnd.fraud.v1+json")
}
}
}
]

View File

@@ -0,0 +1,34 @@
{
"provider": {
"name": "Provider"
},
"consumer": {
"name": "Consumer"
},
"interactions": [
{
"description": "",
"request": {
"method": "GET",
"path": "/frauds"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/vnd.fraud.v1+json"
},
"body": {
"count": 200
}
}
}
],
"metadata": {
"pact-specification": {
"version": "2.0.0"
},
"pact-jvm": {
"version": "2.4.18"
}
}
}

View File

@@ -0,0 +1,59 @@
{
"provider": {
"name": "Alice Service"
},
"consumer": {
"name": "Consumer"
},
"interactions": [
{
"description": "a retrieve Mallory request",
"provider_state": "a user with username 'username' and password 'password' exists",
"request": {
"method": "GET",
"path": "/mallory",
"query": "name=ron&status=good",
"body" : {"id": "123", "method": "create"},
"headers": {
"Content-Type": "application/json"
},
"matchingRules": {
"$.body.id": {
"match": "regex",
"regex": "[0-9]{3}"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"body": [
[
{
"email": "rddtGwwWMEhnkAPEmsyE",
"id": "eb0f8c17-c06a-479e-9204-14f7c95b63a6",
"userName": "AJQrokEGPAVdOHprQpKP"
}
]
],
"matchingRules": {
"$.body[0][*].email": {
"match": "type"
},
"$.body[0][*].id": {
"regex": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
},
"$.body[0]": {
"max": 5,
"match": "type"
},
"$.body[0][*].userName": {
"match": "type"
}
}
}
}
]
}