diff --git a/README.adoc b/README.adoc index d8cdf51dc0..9f24b85aa0 100644 --- a/README.adoc +++ b/README.adoc @@ -15,6 +15,8 @@ and consumers, for HTTP and message-based interactions. :core_path: ../../../.. :doc_samples: {core_path}/samples/wiremock-jetty +:wiremock_tests: {core_path}/spring-cloud-contract-wiremock + Modules giving you the possibility to use http://wiremock.org[WireMock] with different servers by using the @@ -321,6 +323,78 @@ available on the classpath, you can create a stub using WireMock in a number of different ways, including as described above using `@AutoConfigureWireMock(stubs="classpath:resource.json")`. +== Generating Contracts using RestDocs + +Another thing that can be generated with Spring RestDocs is the Spring Cloud +Contract DSL file and documentation. If you combine that with Spring Cloud +WireMock then you're getting both the contracts and stubs. + +TIP: You might wonder why this functionality is in the WireMock module. +Come to think of it, it does make sense since it makes little sense to generate +only contracts and not generate the stubs. That's why we suggest to do both. + +Let's imagine the following test: + +[source,java] +---- + this.mockMvc.perform(post("/foo") + .accept(MediaType.APPLICATION_PDF) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"foo\": 23 }")) + .andExpect(status().isOk()) + .andExpect(content().string("bar")) + // first WireMock + .andDo(WireMockRestDocs.verify() + .jsonPath("$[?(@.foo >= 20)]") + .contentType(MediaType.valueOf("application/json")) + .stub("shouldGrantABeerIfOldEnough")) + // then Contract DSL documentation + .andDo(document("index", SpringCloudContractRestDocs.dslContract())); +---- + +This will lead in the creation of the stub as presented in the previous +section, contract will get generated and a documentation file too. + +The contract will be called `index.groovy` and look more like this. + +[souce,groovy] +---- +import org.springframework.cloud.contract.spec.Contract + +Contract.make { + request { + method 'POST' + url 'http://localhost:8080/foo' + body(''' + {"foo": 23 } + ''') + headers { + header('''Accept''', '''application/json''') + header('''Content-Type''', '''application/json''') + header('''Host''', '''localhost:8080''') + header('''Content-Length''', '''12''') + } + } + response { + status 200 + body(''' + bar + ''') + headers { + header('''Content-Type''', '''application/json;charset=UTF-8''') + header('''Content-Length''', '''3''') + } + testMatchers { + jsonPath('$[?(@.foo >= 20)]', byType()) + } + } +} +---- + +the generated document (example for Asciidoc) will contain a formatted contract +(the location of this file would be `index/dsl-contract.adoc`). + === Spring Cloud Contract Verifier :introduction_url: https://raw.githubusercontent.com/spring-cloud/spring-cloud-contract/1.0.x diff --git a/docs/src/main/asciidoc/spring-cloud-wiremock.adoc b/docs/src/main/asciidoc/spring-cloud-wiremock.adoc index 3ab5aede74..e1185b58c4 100644 --- a/docs/src/main/asciidoc/spring-cloud-wiremock.adoc +++ b/docs/src/main/asciidoc/spring-cloud-wiremock.adoc @@ -1,5 +1,7 @@ :core_path: ../../../.. :doc_samples: {core_path}/samples/wiremock-jetty +:wiremock_tests: {core_path}/spring-cloud-contract-wiremock + Modules giving you the possibility to use http://wiremock.org[WireMock] with different servers by using the @@ -246,4 +248,63 @@ and `contentType()` methods to create request matchers, but not both. On the consumer side, assuming the `resource.json` generated above is available on the classpath, you can create a stub using WireMock in a number of different ways, including as described above using -`@AutoConfigureWireMock(stubs="classpath:resource.json")`. \ No newline at end of file +`@AutoConfigureWireMock(stubs="classpath:resource.json")`. + +== Generating Contracts using RestDocs + +Another thing that can be generated with Spring RestDocs is the Spring Cloud +Contract DSL file and documentation. If you combine that with Spring Cloud +WireMock then you're getting both the contracts and stubs. + +TIP: You might wonder why this functionality is in the WireMock module. +Come to think of it, it does make sense since it makes little sense to generate +only contracts and not generate the stubs. That's why we suggest to do both. + +Let's imagine the following test: + +[source,java] +---- +include::{wiremock_tests}/src/test/java/org/springframework/cloud/contract/wiremock/restdocs/ContractDslSnippetTests.java[tags=contract_snippet] +---- + +This will lead in the creation of the stub as presented in the previous +section, contract will get generated and a documentation file too. + +The contract will be called `index.groovy` and look more like this. + +[souce,groovy] +---- +import org.springframework.cloud.contract.spec.Contract + +Contract.make { + request { + method 'POST' + url 'http://localhost:8080/foo' + body(''' + {"foo": 23 } + ''') + headers { + header('''Accept''', '''application/json''') + header('''Content-Type''', '''application/json''') + header('''Host''', '''localhost:8080''') + header('''Content-Length''', '''12''') + } + } + response { + status 200 + body(''' + bar + ''') + headers { + header('''Content-Type''', '''application/json;charset=UTF-8''') + header('''Content-Length''', '''3''') + } + testMatchers { + jsonPath('$[?(@.foo >= 20)]', byType()) + } + } +} +---- + +the generated document (example for Asciidoc) will contain a formatted contract +(the location of this file would be `index/dsl-contract.adoc`). \ No newline at end of file diff --git a/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/Request.groovy b/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/Request.groovy index 6815572b4a..03636ee721 100644 --- a/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/Request.groovy +++ b/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/Request.groovy @@ -248,7 +248,7 @@ class Request extends Common { @CompileStatic @EqualsAndHashCode(includeFields = true) @ToString(includePackage = false) - private class RequestHeaders extends Headers { + class RequestHeaders extends Headers { @Override DslProperty matching(String value) { diff --git a/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/Response.groovy b/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/Response.groovy index 2600e1d14a..6112243b12 100644 --- a/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/Response.groovy +++ b/spring-cloud-contract-spec/src/main/groovy/org/springframework/cloud/contract/spec/internal/Response.groovy @@ -155,7 +155,7 @@ class Response extends Common { @CompileStatic @EqualsAndHashCode(includeFields = true) @ToString(includePackage = false) - private class ResponseHeaders extends Headers { + class ResponseHeaders extends Headers { @Override DslProperty matching(String value) { diff --git a/spring-cloud-contract-wiremock/pom.xml b/spring-cloud-contract-wiremock/pom.xml index d9d7027cf5..41b4557229 100644 --- a/spring-cloud-contract-wiremock/pom.xml +++ b/spring-cloud-contract-wiremock/pom.xml @@ -64,5 +64,10 @@ spring-boot-configuration-processor true + + org.springframework.cloud + spring-cloud-contract-verifier + test + diff --git a/spring-cloud-contract-wiremock/src/main/java/org/springframework/cloud/contract/wiremock/restdocs/ContractDslSnippet.java b/spring-cloud-contract-wiremock/src/main/java/org/springframework/cloud/contract/wiremock/restdocs/ContractDslSnippet.java new file mode 100644 index 0000000000..37073c5d18 --- /dev/null +++ b/spring-cloud-contract-wiremock/src/main/java/org/springframework/cloud/contract/wiremock/restdocs/ContractDslSnippet.java @@ -0,0 +1,131 @@ +package org.springframework.cloud.contract.wiremock.restdocs; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import org.springframework.http.HttpHeaders; +import org.springframework.restdocs.RestDocumentationContext; +import org.springframework.restdocs.operation.Operation; +import org.springframework.restdocs.operation.OperationRequest; +import org.springframework.restdocs.operation.OperationResponse; +import org.springframework.restdocs.snippet.TemplatedSnippet; +import org.springframework.restdocs.templates.TemplateEngine; + +/** + * A {@link org.springframework.restdocs.snippet.Snippet} that documents the Spring Cloud Contract Groovy DSL. + * + * @author Marcin Grzejszczak + * @since 1.0.4 + */ +public class ContractDslSnippet extends TemplatedSnippet { + + private static final String CONTRACTS_FOLDER = "contracts"; + private static final String SNIPPET_NAME = "dsl-contract"; + + private Map model = new HashMap<>(); + + /** + * Creates a new {@code ContractDslSnippet} with no additional attributes. + */ + protected ContractDslSnippet() { + this(null); + } + + /** + * Creates a new {@code ContractDslSnippet} with the given additional + * {@code attributes} that will be included in the model during template rendering. + * + * @param attributes The additional attributes + */ + protected ContractDslSnippet(Map attributes) { + super(SNIPPET_NAME, attributes); + } + + @Override + protected Map createModel(Operation operation) { + return this.model; + } + + @Override public void document(Operation operation) throws IOException { + TemplateEngine templateEngine = (TemplateEngine) operation.getAttributes().get(TemplateEngine.class.getName()); + String renderedContract = templateEngine.compileTemplate("default-dsl-contract-only") + .render(createModelForContract(operation)); + this.model.put("contract", renderedContract); + storeDslContract(operation, renderedContract); + super.document(operation); + } + + private void insertResponseModel(Operation operation, Map model) { + OperationResponse response = operation.getResponse(); + model.put("response_status", response.getStatus().value()); + model.put("response_body_present", response.getContent().length > 0); + model.put("response_body", response.getContentAsString()); + HttpHeaders headers = response.getHeaders(); + model.put("response_headers_present", !headers.isEmpty()); + model.put("response_headers", headers.toSingleValueMap().entrySet()); + @SuppressWarnings("unchecked") Set jsonPaths = (Set) operation.getAttributes() + .get("contract.jsonPaths"); + model.put("response_json_paths_present", jsonPaths != null && !jsonPaths.isEmpty()); + model.put("response_json_paths", jsonPaths(jsonPaths)); + } + + private Set jsonPaths(Set jsonPaths) { + Set paths = new HashSet<>(); + if (jsonPaths == null) { + return paths; + } + for (String s : jsonPaths) { + paths.add(new JsonPaths(s)); + } + return paths; + } + + private void insertRequestModel(Operation operation, Map model) { + OperationRequest request = operation.getRequest(); + model.put("request_method", request.getMethod()); + model.put("request_url", request.getUri()); + model.put("request_body_present", request.getContent().length > 0); + model.put("request_body", request.getContentAsString()); + HttpHeaders headers = request.getHeaders(); + model.put("request_headers_present", !headers.isEmpty()); + model.put("request_headers", headers.toSingleValueMap().entrySet()); + } + + private Map createModelForContract(Operation operation) { + Map modelForContract = new HashMap<>(); + insertRequestModel(operation, modelForContract); + insertResponseModel(operation, modelForContract); + return modelForContract; + } + + private void storeDslContract(Operation operation, String content) + throws IOException { + RestDocumentationContext context = (RestDocumentationContext) operation + .getAttributes().get(RestDocumentationContext.class.getName()); + File output = new File(context.getOutputDirectory(), + CONTRACTS_FOLDER + "/" + operation.getName() + ".groovy"); + output.getParentFile().mkdirs(); + try (Writer writer = new OutputStreamWriter(new FileOutputStream(output))) { + writer.append(content); + } + } +} + +class JsonPaths { + private final String jsonPath; + + JsonPaths(String jsonPath) { + this.jsonPath = jsonPath; + } + + public String getJsonPath() { + return this.jsonPath; + } +} \ No newline at end of file diff --git a/spring-cloud-contract-wiremock/src/main/java/org/springframework/cloud/contract/wiremock/restdocs/SpringCloudContractRestDocs.java b/spring-cloud-contract-wiremock/src/main/java/org/springframework/cloud/contract/wiremock/restdocs/SpringCloudContractRestDocs.java new file mode 100644 index 0000000000..d638e6d74f --- /dev/null +++ b/spring-cloud-contract-wiremock/src/main/java/org/springframework/cloud/contract/wiremock/restdocs/SpringCloudContractRestDocs.java @@ -0,0 +1,90 @@ +/* + * Copyright 2012-2015 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. + */ + +package org.springframework.cloud.contract.wiremock.restdocs; + +import java.util.Map; + +import org.springframework.restdocs.snippet.Snippet; + +/** + * Convenience class for setting up RestDocs to generate a {@link org.springframework.restdocs.snippet.Snippet} + * with Spring Cloud Contract DSL. Example usage: + * + *
+ * @RunWith(SpringRunner.class)
+ * @SpringBootTest
+ * @AutoConfigureRestDocs(outputDir = "target/snippets")
+ * @AutoConfigureMockMvc
+ * public class ContractRestDocsApplicationTests {
+ *
+ * 	@Autowired
+ * 	private MockMvc mockMvc;
+ *
+ * 	@Test
+ * 	public void contextLoads() throws Exception {
+ *     this.mockMvc.perform(post("/foo")
+ *          .accept(MediaType.APPLICATION_PDF)
+ *          .accept(MediaType.APPLICATION_JSON)
+ *          .contentType(MediaType.APPLICATION_JSON)
+ *          .content("{\"foo\": 23 }"))
+ *     .andExpect(status().isOk())
+ *     .andExpect(content().string("bar"))
+ *     // first WireMock
+ *     .andDo(WireMockRestDocs.verify()
+ *          .jsonPath("$[?(@.foo >= 20)]")
+ *          .contentType(MediaType.valueOf("application/json"))
+ *          .stub("shouldGrantABeerIfOldEnough"))
+ *     // then Contract DSL documentation
+ *     .andDo(document("index", SpringCloudContractRestDocs.dslContract()));
+ * 	}
+ * 
+ * + * which creates a file "target/snippets/contracts/index.groovy" and a + * standard documentation entitled `dsl-contract.adoc` containing that contract. + * + * @author Marcin Grzejszczak + * @since 1.0.4 + */ +public class SpringCloudContractRestDocs { + + private SpringCloudContractRestDocs() { + + } + + /** + * Returns a new {@code Snippet} that will document Spring Cloud Contract DSL for the API + * operation. + * + * @return the snippet that will document the Spring Cloud Contract DSL + */ + public static Snippet dslContract() { + return new ContractDslSnippet(); + } + + /** + * Returns a new {@code Snippet} that will document the Spring Cloud Contract DSL for the API + * operation. The given {@code attributes} will be available during snippet + * generation. + * + * @param attributes the attributes + * @return the snippet that will document the Spring Cloud Contract DSL + */ + public static Snippet dslContract(Map attributes) { + return new ContractDslSnippet(attributes); + } + +} diff --git a/spring-cloud-contract-wiremock/src/main/resources/org/springframework/restdocs/templates/asciidoctor/default-dsl-contract.snippet b/spring-cloud-contract-wiremock/src/main/resources/org/springframework/restdocs/templates/asciidoctor/default-dsl-contract.snippet new file mode 100644 index 0000000000..200deae6cc --- /dev/null +++ b/spring-cloud-contract-wiremock/src/main/resources/org/springframework/restdocs/templates/asciidoctor/default-dsl-contract.snippet @@ -0,0 +1,4 @@ +[source,groovy] +---- +{{contract}} +---- \ No newline at end of file diff --git a/spring-cloud-contract-wiremock/src/main/resources/org/springframework/restdocs/templates/default-dsl-contract-only.snippet b/spring-cloud-contract-wiremock/src/main/resources/org/springframework/restdocs/templates/default-dsl-contract-only.snippet new file mode 100644 index 0000000000..96dacddc37 --- /dev/null +++ b/spring-cloud-contract-wiremock/src/main/resources/org/springframework/restdocs/templates/default-dsl-contract-only.snippet @@ -0,0 +1,42 @@ +import org.springframework.cloud.contract.spec.Contract + +Contract.make { + request { + method '{{request_method}}' + url '{{request_url}}' + {{#request_body_present}} + body(''' + {{request_body}} + ''') + {{/request_body_present}} + {{#request_headers_present}} + headers { + {{#request_headers}} + header('''{{key}}''', '''{{value}}''') + {{/request_headers}} + } + {{/request_headers_present}} + } + response { + status {{response_status}} + {{#response_body_present}} + body(''' + {{response_body}} + ''') + {{/response_body_present}} + {{#response_headers_present}} + headers { + {{#response_headers}} + header('''{{key}}''', '''{{value}}''') + {{/response_headers}} + } + {{/response_headers_present}} + {{#response_json_paths_present}} + testMatchers { + {{#response_json_paths}} + jsonPath('{{jsonPath}}', byType()) + {{/response_json_paths}} + } + {{/response_json_paths_present}} + } +} \ No newline at end of file diff --git a/spring-cloud-contract-wiremock/src/main/resources/org/springframework/restdocs/templates/markdown/default-dsl-contract.snippet b/spring-cloud-contract-wiremock/src/main/resources/org/springframework/restdocs/templates/markdown/default-dsl-contract.snippet new file mode 100644 index 0000000000..8e538e30eb --- /dev/null +++ b/spring-cloud-contract-wiremock/src/main/resources/org/springframework/restdocs/templates/markdown/default-dsl-contract.snippet @@ -0,0 +1,3 @@ +```groovy +{{contract}} +``` \ No newline at end of file diff --git a/spring-cloud-contract-wiremock/src/test/java/org/springframework/cloud/contract/wiremock/restdocs/ContractDslSnippetTests.java b/spring-cloud-contract-wiremock/src/test/java/org/springframework/cloud/contract/wiremock/restdocs/ContractDslSnippetTests.java new file mode 100644 index 0000000000..f38b1d38dc --- /dev/null +++ b/spring-cloud-contract-wiremock/src/test/java/org/springframework/cloud/contract/wiremock/restdocs/ContractDslSnippetTests.java @@ -0,0 +1,137 @@ +package org.springframework.cloud.contract.wiremock.restdocs; + +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.contract.spec.Contract; +import org.springframework.cloud.contract.verifier.util.ContractVerifierDslConverter; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.restdocs.JUnitRestDocumentation; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.WebApplicationContext; + +import static org.assertj.core.api.BDDAssertions.then; +import static org.springframework.cloud.contract.wiremock.restdocs.SpringCloudContractRestDocs.dslContract; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Marcin Grzejszczak + */ +@RunWith(SpringRunner.class) +@SpringBootTest(classes = ContractDslSnippetTests.Config.class) +public class ContractDslSnippetTests { + + private static final String OUTPUT = "target/generated-snippets"; + @Rule + public JUnitRestDocumentation restDocumentation = new JUnitRestDocumentation(OUTPUT); + + MockMvc mockMvc; + @Autowired WebApplicationContext context; + + @Before + public void setUp() { + this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context) + .apply(documentationConfiguration(this.restDocumentation)) + .build(); + } + + @Test + public void should_create_contract_template_and_doc() throws Exception { + //tag::contract_snippet[] + this.mockMvc.perform(post("/foo") + .accept(MediaType.APPLICATION_PDF) + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"foo\": 23 }")) + .andExpect(status().isOk()) + .andExpect(content().string("bar")) + // first WireMock + .andDo(WireMockRestDocs.verify() + .jsonPath("$[?(@.foo >= 20)]") + .contentType(MediaType.valueOf("application/json")) + .stub("shouldGrantABeerIfOldEnough")) + // then Contract DSL documentation + .andDo(document("index", SpringCloudContractRestDocs.dslContract())); + //end::contract_snippet[] + + then(file("/contracts/index.groovy")).exists(); + then(file("/index/dsl-contract.adoc")).exists(); + String contract = readFromFile(file("/contracts/index.groovy")); + // try to parse the contract + Contract parsedContract = ContractVerifierDslConverter.convert(contract); + then(parsedContract.getRequest().getHeaders().getEntries()).isNotEmpty(); + then(parsedContract.getRequest().getMethod().getClientValue()).isNotNull(); + then(parsedContract.getRequest().getUrl().getClientValue()).isNotNull(); + then(parsedContract.getRequest().getBody().getClientValue()).isNotNull(); + then(parsedContract.getResponse().getStatus().getClientValue()).isNotNull(); + then(parsedContract.getResponse().getHeaders().getEntries()).isNotEmpty(); + then(parsedContract.getResponse().getBody().getClientValue()).isNotNull(); + then(parsedContract.getResponse().getMatchers().hasMatchers()).isTrue(); + } + + @Test + public void should_create_contract_template_and_doc_without_body_and_headers() throws Exception { + this.mockMvc.perform(MockMvcRequestBuilders.get("/foo")) + .andExpect(status().isOk()) + .andDo(document("empty", dslContract())); + + then(file("/contracts/empty.groovy")).exists(); + then(file("/empty/dsl-contract.adoc")).exists(); + String contract = readFromFile(file("/contracts/empty.groovy")); + // try to parse the contract + Contract parsedContract = ContractVerifierDslConverter.convert(contract); + then(parsedContract.getRequest().getHeaders().getEntries()).isNotEmpty(); + then(parsedContract.getRequest().getMethod().getClientValue()).isNotNull(); + then(parsedContract.getRequest().getUrl().getClientValue()).isNotNull(); + then(parsedContract.getRequest().getBody()).isNull(); + then(parsedContract.getResponse().getStatus().getClientValue()).isNotNull(); + then(parsedContract.getResponse().getHeaders()).isNull(); + then(parsedContract.getResponse().getBody()).isNull(); + then(parsedContract.getResponse().getMatchers()).isNull(); + } + + private File file(String name) throws URISyntaxException { + return new File(OUTPUT, name); + } + + private String readFromFile(File f) throws IOException { + byte[] encoded = Files.readAllBytes(f.toPath()); + return new String(encoded, StandardCharsets.UTF_8); + } + + @Configuration + @EnableAutoConfiguration + @RestController + static class Config { + + @PostMapping("/foo") + String foo() { + return "bar"; + } + + @GetMapping("/foo") + void getFoo() { + } + } +} \ No newline at end of file