Adds metadata to contracts (#1466)

fixes #1340
fixes #1078 
fixes #1406
This commit is contained in:
Marcin Grzejszczak
2020-08-04 13:36:22 +02:00
committed by GitHub
parent 91b7b95557
commit 242c8208a9
27 changed files with 889 additions and 131 deletions

View File

@@ -30,7 +30,7 @@
:standalone_pact_path: {samples_path}/standalone/dsl
:standalone_restdocs_path: {samples_path}/standalone/restdocs
:tests_path: {core_path}/tests
:samples_branch: 2.2.x
:samples_branch: 3.0.x
:samples_url: https://raw.githubusercontent.com/spring-cloud-samples/spring-cloud-contract-samples/{samples_branch}
:samples_code: https://github.com/spring-cloud-samples/spring-cloud-contract-samples/tree/{samples_branch}/
:doc_samples: {core_path}/samples/wiremock-jetty

View File

@@ -193,6 +193,7 @@ The following sections describe the most common top-level elements:
* <<contract-dsl-ignoring-contracts>>
* <<contract-dsl-in-progress>>
* <<contract-dsl-passing-values-from-files>>
* <<contract-dsl-metadata>>
[[contract-dsl-description]]
==== Description
@@ -437,6 +438,40 @@ include::{contract_kotlin_spec_path}/src/test/resources/contracts/shouldWorkWith
IMPORTANT: You should use this approach whenever you want to work with binary payloads,
both for HTTP and messaging.
[[contract-dsl-metadata]]
==== Metadata
You can add `metadata` to your contract. Via the metadata you can pass in configuration to extensions. Below you can find
an example of using the `wiremock` key and value being WireMock's `StubMapping` object. Spring Cloud Contract is able to
patch parts of your generated stub mapping with your custom code. You may want to do that in order to add webhooks, custom
delays or integrate with third party WireMock extensions.
====
[source,groovy,indent=0,role="primary"]
.groovy
----
include::{standalone_samples_path}/http-server/src/test/resources/contracts/fraud/shouldReturnFraudStats.groovy[tags=metadata,indent=0]
----
[source,yaml,indent=0,role="secondary"]
.yml
----
include::{standalone_samples_path}/http-server/src/test/resources/contracts/yml/fraud/shouldReturnFraudStats.yml[tags=metadata,indent=0]
----
[source,java,indent=0,subs="verbatim,attributes",role="secondary"]
.java
----
include::{verifier_core_path}/src/test/resources/contractsToCompile/contract_rest_with_tags.java[tags=metadata,indent=0]
----
[source,kotlin,indent=0,subs="verbatim,attributes",role="secondary"]
.kotlin
----
include::{contract_kotlin_spec_path}/src/test/kotlin/org/springframework/cloud/contract/spec/ContractTests.kt[tags=metadata,indent=0]
----
====
[[features-http]]
== Contracts for HTTP

View File

@@ -168,6 +168,54 @@ include::{wiremock_tests}/src/test/java/org/springframework/cloud/contract/wirem
----
====
[[customization-wiremock-from-metadata]]
=== Customization of WireMock via Metadata
With version 3.0.0 you're able to set `metadata` in your contracts. If you set an entry with key equal to `wiremock` and the value
will be a valid WireMock's `StubMapping` JSON / map or an actual `StubMapping` object, Spring Cloud Contract will patch the generated
stub with part of your customization. Let's look at the following example
[source,yaml,indent=0]
----
include::{standalone_samples_path}/http-server/src/test/resources/contracts/yml/fraud/shouldReturnFraudStats.yml[tags=metadata,indent=0]
----
In the `metadata` section we've set an entry with key `wiremock` and its value is a JSON `StubMapping` that sets a delay in the generated stub. Such code allowed us to get the following merged WireMock JSON stub.
[source,json,indent=0]
----
{
"id" : "ebae49e2-a2a3-490c-a57f-ba28e26b81ea",
"request" : {
"url" : "/yamlfrauds",
"method" : "GET"
},
"response" : {
"status" : 200,
"body" : "{\"count\":200}",
"headers" : {
"Content-Type" : "application/json"
},
"fixedDelayMilliseconds" : 2000,
"transformers" : [ "response-template" ]
},
"uuid" : "ebae49e2-a2a3-490c-a57f-ba28e26b81ea"
}
----
The current implementation allows to manipulate only the stub side (we don't change the generated test). Also, what does not get changed
are the whole request and body and headers of the response.
[[customization-wiremock-from-metadata-custom-processor]]
==== Customization of WireMock via Metadata and a Custom Processor
If you want to apply a custom WireMock `StubMapping` post processing, you can under `META-INF/spring.factories` under the
`org.springframework.cloud.contract.verifier.converter.StubProcessor` key register your own implementation of a stub processor. For your convenience we've created an interface called `org.springframework.cloud.contract.verifier.wiremock.WireMockStubPostProcessor` that is dedicated to WireMock.
You'll have to implement methods to inform Spring Cloud Contract whether the post processor is applicable for a given contract and how should the post processing look like.
IMPORTANT: On the consumer side, when using Stub Runner, remember to pass the custom `HttpServerStubConfigurer` implementation (e.g. the one that extends `WireMockHttpServerStubConfigurer`) where you'll register a custom extension of your choosing. If you don't do so, even you have a custom WireMock extension on the classpath, WireMock will not notice it, won't apply it and will print out a warning statement that the given extension was not found.
[[customization-pluggable-architecture]]
== Using the Pluggable Architecture

View File

@@ -57,3 +57,15 @@ response:
regex: bar
- key: foo3
predefined:
metadata:
wiremock:
"postServeActions": {
"webhook": {
"headers": {
"Content-Type": "application/json"
},
"method": "POST",
"body": "{ \"result\": \"SUCCESS\" }",
"url": "http://localhost:56299/callback"
}
}

View File

@@ -40,6 +40,8 @@ public class LoanApplicationService {
private final RestTemplate restTemplate;
private int port = 6565;
private String prefix = "";
@Autowired
public LoanApplicationService(RestTemplateBuilder builder) {
@@ -61,13 +63,17 @@ public class LoanApplicationService {
// tag::client_call_server[]
ResponseEntity<FraudServiceResponse> response = restTemplate.exchange(
"http://localhost:" + port + "/fraudcheck", HttpMethod.PUT,
"http://localhost:" + port + fraudCheck(), HttpMethod.PUT,
new HttpEntity<>(request, httpHeaders), FraudServiceResponse.class);
// end::client_call_server[]
return response.getBody();
}
private String fraudCheck() {
return "/" + prefix + "fraudcheck";
}
private LoanApplicationResult buildResponseFromFraudResult(
FraudServiceResponse response) {
LoanApplicationStatus applicationStatus = null;
@@ -86,7 +92,7 @@ public class LoanApplicationService {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
ResponseEntity<Response> response = restTemplate.exchange(
"http://localhost:" + port + "/frauds", HttpMethod.GET,
"http://localhost:" + port + "/" + prefix + "frauds", HttpMethod.GET,
new HttpEntity<>(httpHeaders), Response.class);
return response.getBody().getCount();
}
@@ -95,7 +101,7 @@ public class LoanApplicationService {
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
ResponseEntity<Response> response = restTemplate.exchange(
"http://localhost:" + port + "/drunks", HttpMethod.GET,
"http://localhost:" + port + "/"+ prefix + "drunks", HttpMethod.GET,
new HttpEntity<>(httpHeaders), Response.class);
return response.getBody().getCount();
}
@@ -105,7 +111,7 @@ public class LoanApplicationService {
httpHeaders.add("Cookie", "name=foo");
httpHeaders.add("Cookie", "name2=bar");
ResponseEntity<String> response = restTemplate.exchange(
"http://localhost:" + port + "/frauds/name", HttpMethod.GET,
"http://localhost:" + port + "/" + prefix + "frauds/name", HttpMethod.GET,
new HttpEntity<>(httpHeaders), String.class);
return response.getBody();
}
@@ -114,4 +120,7 @@ public class LoanApplicationService {
this.port = port;
}
public void setPrefix(String prefix) {
this.prefix = prefix;
}
}

View File

@@ -20,31 +20,35 @@ import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
import java.time.Duration;
import com.example.loan.model.Client;
import com.example.loan.model.LoanApplication;
import com.example.loan.model.LoanApplicationResult;
import com.example.loan.model.LoanApplicationStatus;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.JsonPath;
import io.restassured.RestAssured;
import io.restassured.response.ResponseOptions;
import io.restassured.specification.RequestSpecification;
import org.assertj.core.api.BDDAssertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner;
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerPort;
import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties;
import org.springframework.http.RequestEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import static com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.toomuchcoding.jsonassert.JsonAssertion.assertThatJson;
import static org.assertj.core.api.Assertions.assertThat;
@@ -52,7 +56,7 @@ import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
@AutoConfigureStubRunner(ids = {
"com.example:http-server-dsl:0.0.1:stubs" }, stubsMode = StubRunnerProperties.StubsMode.LOCAL)
"com.example:http-server-dsl:0.0.1:stubs"}, stubsMode = StubRunnerProperties.StubsMode.LOCAL)
public class LoanApplicationServiceTests {
// end::autoconfigure_stubrunner[]
@@ -65,6 +69,7 @@ public class LoanApplicationServiceTests {
@BeforeEach
public void setup() {
this.service.setPrefix("");
this.service.setPort(this.stubPort);
}
@@ -112,6 +117,15 @@ public class LoanApplicationServiceTests {
assertThat(count).isEqualTo(100);
}
// metadata
@Test
public void shouldFailToSuccessfullyGetAllDrunksDueToTimeout() {
LoanApplicationService service = new LoanApplicationService(new RestTemplateBuilder().setReadTimeout(Duration.ofSeconds(1)));
service.setPort(this.stubPort);
// when:
BDDAssertions.thenThrownBy(service::countDrunks).hasMessageContaining("Read timed out");
}
@Test
public void shouldSuccessfullyGetCookies() {
// when:
@@ -124,7 +138,7 @@ public class LoanApplicationServiceTests {
public void shouldSuccessfullyWorkWithMultipart() {
// given:
RequestSpecification request = RestAssured.given()
.baseUri("http://localhost:"+ stubPort + "/")
.baseUri("http://localhost:" + stubPort + "/")
.header("Content-Type", "multipart/form-data")
.multiPart("file1", "filename1", "content1".getBytes())
.multiPart("file2", "filename1", "content2".getBytes()).multiPart("test",
@@ -153,7 +167,7 @@ public class LoanApplicationServiceTests {
// when:
ResponseEntity<byte[]> exchange = new RestTemplate()
.exchange(
RequestEntity.put(URI.create("http://localhost:"+ stubPort + "/1"))
RequestEntity.put(URI.create("http://localhost:" + stubPort + "/1"))
.header("Content-Type", "application/octet-stream")
.body(Files.readAllBytes(request.toPath())),
byte[].class);
@@ -166,4 +180,52 @@ public class LoanApplicationServiceTests {
assertThat(exchange.getBody()).isEqualTo(Files.readAllBytes(response.toPath()));
}
}
@Test
public void shouldSuccessfullyApplyForLoanForYaml() {
this.service.setPrefix("yaml");
// given:
LoanApplication application = new LoanApplication(new Client("1234567890"),
123.123);
// when:
LoanApplicationResult loanApplication = service.loanApplication(application);
// then:
assertThat(loanApplication.getLoanApplicationStatus())
.isEqualTo(LoanApplicationStatus.LOAN_APPLIED);
assertThat(loanApplication.getRejectionReason()).isNull();
}
@Test
public void shouldBeRejectedDueToAbnormalLoanAmountForYaml() {
this.service.setPrefix("yaml");
// given:
LoanApplication application = new LoanApplication(new Client("1234567890"),
99999);
// when:
LoanApplicationResult loanApplication = service.loanApplication(application);
// then:
assertThat(loanApplication.getLoanApplicationStatus())
.isEqualTo(LoanApplicationStatus.LOAN_APPLICATION_REJECTED);
assertThat(loanApplication.getRejectionReason()).isEqualTo("Amount too high");
}
@Test
public void shouldSuccessfullyGetAllFraudsForYaml() {
this.service.setPrefix("yaml");
// when:
int count = service.countAllFrauds();
// then:
assertThat(count).isGreaterThanOrEqualTo(200);
}
// metadata
@Test
public void shouldFailToSuccessfullyGetAllDrunksDueToTimeoutForYaml() {
LoanApplicationService service = new LoanApplicationService(new RestTemplateBuilder().setReadTimeout(Duration.ofSeconds(1)));
service.setPort(this.stubPort);
service.setPrefix("yaml");
// when:
BDDAssertions.thenThrownBy(service::countAllFrauds).hasMessageContaining("Read timed out");
}
}

View File

@@ -35,6 +35,7 @@ import org.springframework.cloud.contract.spec.Contract
}
}
},
// tag::metadata[]
Contract.make {
request {
method GET()
@@ -49,5 +50,14 @@ import org.springframework.cloud.contract.spec.Contract
contentType("application/json")
}
}
metadata([wiremock: '''\
{
"response" : {
"fixedDelayMilliseconds": 2000
}
}
'''
])
}
// end::metadata[]
]

View File

@@ -1,4 +1,5 @@
---
# tag::metadata[]
name: "should count all frauds"
request:
method: GET
@@ -9,6 +10,14 @@ response:
count: 200
headers:
Content-Type: application/json
metadata:
wiremock: >
{
"response" : {
"fixedDelayMilliseconds": 2000
}
}
# end::metadata[]
---
request:
method: GET

View File

@@ -16,6 +16,8 @@
package org.springframework.cloud.contract.spec;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
@@ -95,6 +97,11 @@ public class Contract {
*/
private boolean inProgress;
/**
* Mapping of metadata. Can be used for external integrations.
*/
private Map<String, Object> metadata = new HashMap<>();
public Contract() {
}
@@ -258,6 +265,14 @@ public class Contract {
consumer.call();
}
/**
* Appends all entries to the existing metadata mapping.
* @param map metadata to set
*/
public void metadata(Map<String, Object> map) {
this.metadata.putAll(map);
}
/**
* Whether the contract should be ignored or not.
*/
@@ -360,6 +375,14 @@ public class Contract {
this.inProgress = inProgress;
}
public void setMetadata(Map<String, Object> metadata) {
this.metadata = metadata;
}
public Map<String, Object> getMetadata() {
return metadata;
}
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -376,13 +399,14 @@ public class Contract {
&& Objects.equals(description, contract.description)
&& Objects.equals(name, contract.name)
&& Objects.equals(input, contract.input)
&& Objects.equals(metadata, contract.metadata)
&& Objects.equals(outputMessage, contract.outputMessage);
}
@Override
public int hashCode() {
return Objects.hash(priority, request, response, label, description, name, input,
outputMessage, ignored);
outputMessage, metadata, ignored);
}
@Override

View File

@@ -25,115 +25,135 @@ import org.springframework.cloud.contract.spec.internal.*
@ContractDslMarker
class ContractDsl {
companion object {
fun contract(dsl: ContractDsl.() -> Unit): Contract = ContractDsl().apply(dsl).get()
}
companion object {
fun contract(dsl: ContractDsl.() -> Unit): Contract = ContractDsl().apply(dsl).get()
}
/**
* You can set the level of priority of this contract. If there are two contracts
* mapped for example to the same endpoint, then the one with greater priority should
* take precedence. A priority of 1 is highest and takes precedence over a priority of
* 2.
*/
var priority: Int? = null
/**
* You can set the level of priority of this contract. If there are two contracts
* mapped for example to the same endpoint, then the one with greater priority should
* take precedence. A priority of 1 is highest and takes precedence over a priority of
* 2.
*/
var priority: Int? = null
/**
* The label by which you'll reference the contract on the message consumer side.
*/
var label: String? = null
/**
* The label by which you'll reference the contract on the message consumer side.
*/
var label: String? = null
/**
* Description of a contract. May be used in the documentation generation.
*/
var description: String? = null
/**
* Description of a contract. May be used in the documentation generation.
*/
var description: String? = null
/**
* Name of the generated test / stub. If not provided then the file name will be used.
* If you have multiple contracts in a single file and you don't provide this value
* then a prefix will be added to the file with the index number while iterating over
* the collection of contracts.
*
* Remember to have a unique name for every single contract. Otherwise you might
* generate tests that have two identical methods or you will override the stubs.
*/
var name: String? = null
/**
* Name of the generated test / stub. If not provided then the file name will be used.
* If you have multiple contracts in a single file and you don't provide this value
* then a prefix will be added to the file with the index number while iterating over
* the collection of contracts.
*
* Remember to have a unique name for every single contract. Otherwise you might
* generate tests that have two identical methods or you will override the stubs.
*/
var name: String? = null
/**
* Whether the contract should be ignored or not.
*/
var ignored: Boolean = false
/**
* Whether the contract should be ignored or not.
*/
var ignored: Boolean = false
/**
* Whether the contract is in progress. It's not ignored, but the feature is not yet
* finished. Used together with the {@code generateStubs} option.
*/
var inProgress: Boolean = false
/**
* Whether the contract is in progress. It's not ignored, but the feature is not yet
* finished. Used together with the {@code generateStubs} option.
*/
var inProgress: Boolean = false
/**
* The HTTP request part of the contract.
*/
var request: Request? = null
/**
* The HTTP request part of the contract.
*/
var request: Request? = null
/**
* The HTTP response part of the contract.
*/
var response: Response? = null
/**
* The HTTP response part of the contract.
*/
var response: Response? = null
/**
* The input side of a messaging contract.
*/
var input: Input? = null
/**
* The input side of a messaging contract.
*/
var input: Input? = null
/**
* The output side of a messaging contract.
*/
var outputMessage: OutputMessage? = null
/**
* The output side of a messaging contract.
*/
var outputMessage: OutputMessage? = null
/**
* The HTTP request part of the contract.
* @param configurer lambda to configure the HTTP request
*/
fun request(configurer: RequestDsl.() -> Unit) {
request = RequestDsl().apply(configurer).get()
}
/**
* Mapping of metadata. Can be used for external integrations.
*/
var metadata: Map<String, Any> = HashMap()
/**
* The HTTP response part of the contract.
* @param configurer lambda to configure the HTTP response
*/
fun response(configurer: ResponseDsl.() -> Unit) {
response = ResponseDsl().apply(configurer).get()
}
/**
* The HTTP request part of the contract.
* @param configurer lambda to configure the HTTP request
*/
fun request(configurer: RequestDsl.() -> Unit) {
request = RequestDsl().apply(configurer).get()
}
/**
* The input part of the contract.
* @param configurer lambda to configure the input message
*/
fun input(configurer: InputDsl.() -> Unit) {
input = InputDsl().apply(configurer).get()
}
/**
* The HTTP response part of the contract.
* @param configurer lambda to configure the HTTP response
*/
fun response(configurer: ResponseDsl.() -> Unit) {
response = ResponseDsl().apply(configurer).get()
}
/**
* The output part of the contract.
* @param configurer lambda to configure the output message
*/
fun outputMessage(configurer: OutputMessageDsl.() -> Unit) {
outputMessage = OutputMessageDsl().apply(configurer).get()
}
/**
* The input part of the contract.
* @param configurer lambda to configure the input message
*/
fun input(configurer: InputDsl.() -> Unit) {
input = InputDsl().apply(configurer).get()
}
private fun get(): Contract {
val contract = Contract()
priority?.also { contract.priority = priority }
label?.also { contract.label = label }
description?.also { contract.description = description }
name?.also { contract.name = name }
contract.ignored = ignored
contract.inProgress = inProgress
request?.also { contract.request = request }
response?.also { contract.response = response }
input?.also { contract.input = input }
outputMessage?.also { contract.outputMessage = outputMessage }
return contract
}
/**
* The output part of the contract.
* @param configurer lambda to configure the output message
*/
fun outputMessage(configurer: OutputMessageDsl.() -> Unit) {
outputMessage = OutputMessageDsl().apply(configurer).get()
}
/**
* The metadata.
* @param map metadata to set
*/
fun metadata(map: Map<String, Any>) {
metadata = metadata.plus(map)
}
/**
* The metadata.
* @param metadata metadata to set
*/
fun metadata(vararg metadata: Pair<String, Any>) = metadata(metadata.toMap())
private fun get(): Contract {
val contract = Contract()
priority?.also { contract.priority = priority }
label?.also { contract.label = label }
description?.also { contract.description = description }
name?.also { contract.name = name }
contract.metadata = metadata
contract.ignored = ignored
contract.inProgress = inProgress
request?.also { contract.request = request }
response?.also { contract.response = response }
input?.also { contract.input = input }
outputMessage?.also { contract.outputMessage = outputMessage }
return contract
}
}

View File

@@ -305,6 +305,27 @@ then:
}
}
@Test
fun `should set contract metadata`() {
val contract =
// tag::metadata[]
contract {
metadata("wiremock" to """
{
"response" : {
"fixedDelayMilliseconds": 2000
}
}""")
}
// end::metadata[]
assertDoesNotThrow {
Contract.assertContract(contract)
}.also {
assertThat(contract.metadata).isNotEmpty
}
}
@Test
fun `should make equals and hashcode work properly for URL`() {
val a: Contract = contract {

View File

@@ -32,6 +32,7 @@ import groovy.json.JsonOutput;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.cloud.contract.spec.Contract;
import org.springframework.cloud.contract.spec.internal.DslProperty;
import org.springframework.cloud.contract.spec.internal.Headers;
@@ -86,15 +87,9 @@ class StubRunnerExecutor implements StubFinder {
}
return runningStubs();
}
try {
HttpServerStubConfigurer configurer = stubRunnerOptions
.getHttpServerStubConfigurer().newInstance();
startStubServers(configurer, stubRunnerOptions, stubConfiguration,
repository);
}
catch (InstantiationException | IllegalAccessException ex) {
log.error("Failed to instantiate the HTTP stub configurer", ex);
}
HttpServerStubConfigurer configurer = BeanUtils
.instantiateClass(stubRunnerOptions.getHttpServerStubConfigurer());
startStubServers(configurer, stubRunnerOptions, stubConfiguration, repository);
RunningStubs runningCollaborators = runningStubs();
log.info("All stubs are now running " + runningCollaborators.toString());
return runningCollaborators;

View File

@@ -16,7 +16,9 @@
package org.springframework.cloud.contract.verifier.converter;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.cloud.contract.spec.Contract;
import org.springframework.cloud.contract.verifier.file.ContractMetadata;
@@ -24,9 +26,10 @@ import org.springframework.cloud.contract.verifier.file.ContractMetadata;
/**
* Converts contracts into their stub representation.
*
* @param <T> - type of stub mapping
* @since 1.1.0
*/
public interface StubGenerator {
public interface StubGenerator<T> {
/**
* @param fileName - file name
@@ -45,6 +48,35 @@ public interface StubGenerator {
*/
Map<Contract, String> convertContents(String rootName, ContractMetadata content);
/**
* Post process a generated stub mapping.
* @param stubMapping - mapping of a stub
* @param contract - contract for which stub was generated
* @return the converted stub mapping
*/
default T postProcessStubMapping(T stubMapping, Contract contract) {
List<StubPostProcessor> processors = StubPostProcessor.PROCESSORS.stream()
.filter(p -> p.isApplicable(contract)).collect(Collectors.toList());
if (processors.isEmpty()) {
return defaultStubMappingPostProcessing(stubMapping, contract);
}
T stub = stubMapping;
for (StubPostProcessor processor : processors) {
stub = (T) processor.postProcess(stub, contract);
}
return stub;
}
/**
* Stub mapping to chose when no post processors where found on the classpath.
* @param stubMapping - mapping of a stub
* @param contract - contract for which stub was generated
* @return the converted stub mapping
*/
default T defaultStubMappingPostProcessing(T stubMapping, Contract contract) {
return stubMapping;
}
/**
* @param inputFileName - name of the input file
* @return the name of the converted stub file. If you have multiple contracts in a

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2013-2020 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
*
* https://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.
*/
package org.springframework.cloud.contract.verifier.converter;
import java.util.List;
import org.springframework.cloud.contract.spec.Contract;
import org.springframework.core.io.support.SpringFactoriesLoader;
/**
* Post processor of stub mappings.
*
* @param <T> type of stub mapping
* @author Marcin Grzejszczak
* @since 3.0.0
*/
public interface StubPostProcessor<T> {
/**
* List of registered stub post processors.
*/
List<StubPostProcessor> PROCESSORS = SpringFactoriesLoader
.loadFactories(StubPostProcessor.class, null);
/**
* @param stubMapping - generated stub mapping
* @param contract - contract for which the mapping was generated
* @return modified stub mapping
*/
default T postProcess(T stubMapping, Contract contract) {
return stubMapping;
}
/**
* @param contract - contract for which the mapping was generated
* @return {@code true} if this post process should be applied
*/
boolean isApplicable(Contract contract);
}

View File

@@ -0,0 +1,188 @@
/*
* Copyright 2013-2020 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
*
* https://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.
*/
package org.springframework.cloud.contract.verifier.wiremock;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.tomakehurst.wiremock.common.Metadata;
import com.github.tomakehurst.wiremock.extension.Parameters;
import com.github.tomakehurst.wiremock.http.ChunkedDribbleDelay;
import com.github.tomakehurst.wiremock.http.DelayDistribution;
import com.github.tomakehurst.wiremock.http.Fault;
import com.github.tomakehurst.wiremock.http.ResponseDefinition;
import com.github.tomakehurst.wiremock.stubbing.StubMapping;
import org.springframework.cloud.contract.spec.Contract;
class DefaultWireMockStubPostProcessor implements WireMockStubPostProcessor {
private static final List<Class> APPLICABLE_CLASSES = Arrays.asList(String.class,
StubMapping.class, Map.class);
public static final String WIREMOCK_METADATA_ENTRY = "wiremock";
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public StubMapping postProcess(StubMapping stubMapping, Contract contract) {
Object wiremock = getWiremockEntry(contract);
StubMapping stubMappingFromMetadata = stubMappingFromMetadata(wiremock);
stubMapping.setResponse(mergedResponse(stubMapping, stubMappingFromMetadata));
if (stubMappingFromMetadata.getPostServeActions() != null) {
setPostServeActions(stubMapping, stubMappingFromMetadata);
}
if (stubMappingFromMetadata.getMetadata() != null) {
setMetadata(stubMapping, stubMappingFromMetadata);
}
return stubMapping;
}
public void setPostServeActions(StubMapping stubMapping,
StubMapping stubMappingFromMetadata) {
Map<String, Parameters> postServeActions = stubMapping.getPostServeActions();
postServeActions = postServeActions != null ? postServeActions : new HashMap<>();
postServeActions.putAll(stubMappingFromMetadata.getPostServeActions());
stubMapping.setPostServeActions(postServeActions);
}
public void setMetadata(StubMapping stubMapping,
StubMapping stubMappingFromMetadata) {
Metadata metadata = stubMapping.getMetadata();
metadata = metadata != null ? metadata : new Metadata();
metadata.putAll(stubMappingFromMetadata.getPostServeActions());
stubMapping.setMetadata(metadata);
}
public ResponseDefinition mergedResponse(StubMapping stubMapping,
StubMapping stubMappingFromMetadata) {
ResponseDefinition responseDefinition = new ResponseDefinition(
stubMapping.getResponse().getStatus(),
stubMapping.getResponse().getStatusMessage(),
stubMapping.getResponse().getBody(),
stubMapping.getResponse().getJsonBody(),
stubMapping.getResponse().getBase64Body(),
stubMapping.getResponse().getBodyFileName(),
stubMapping.getResponse().getHeaders(),
stubMapping.getResponse().getAdditionalProxyRequestHeaders(),
fixedDelayMilliseconds(stubMapping, stubMappingFromMetadata),
delayDistribution(stubMapping, stubMappingFromMetadata),
chunkedDribbleDelay(stubMapping, stubMappingFromMetadata),
proxyBaseUrl(stubMapping, stubMappingFromMetadata),
fault(stubMapping, stubMappingFromMetadata),
transformers(stubMapping, stubMappingFromMetadata),
transformerParameters(stubMapping, stubMappingFromMetadata),
wasConfigured(stubMapping, stubMappingFromMetadata));
return responseDefinition;
}
public Boolean wasConfigured(StubMapping stubMapping,
StubMapping stubMappingFromMetadata) {
return stubMappingFromMetadata.getResponse().isFromConfiguredStub() != null
? stubMappingFromMetadata.getResponse().isFromConfiguredStub()
: stubMapping.getResponse().isFromConfiguredStub();
}
public Parameters transformerParameters(StubMapping stubMapping,
StubMapping stubMappingFromMetadata) {
return stubMappingFromMetadata.getResponse().getTransformerParameters() != null
? stubMappingFromMetadata.getResponse().getTransformerParameters()
: stubMapping.getResponse().getTransformerParameters();
}
public List<String> transformers(StubMapping stubMapping,
StubMapping stubMappingFromMetadata) {
return stubMappingFromMetadata.getResponse().getTransformers() != null
? stubMappingFromMetadata.getResponse().getTransformers()
: stubMapping.getResponse().getTransformers();
}
public Fault fault(StubMapping stubMapping, StubMapping stubMappingFromMetadata) {
return stubMappingFromMetadata.getResponse().getFault() != null
? stubMappingFromMetadata.getResponse().getFault()
: stubMapping.getResponse().getFault();
}
public String proxyBaseUrl(StubMapping stubMapping,
StubMapping stubMappingFromMetadata) {
return stubMappingFromMetadata.getResponse().getProxyBaseUrl() != null
? stubMappingFromMetadata.getResponse().getProxyBaseUrl()
: stubMapping.getResponse().getProxyBaseUrl();
}
public ChunkedDribbleDelay chunkedDribbleDelay(StubMapping stubMapping,
StubMapping stubMappingFromMetadata) {
return stubMappingFromMetadata.getResponse().getChunkedDribbleDelay() != null
? stubMappingFromMetadata.getResponse().getChunkedDribbleDelay()
: stubMapping.getResponse().getChunkedDribbleDelay();
}
public DelayDistribution delayDistribution(StubMapping stubMapping,
StubMapping stubMappingFromMetadata) {
return stubMappingFromMetadata.getResponse().getDelayDistribution() != null
? stubMappingFromMetadata.getResponse().getDelayDistribution()
: stubMapping.getResponse().getDelayDistribution();
}
public Integer fixedDelayMilliseconds(StubMapping stubMapping,
StubMapping stubMappingFromMetadata) {
return stubMappingFromMetadata.getResponse().getFixedDelayMilliseconds() != null
? stubMappingFromMetadata.getResponse().getFixedDelayMilliseconds()
: stubMapping.getResponse().getFixedDelayMilliseconds();
}
private Object getWiremockEntry(Contract contract) {
return contract.getMetadata().get(WIREMOCK_METADATA_ENTRY);
}
private StubMapping stubMappingFromMetadata(Object wiremock) {
if (wiremock instanceof String) {
return StubMapping.buildFrom((String) wiremock);
}
else if (wiremock instanceof StubMapping) {
return (StubMapping) wiremock;
}
else if (wiremock instanceof Map) {
try {
return StubMapping
.buildFrom(this.objectMapper.writeValueAsString(wiremock));
}
catch (JsonProcessingException e) {
throw new IllegalStateException(
"Failed to build StubMapping for map [" + wiremock + "]", e);
}
}
throw new UnsupportedOperationException(
"Unsupported type for wiremock metadata extension");
}
@Override
public boolean isApplicable(Contract contract) {
boolean contains = contract.getMetadata().containsKey(WIREMOCK_METADATA_ENTRY);
if (!contains) {
return false;
}
Object wiremock = getWiremockEntry(contract);
return APPLICABLE_CLASSES.stream()
.anyMatch(aClass -> aClass.isAssignableFrom(wiremock.getClass()));
}
}

View File

@@ -24,6 +24,8 @@ import java.util.Map;
import java.util.NoSuchElementException;
import java.util.stream.Collectors;
import com.github.tomakehurst.wiremock.stubbing.StubMapping;
import org.springframework.cloud.contract.spec.Contract;
import org.springframework.cloud.contract.verifier.dsl.wiremock.WireMockStubStrategy;
import org.springframework.cloud.contract.verifier.file.ContractMetadata;
@@ -39,7 +41,31 @@ public class DslToWireMockClientConverter extends DslToWireMockConverter {
private String convertASingleContract(String rootName, ContractMetadata contract,
Contract dsl) {
return new WireMockStubStrategy(rootName, contract, dsl).toWireMockClientStub();
StubMapping stubMapping = new WireMockStubStrategy(rootName, contract, dsl)
.toWireMockClientStub();
StubMapping mapping = postProcessStubMapping(stubMapping, dsl);
if (mapping == null) {
return "";
}
return mapping.toString();
}
@Override
public StubMapping postProcessStubMapping(StubMapping stubMapping,
Contract contract) {
StubMapping mapping = super.postProcessStubMapping(stubMapping, contract);
// apply the default WireMock processor as the last one
return defaultStubMappingPostProcessing(mapping, contract);
}
@Override
public StubMapping defaultStubMappingPostProcessing(StubMapping stubMapping,
Contract contract) {
DefaultWireMockStubPostProcessor processor = new DefaultWireMockStubPostProcessor();
if (processor.isApplicable(contract)) {
return processor.postProcess(stubMapping, contract);
}
return stubMapping;
}
@Override

View File

@@ -16,6 +16,8 @@
package org.springframework.cloud.contract.verifier.wiremock;
import com.github.tomakehurst.wiremock.stubbing.StubMapping;
import org.springframework.cloud.contract.verifier.converter.StubGenerator;
/**
@@ -23,7 +25,7 @@ import org.springframework.cloud.contract.verifier.converter.StubGenerator;
*
* @since 1.0.0
*/
public abstract class DslToWireMockConverter implements StubGenerator {
public abstract class DslToWireMockConverter implements StubGenerator<StubMapping> {
@Override
public String generateOutputFileNameForInput(String inputFileName) {

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2013-2020 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
*
* https://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.
*/
package org.springframework.cloud.contract.verifier.wiremock;
import com.github.tomakehurst.wiremock.stubbing.StubMapping;
import org.springframework.cloud.contract.verifier.converter.StubPostProcessor;
/**
* Post processor of WireMock stub mappings.
*
* @author Marcin Grzejszczak
* @since 3.0.0
*/
public interface WireMockStubPostProcessor extends StubPostProcessor<StubMapping> {
}

View File

@@ -0,0 +1,153 @@
/*
* Copyright 2013-2020 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
*
* https://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.
*/
package org.springframework.cloud.contract.verifier.wiremock;
import java.util.HashMap;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.tomakehurst.wiremock.extension.Parameters;
import com.github.tomakehurst.wiremock.stubbing.StubMapping;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.contract.spec.Contract;
import static org.assertj.core.api.BDDAssertions.then;
class DefaultWireMockStubPostProcessorTests {
// @formatter:off
private static final String STUB_MAPPING = "{\n" + " \"request\": {\n"
+ " \"method\": \"GET\",\n" + " \"url\": \"/ping\"\n"
+ " },\n" + " \"response\": {\n" + " \"status\": 200,\n"
+ " \"body\": \"pong\",\n" + " \"headers\": {\n"
+ " \"Content-Type\": \"text/plain\"\n" + " }\n" + " }\n"
+ "}";
private static final String POST_SERVE_ACTION = "{ \"postServeActions\": {\n"
+ " \"webhook\": {\n" + " \"headers\": {\n"
+ " \"Content-Type\": \"application/json\"\n" + " },\n"
+ " \"method\": \"POST\",\n"
+ " \"body\": \"{ \\\"result\\\": \\\"SUCCESS\\\" }\",\n"
+ " \"url\": \"http://localhost:56299/callback\"\n" + " }\n"
+ " } }";
private static final String RESPONSE_DELAY = "{\n"
+ " \"response\": {\n"
+ " \"delayDistribution\": {\n"
+ " \"type\": \"lognormal\",\n"
+ " \"median\": 80,\n"
+ " \"sigma\": 0.4\n"
+ " }\n"
+ " }\n"
+ "}\n";
// @formatter:on
@Test
void should_not_be_applicable_for_missing_metadata_entry() {
then(new DefaultWireMockStubPostProcessor().isApplicable(new Contract()))
.isFalse();
}
@Test
void should_not_be_applicable_for_invalid_metadata_entry() {
Contract contract = new Contract();
contract.getMetadata().put("wiremock", 5);
then(new DefaultWireMockStubPostProcessor().isApplicable(contract)).isFalse();
}
@Test
void should_be_applicable_for_valid_metadata_entry() {
Contract contract = new Contract();
contract.getMetadata().put("wiremock", "foo");
then(new DefaultWireMockStubPostProcessor().isApplicable(contract)).isTrue();
contract.getMetadata().put("wiremock", new StubMapping());
then(new DefaultWireMockStubPostProcessor().isApplicable(contract)).isTrue();
contract.getMetadata().put("wiremock", new HashMap<>());
then(new DefaultWireMockStubPostProcessor().isApplicable(contract)).isTrue();
}
@Test
void should_merge_stub_mappings_when_stub_mapping_is_string() {
Contract contract = new Contract();
contract.getMetadata().put("wiremock", POST_SERVE_ACTION);
StubMapping stubMapping = StubMapping.buildFrom(STUB_MAPPING);
StubMapping result = new DefaultWireMockStubPostProcessor()
.postProcess(stubMapping, contract);
thenPostServerActionWasSet(result);
}
@Test
void should_merge_stub_mappings_when_stub_mapping_is_stub_mapping() {
Contract contract = new Contract();
contract.getMetadata().put("wiremock", StubMapping.buildFrom(POST_SERVE_ACTION));
StubMapping stubMapping = StubMapping.buildFrom(STUB_MAPPING);
StubMapping result = new DefaultWireMockStubPostProcessor()
.postProcess(stubMapping, contract);
thenPostServerActionWasSet(result);
}
@Test
void should_merge_stub_mappings_when_stub_mapping_is_map()
throws JsonProcessingException {
Contract contract = new Contract();
contract.getMetadata().put("wiremock",
new ObjectMapper().readValue(POST_SERVE_ACTION, HashMap.class));
StubMapping stubMapping = StubMapping.buildFrom(STUB_MAPPING);
StubMapping result = new DefaultWireMockStubPostProcessor()
.postProcess(stubMapping, contract);
thenPostServerActionWasSet(result);
}
@Test
void should_merge_stub_mappings_when_stub_mapping_is_string_and_contains_response() {
Contract contract = new Contract();
contract.getMetadata().put("wiremock", RESPONSE_DELAY);
StubMapping stubMapping = StubMapping.buildFrom(STUB_MAPPING);
StubMapping result = new DefaultWireMockStubPostProcessor()
.postProcess(stubMapping, contract);
then(result.getRequest().getMethod().getName()).isEqualTo("GET");
then(result.getResponse().getStatus()).isEqualTo(200);
then(result.getResponse().getBody()).isEqualTo("pong");
then(result.getResponse().getHeaders().size()).isEqualTo(1);
then(result.getResponse().getDelayDistribution()).isNotNull();
}
private void thenPostServerActionWasSet(StubMapping result) {
then(result.getRequest().getMethod().getName()).isEqualTo("GET");
then(result.getResponse().getStatus()).isEqualTo(200);
then(result.getResponse().getBody()).isEqualTo("pong");
then(result.getPostServeActions()).containsKey("webhook");
Parameters webhook = result.getPostServeActions().get("webhook");
then(webhook.getString("method")).isEqualTo("POST");
}
}

View File

@@ -61,6 +61,7 @@ class ContractsToYaml {
yamlContract.inProgress = contract.inProgress
yamlContract.description = contract.description
yamlContract.label = contract.label
yamlContract.metadata = contract.metadata
request(contract, yamlContract)
response(yamlContract, contract)
input(contract, yamlContract)

View File

@@ -54,6 +54,8 @@ public class YamlContract {
public boolean inProgress;
public Map<String, Object> metadata;
public static class Request {
public String method;

View File

@@ -102,6 +102,9 @@ class YamlToContracts {
if (yamlContract.inProgress) {
inProgress()
}
if (yamlContract.metadata) {
metadata(yamlContract.metadata)
}
if (yamlContract.request?.method) {
request {
method(yamlContract.request?.method)

View File

@@ -51,9 +51,9 @@ class WireMockStubStrategy {
}
/**
* Converts {@link ContractMetadata} to String version of {@link StubMapping}
* Converts {@link ContractMetadata} to {@link StubMapping}
*/
String toWireMockClientStub() {
StubMapping toWireMockClientStub() {
StubMapping stubMapping = new StubMapping()
RequestPattern request = wireMockRequestStubStrategy.buildClientRequestContent()
ResponseDefinition response = wireMockResponseStubStrategy.
@@ -66,11 +66,11 @@ class WireMockStubStrategy {
stubMapping.response = response
if (!request || !response) {
return ''
return null
}
if (groovyDsl.ignored || contract.ignored) {
return ''
return null
}
if (contract.order != null) {
@@ -80,6 +80,6 @@ class WireMockStubStrategy {
stubMapping.newScenarioState = STEP_PREFIX + (contract.order + 1)
}
}
return StubMapping.buildJsonStringFor(stubMapping)
return stubMapping
}
}

View File

@@ -714,6 +714,7 @@ name: "post1"
priority: null
ignored: false
inProgress: false
metadata: null
'''
String expectedYaml2 = '''\
---
@@ -756,6 +757,7 @@ name: "post2"
priority: null
ignored: false
inProgress: false
metadata: null
'''
when:
Map<String, byte[]> strings = converter.store([

View File

@@ -19,6 +19,7 @@ package org.springframework.cloud.contract.verifier.dsl.wiremock
import com.github.tomakehurst.wiremock.WireMockServer
import com.github.tomakehurst.wiremock.core.WireMockConfiguration
import com.github.tomakehurst.wiremock.extension.responsetemplating.ResponseTemplateTransformer
import com.github.tomakehurst.wiremock.stubbing.StubMapping
import groovy.json.JsonBuilder
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
@@ -2003,7 +2004,7 @@ class WireMockGroovyDslSpec extends Specification implements WireMockStubVerifie
when:
def json = toWireMockClientJsonStub(groovyDsl)
then:
json == ''
json == null
}
@Issue('#30')
@@ -2031,7 +2032,7 @@ class WireMockGroovyDslSpec extends Specification implements WireMockStubVerifie
def json = new WireMockStubStrategy("Test", new ContractMetadata(null, true, 0, null, groovyDsl), groovyDsl).
toWireMockClientStub()
then:
json == ''
json == null
}
@Issue('#237')
@@ -2982,7 +2983,7 @@ class WireMockGroovyDslSpec extends Specification implements WireMockStubVerifie
}
}
and:
String wireMockStub = new WireMockStubStrategy("Test",
StubMapping wireMockStub = new WireMockStubStrategy("Test",
new ContractMetadata(null, false, 0, null, contractDsl), contractDsl)
.toWireMockClientStub()
@@ -2992,7 +2993,7 @@ class WireMockGroovyDslSpec extends Specification implements WireMockStubVerifie
server.start()
and:
stubMappingIsValidWireMockStub(wireMockStub)
server.addStubMapping(WireMockStubMapping.buildFrom(wireMockStub))
server.addStubMapping(wireMockStub)
when:
ResponseEntity<String> entity = callWithOptionalAndEmpty(port)
then:

View File

@@ -28,11 +28,16 @@ trait WireMockStubVerifier {
void stubMappingIsValidWireMockStub(String mappingDefinition) {
StubMapping stubMapping = WireMockStubMapping.buildFrom(mappingDefinition)
stubMapping.request.bodyPatterns.findAll { it.isPresent() && it instanceof RegexPattern }.every {
stubMappingIsValidWireMockStub(stubMapping)
}
void stubMappingIsValidWireMockStub(StubMapping mappingDefinition) {
mappingDefinition.request.bodyPatterns.findAll { it.isPresent() && it instanceof RegexPattern }.every {
Pattern.compile(it.getValue())
}
assert !mappingDefinition.contains('org.springframework.cloud.contract.spec.internal')
assert !mappingDefinition.contains('cursor')
String definition = mappingDefinition.toString()
assert !definition.contains('org.springframework.cloud.contract.spec.internal')
assert !definition.contains('cursor')
}
void stubMappingIsValidWireMockStub(Contract contractDsl) {

View File

@@ -16,8 +16,11 @@
*/
// tag::class[]
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
import org.springframework.cloud.contract.spec.Contract;
@@ -58,6 +61,16 @@ class contract_rest_with_tags implements Supplier<Collection<Contract>> {
// end::in_progress[]
static Object metadata = Collections.singletonList(
// tag::metadata[]
Contract.make(c -> {
Map<String, Object> map = new HashMap<>();
map.put("wiremock", "{ \"response\" : { \"fixedDelayMilliseconds\" : 2000 } }");
c.metadata(map);
}));
// end::metadata[]
@Override
public Collection<Contract> get() {
return Collections.singletonList(Contract.make(c -> {