diff --git a/docs/pom.xml b/docs/pom.xml index 74d92f5ecf..31d70a8a26 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -24,8 +24,24 @@ 2.5.10 - src/main/asciidoc + + org.codehaus.mojo + build-helper-maven-plugin + + + generate-sources + + add-source + + + + src/main/asciidoc + + + + + org.apache.maven.plugins maven-compiler-plugin @@ -75,12 +91,10 @@ com.fasterxml.jackson.module jackson-module-jsonSchema ${jackson-module-jsonSchema.version} - test org.springframework.amqp spring-amqp - test @@ -115,6 +129,19 @@ ./build_adocs.sh + + generate-adoc-resources + process-classes + + java + + + org.springframework.cloud.contract.docs.Main + + ${maven.multiModuleProjectDirectory}/docs + + + diff --git a/docs/src/main/asciidoc/_project-features-flows.adoc b/docs/src/main/asciidoc/_project-features-flows.adoc index 82936ba7cb..390c71e04a 100644 --- a/docs/src/main/asciidoc/_project-features-flows.adoc +++ b/docs/src/main/asciidoc/_project-features-flows.adoc @@ -439,3 +439,188 @@ Contract.make { The generated document (formatted in Asciidoc in this case) contains a formatted contract. The location of this file would be `index/dsl-contract.adoc`. + +[[features-graphql]] +=== GraphQL + +Since https://graphql.org/[GraphQL] is essentially HTTP you can write a contract for it by creating a standard HTTP contract with an additional `metadata` entry with key `verifier` and a mapping `tool=graphql`. + +==== +[source,groovy,indent=0,subs="verbatim,attributes",role="primary"] +.Groovy +---- +import org.springframework.cloud.contract.spec.Contract + +Contract.make { + + request { + method(POST()) + url("/graphql") + headers { + contentType("application/json") + } + body(''' +{ + "query":"query queryName($personName: String!) {\\n personToCheck(name: $personName) {\\n name\\n age\\n }\\n}\\n\\n\\n\\n", + "variables":{"personName":"Old Enough"}, + "operationName":"queryName" +} +''') + } + + response { + status(200) + headers { + contentType("application/json") + } + body('''\ +{ + "data": { + "personToCheck": { + "name": "Old Enough", + "age": "40" + } + } +} +''') + } + metadata(verifier: [ + tool: "graphql" + ]) +} +---- + +[source,yml,indent=0,subs="verbatim,attributes",role="secondary"] +.YAML +---- +--- +request: + method: "POST" + url: "/graphql" + headers: + Content-Type: "application/json" + body: + query: "query queryName($personName: String!) { personToCheck(name: $personName) + { name age } }" + variables: + personName: "Old Enough" + operationName: "queryName" + matchers: + headers: + - key: "Content-Type" + regex: "application/json.*" + regexType: "as_string" +response: + status: 200 + headers: + Content-Type: "application/json" + body: + data: + personToCheck: + name: "Old Enough" + age: "40" + matchers: + headers: + - key: "Content-Type" + regex: "application/json.*" + regexType: "as_string" +name: "shouldRetrieveOldEnoughPerson" +metadata: + verifier: + tool: "graphql" +---- +==== + +Adding the metadata section will change the way the default, WireMock stub is built. It will now use the Spring Cloud Contract request matcher, so that e.g. the `query` part of the GraphQL request gets compared against the real request by ignoring whitespaces. + +[[features-graphql-producer]] +==== Producer Side Setup + +On the producer side your configuration can look as follows. + +==== +[source,xml,indent=0,subs="verbatim,attributes",role="primary"] +.Maven +---- + + org.springframework.cloud + spring-cloud-contract-maven-plugin + ${spring-cloud-contract.version} + true + + EXPLICIT + com.example.BaseClass + + +---- + +[source,groovy,indent=0,subs="verbatim,attributes",role="secondary"] +.Gradle +---- +contracts { + testMode = "EXPLICIT" + baseClassForTests = "com.example.BaseClass" +} +---- +==== + +The base class would set up the applicatoin running on a random port. + +==== +[source,java,indent=0,subs="verbatim,attributes"] +.Base Class +---- +@SpringBootTest(classes = ProducerApplication.class, + properties = "graphql.servlet.websocket.enabled=false", + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public abstract class BaseClass { + + @LocalServerPort int port; + + @BeforeEach + public void setup() { + RestAssured.baseURI = "http://localhost:" + port; + } +} + +---- +==== + +[[features-graphql-consumer]] +==== Consumer Side Setup + +Example of a consumer side test of the GraphQL API. + +==== +[source,java,indent=0,subs="verbatim,attributes"] +.Consumer Side Test +---- +@SpringBootTest(webEnvironment = WebEnvironment.NONE) +public class BeerControllerGraphQLTest { + + @RegisterExtension + static StubRunnerExtension rule = new StubRunnerExtension() + .downloadStub("com.example","beer-api-producer-graphql") + .stubsMode(StubRunnerProperties.StubsMode.LOCAL); + + private static final String REQUEST_BODY = "{\n" + + "\"query\":\"query queryName($personName: String!) {\\n personToCheck(name: $personName) {\\n name\\n age\\n }\\n}\"," + + "\"variables\":{\"personName\":\"Old Enough\"},\n" + + "\"operationName\":\"queryName\"\n" + + "}"; + + @Test + public void should_send_a_graphql_request() { + ResponseEntity responseEntity = new RestTemplate() + .exchange(RequestEntity + .post(URI.create("http://localhost:" + rule.findStubUrl("beer-api-producer-graphql").getPort() + "/graphql")) + .contentType(MediaType.APPLICATION_JSON) + .body(REQUEST_BODY), String.class); + + BDDAssertions.then(responseEntity.getStatusCodeValue()).isEqualTo(200); + + } +} + +---- +==== \ No newline at end of file diff --git a/docs/src/main/asciidoc/_project-features-stubrunner.adoc b/docs/src/main/asciidoc/_project-features-stubrunner.adoc index c972659c4c..734c148ca3 100644 --- a/docs/src/main/asciidoc/_project-features-stubrunner.adoc +++ b/docs/src/main/asciidoc/_project-features-stubrunner.adoc @@ -81,7 +81,7 @@ You can pick from the following options of acquiring stubs: - Classpath-scanning solution that searches the classpath with a pattern to retrieve stubs - Writing your own implementation of the `org.springframework.cloud.contract.stubrunner.StubDownloaderBuilder` for full customization -The latter example is described in the <> section. +The latter example is described in the <> section. [[features-stub-runner-downloading-stub]] ===== Downloading Stubs diff --git a/docs/src/main/asciidoc/documentation-overview.adoc b/docs/src/main/asciidoc/documentation-overview.adoc index cb2633b7e7..fd14da2c9a 100644 --- a/docs/src/main/asciidoc/documentation-overview.adoc +++ b/docs/src/main/asciidoc/documentation-overview.adoc @@ -111,18 +111,18 @@ link:docker-project.html[Docker] Finally, we have a few topics for more advanced users: * *Customizing the DSL:* -<> | -<> | -<> | -<> | -<> +<> | +<> | +<> | +<> | +<> * *Customizing WireMock:* -<> | -<> +<> | +<> * *Customizing {project-full-name}:* -<> | -<> | -<> | -<> | -<> | -<> +<> | +<> | +<> | +<> | +<> | +<> diff --git a/docs/src/main/asciidoc/project-features.adoc b/docs/src/main/asciidoc/project-features.adoc index 431e956339..5cb7ca1d07 100644 --- a/docs/src/main/asciidoc/project-features.adoc +++ b/docs/src/main/asciidoc/project-features.adoc @@ -37,4 +37,4 @@ If you want to learn more about any of the classes discussed in this section, yo If you are comfortable with {project-full-name}'s core features, you can continue on and read about -<>. +<>. diff --git a/docs/src/main/java/org/springframework/cloud/contract/docs/Main.java b/docs/src/main/java/org/springframework/cloud/contract/docs/Main.java new file mode 100644 index 0000000000..da692a8ad7 --- /dev/null +++ b/docs/src/main/java/org/springframework/cloud/contract/docs/Main.java @@ -0,0 +1,150 @@ +/* + * Copyright 2012-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.docs; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import com.fasterxml.jackson.module.jsonSchema.JsonSchema; +import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.SimpleBeanDefinitionRegistry; +import org.springframework.cloud.contract.verifier.converter.YamlContract; +import org.springframework.cloud.contract.verifier.util.SpringCloudContractMetadata; +import org.springframework.context.annotation.ClassPathBeanDefinitionScanner; +import org.springframework.core.type.filter.AssignableTypeFilter; +import org.springframework.core.type.filter.TypeFilter; + +/** + * @author Marcin Grzejszczak + */ +public class Main { + + private static final Logger log = LoggerFactory.getLogger(Main.class); + + private final File rootPath; + + public Main(File rootPath) { + this.rootPath = rootPath; + } + + public static void main(String... args) throws Exception { + File rootPath = new File(args[0]); + Main main = new Main(rootPath); + main.produceJsonSchemaOfAYamlModel(); + main.produceAdocWithAllOfMetadataClasses(); + } + + void produceJsonSchemaOfAYamlModel() throws IOException { + log.info("Generating schema..."); + String schemaString = generateJsonSchemaForClass(YamlContract.class); + File schemaFile = new File(this.rootPath, "target/contract_schema.json"); + Files.write(schemaFile.toPath(), schemaString.getBytes()); + log.info("Generated schema!"); + } + + private String generateJsonSchemaForClass(Class clazz) + throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + mapper.enable(SerializationFeature.INDENT_OUTPUT); + JsonSchemaGenerator schemaGen = new JsonSchemaGenerator(mapper); + JsonSchema schema = schemaGen.generateSchema(clazz); + return mapper.writeValueAsString(schema); + } + + void produceAdocWithAllOfMetadataClasses() throws Exception { + log.info("Produce adoc with all metadata..."); + List metadata = metadataClasses(); + File doc = new File(this.rootPath, "target/metadata.adoc"); + StringBuilder sb = adocWithMetadata(metadata); + Files.write(doc.toPath(), sb.toString().getBytes()); + log.info("Produced adoc with all metadata!"); + } + + private StringBuilder adocWithMetadata(List metadata) throws Exception { + YAMLMapper mapper = new YAMLMapper(); + mapper.enable(SerializationFeature.INDENT_OUTPUT); + mapper.disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER); + StringBuilder sb = new StringBuilder(); + for (Class metadatum : metadata) { + SpringCloudContractMetadata newInstance = (SpringCloudContractMetadata) metadatum + .newInstance(); + String description = newInstance.description(); + String key = newInstance.key(); + List additionalClasses = classesToLookAt(metadatum, newInstance); + // @formatter:off + sb + .append("[[metadata-").append(key).append("]]\n") + .append("##### Metadata `").append(key).append("`\n\n") + .append("* key: `").append(key).append("`").append("\n") + .append("* description:\n\n").append(description).append("\n\n") + .append("Example:\n\n") + .append("```yaml\n").append(mapper.writeValueAsString(newInstance)).append("\n```\n\n") + // To make the schema collapsable + .append("+++
+++\nClick here to expand the JSON schema:\n+++
+++\n") + .append("```json\n").append(generateJsonSchemaForClass(metadatum)).append("\n```\n") + .append("+++
+++\n\n") + .append("If you are interested in learning more about the types and its properties, check out the following classes:\n\n") + .append(additionalClasses.stream().map(aClass -> "* `" + aClass.getName() + "`").collect(Collectors.joining("\n"))) + .append("\n\n"); + // @formatter:on + } + return sb; + } + + private List classesToLookAt(Class metadatum, + SpringCloudContractMetadata newInstance) { + List additionalClasses = new ArrayList<>(); + additionalClasses.add(metadatum); + additionalClasses.addAll(newInstance.additionalClassesToLookAt()); + return additionalClasses; + } + + private List metadataClasses() throws ClassNotFoundException { + BeanDefinitionRegistry bdr = new SimpleBeanDefinitionRegistry(); + ClassPathBeanDefinitionScanner s = new ClassPathBeanDefinitionScanner(bdr, false); + TypeFilter tf = new AssignableTypeFilter(SpringCloudContractMetadata.class); + s.addIncludeFilter(tf); + String basePackage = "org.springframework.cloud.contract"; + s.scan(basePackage); + String[] beans = bdr.getBeanDefinitionNames(); + List metadata = new ArrayList<>(); + for (String bean : beans) { + BeanDefinition beanDefinition = bdr.getBeanDefinition(bean); + String beanClassName = beanDefinition.getBeanClassName(); + if (beanClassName != null && !beanClassName.contains(basePackage)) { + continue; + } + metadata.add(Class.forName(beanClassName)); + } + return metadata; + } + +} diff --git a/docs/src/test/java/org/springframework/cloud/contract/docs/AdditionalResourcesGenerationTests.java b/docs/src/test/java/org/springframework/cloud/contract/docs/AdditionalResourcesGenerationTests.java index 416bcb5e43..b4eab03696 100644 --- a/docs/src/test/java/org/springframework/cloud/contract/docs/AdditionalResourcesGenerationTests.java +++ b/docs/src/test/java/org/springframework/cloud/contract/docs/AdditionalResourcesGenerationTests.java @@ -19,36 +19,14 @@ package org.springframework.cloud.contract.docs; import java.io.File; import java.io.IOException; import java.nio.file.Files; -import java.util.ArrayList; import java.util.Collection; -import java.util.List; -import java.util.stream.Collectors; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; -import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; -import com.fasterxml.jackson.module.jsonSchema.JsonSchema; -import com.fasterxml.jackson.module.jsonSchema.JsonSchemaGenerator; import org.assertj.core.api.BDDAssertions; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.config.BeanDefinition; -import org.springframework.beans.factory.support.BeanDefinitionRegistry; -import org.springframework.beans.factory.support.SimpleBeanDefinitionRegistry; import org.springframework.cloud.contract.spec.Contract; -import org.springframework.cloud.contract.verifier.converter.YamlContract; import org.springframework.cloud.contract.verifier.converter.YamlContractConverter; -import org.springframework.cloud.contract.verifier.util.SpringCloudContractMetadata; -import org.springframework.context.annotation.ClassPathBeanDefinitionScanner; -import org.springframework.core.type.filter.AssignableTypeFilter; -import org.springframework.core.type.filter.TypeFilter; -/** - * This test generates additional resources in the `target` folder that are then - * referenced by the documentation. - */ class AdditionalResourcesGenerationTests { // @formatter:off @@ -113,22 +91,6 @@ class AdditionalResourcesGenerationTests { + " predefined:\n"; // @formatter:on - @Test - void should_produce_a_json_schema_of_a_yaml_model() throws IOException { - String schemaString = generateJsonSchemaForClass(YamlContract.class); - File schemaFile = new File("target/contract_schema.json"); - Files.write(schemaFile.toPath(), schemaString.getBytes()); - } - - private String generateJsonSchemaForClass(Class clazz) - throws JsonProcessingException { - ObjectMapper mapper = new ObjectMapper(); - mapper.enable(SerializationFeature.INDENT_OUTPUT); - JsonSchemaGenerator schemaGen = new JsonSchemaGenerator(mapper); - JsonSchema schema = schemaGen.generateSchema(clazz); - return mapper.writeValueAsString(schema); - } - @Test void should_convert_yaml_to_contract() throws IOException { File ymlFile = new File("target/contract.yml"); @@ -139,73 +101,4 @@ class AdditionalResourcesGenerationTests { BDDAssertions.then(contracts).isNotEmpty(); } - @Test - void should_produce_an_adoc_with_all_of_metadata_classes() throws Exception { - List metadata = metadataClasses(); - File doc = new File("target/metadata.adoc"); - - StringBuilder sb = adocWithMetadata(metadata); - - Files.write(doc.toPath(), sb.toString().getBytes()); - } - - private StringBuilder adocWithMetadata(List metadata) throws Exception { - YAMLMapper mapper = new YAMLMapper(); - mapper.enable(SerializationFeature.INDENT_OUTPUT); - mapper.disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER); - StringBuilder sb = new StringBuilder(); - for (Class metadatum : metadata) { - SpringCloudContractMetadata newInstance = (SpringCloudContractMetadata) metadatum - .newInstance(); - String description = newInstance.description(); - String key = newInstance.key(); - List additionalClasses = classesToLookAt(metadatum, newInstance); - // @formatter:off - sb - .append("[[metadata-").append(key).append("]]\n") - .append("##### Metadata `").append(key).append("`\n\n") - .append("* key: `").append(key).append("`").append("\n") - .append("* description:\n\n").append(description).append("\n\n") - .append("Example:\n\n") - .append("```yaml\n").append(mapper.writeValueAsString(newInstance)).append("\n```\n\n") - // To make the schema collapsable - .append("+++
+++\nClick here to expand the JSON schema:\n+++
+++\n") - .append("```json\n").append(generateJsonSchemaForClass(metadatum)).append("\n```\n") - .append("+++
+++\n\n") - .append("If you are interested in learning more about the types and its properties, check out the following classes:\n\n") - .append(additionalClasses.stream().map(aClass -> "* `" + aClass.getName() + "`").collect(Collectors.joining("\n"))) - .append("\n\n"); - // @formatter:on - } - return sb; - } - - private List classesToLookAt(Class metadatum, - SpringCloudContractMetadata newInstance) { - List additionalClasses = new ArrayList<>(); - additionalClasses.add(metadatum); - additionalClasses.addAll(newInstance.additionalClassesToLookAt()); - return additionalClasses; - } - - private List metadataClasses() throws ClassNotFoundException { - BeanDefinitionRegistry bdr = new SimpleBeanDefinitionRegistry(); - ClassPathBeanDefinitionScanner s = new ClassPathBeanDefinitionScanner(bdr, false); - TypeFilter tf = new AssignableTypeFilter(SpringCloudContractMetadata.class); - s.addIncludeFilter(tf); - String basePackage = "org.springframework.cloud.contract"; - s.scan(basePackage); - String[] beans = bdr.getBeanDefinitionNames(); - List metadata = new ArrayList<>(); - for (String bean : beans) { - BeanDefinition beanDefinition = bdr.getBeanDefinition(bean); - String beanClassName = beanDefinition.getBeanClassName(); - if (beanClassName != null && !beanClassName.contains(basePackage)) { - continue; - } - metadata.add(Class.forName(beanClassName)); - } - return metadata; - } - } diff --git a/pom.xml b/pom.xml index 2aaf431345..566689fcc6 100644 --- a/pom.xml +++ b/pom.xml @@ -45,6 +45,7 @@ 5.5.1.201910021850-r 1 3.4.6 + 2.19.0 1.3.2 5.6.2 diff --git a/spring-cloud-contract-stub-runner/src/main/java/org/springframework/cloud/contract/stubrunner/provider/wiremock/WireMockHttpServerStub.java b/spring-cloud-contract-stub-runner/src/main/java/org/springframework/cloud/contract/stubrunner/provider/wiremock/WireMockHttpServerStub.java index 9e142d1f5b..af41e9b572 100644 --- a/spring-cloud-contract-stub-runner/src/main/java/org/springframework/cloud/contract/stubrunner/provider/wiremock/WireMockHttpServerStub.java +++ b/spring-cloud-contract-stub-runner/src/main/java/org/springframework/cloud/contract/stubrunner/provider/wiremock/WireMockHttpServerStub.java @@ -22,6 +22,7 @@ import java.io.InputStream; import java.nio.charset.Charset; import java.nio.file.Files; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.List; @@ -46,6 +47,7 @@ import org.springframework.cloud.contract.stubrunner.HttpServerStubConfigurer; import org.springframework.cloud.contract.verifier.builder.handlebars.HandlebarsEscapeHelper; import org.springframework.cloud.contract.verifier.builder.handlebars.HandlebarsJsonPathHelper; import org.springframework.cloud.contract.verifier.dsl.wiremock.DefaultResponseTransformer; +import org.springframework.cloud.contract.verifier.dsl.wiremock.SpringCloudContractRequestMatcher; import org.springframework.cloud.contract.verifier.dsl.wiremock.WireMockExtensions; import org.springframework.cloud.contract.wiremock.WireMockSpring; import org.springframework.core.io.support.SpringFactoriesLoader; @@ -92,7 +94,9 @@ public class WireMockHttpServerStub implements HttpServerStub { } } else { - extensions.add(new DefaultResponseTransformer(false, helpers())); + extensions.addAll( + Arrays.asList(new DefaultResponseTransformer(false, helpers()), + new SpringCloudContractRequestMatcher())); } return extensions.toArray(new Extension[extensions.size()]); } diff --git a/spring-cloud-contract-tools/spring-cloud-contract-converters/src/test/groovy/org/springframework/cloud/contract/verifier/wiremock/DslToWireMockClientConverterSpec.groovy b/spring-cloud-contract-tools/spring-cloud-contract-converters/src/test/groovy/org/springframework/cloud/contract/verifier/wiremock/DslToWireMockClientConverterSpec.groovy index 5fc66c7f9a..e19f3e3614 100755 --- a/spring-cloud-contract-tools/spring-cloud-contract-converters/src/test/groovy/org/springframework/cloud/contract/verifier/wiremock/DslToWireMockClientConverterSpec.groovy +++ b/spring-cloud-contract-tools/spring-cloud-contract-converters/src/test/groovy/org/springframework/cloud/contract/verifier/wiremock/DslToWireMockClientConverterSpec.groovy @@ -119,7 +119,7 @@ class DslToWireMockClientConverterSpec extends Specification { ContractVerifierDslConverter.convertAsCollection(new File("/"), file))).values().first() then: JSONAssert.assertEquals(''' -{"request":{"url":"/multipart","method":"POST","headers":{"Content-Type":{"matches":"multipart/form-data.*"}},"bodyPatterns":[{"matches" : ".*--(.*)\\r\\nContent-Disposition: form-data; name=\\"file\\"; filename=\\".+\\"\\r\\n(Content-Type: .*\\r\\n)?(Content-Transfer-Encoding: .*\\r\\n)?(Content-Length: \\\\d+\\r\\n)?\\r\\n.+\\r\\n--\\\\1.*"}]},"response":{"status":200,"body":"hello","transformers":["response-template"]}} +{"request":{"url":"/multipart","method":"POST","headers":{"Content-Type":{"matches":"multipart/form-data.*"}},"bodyPatterns":[{"matches" : ".*--(.*)\\r\\nContent-Disposition: form-data; name=\\"file\\"; filename=\\".+\\"\\r\\n(Content-Type: .*\\r\\n)?(Content-Transfer-Encoding: .*\\r\\n)?(Content-Length: \\\\d+\\r\\n)?\\r\\n.+\\r\\n--\\\\1.*"}]},"response":{"status":200,"body":"hello","transformers":["response-template", "spring-cloud-contract" ]}} ''', json, false) and: StubMapping mapping = stubMappingIsValidWireMockStub(json) @@ -752,7 +752,7 @@ class DslToWireMockClientConverterSpec extends Specification { "headers" : { "Content-Type" : "application/json" }, - "transformers" : [ "response-template" ] + "transformers" : [ "response-template", "spring-cloud-contract" ] } } ''' @@ -992,7 +992,7 @@ class DslToWireMockClientConverterSpec extends Specification { "CorrelationID" : "11111111-1111-1111-1111-111111111111", "Content-Type" : "application/json;charset=UTF-8" }, - "transformers" : [ "response-template" ] + "transformers" : [ "response-template", "spring-cloud-contract" ] }, "priority" : 1 } diff --git a/spring-cloud-contract-verifier/pom.xml b/spring-cloud-contract-verifier/pom.xml index 74809b1a01..1d30c776b8 100644 --- a/spring-cloud-contract-verifier/pom.xml +++ b/spring-cloud-contract-verifier/pom.xml @@ -189,6 +189,12 @@ mockito-core compile + + net.javacrumbs.json-unit + json-unit-assertj + ${json-unit-assertj.version} + compile + org.slf4j slf4j-simple diff --git a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockRequestStubStrategy.groovy b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockRequestStubStrategy.groovy index e6c35f053c..e286c301a4 100755 --- a/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockRequestStubStrategy.groovy +++ b/spring-cloud-contract-verifier/src/main/groovy/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockRequestStubStrategy.groovy @@ -19,6 +19,7 @@ package org.springframework.cloud.contract.verifier.dsl.wiremock import java.util.regex.Pattern import com.github.tomakehurst.wiremock.client.WireMock +import com.github.tomakehurst.wiremock.extension.Parameters import com.github.tomakehurst.wiremock.http.RequestMethod import com.github.tomakehurst.wiremock.matching.ContentPattern import com.github.tomakehurst.wiremock.matching.RequestPattern @@ -45,6 +46,9 @@ import org.springframework.cloud.contract.spec.internal.QueryParameters import org.springframework.cloud.contract.spec.internal.RegexPatterns import org.springframework.cloud.contract.spec.internal.RegexProperty import org.springframework.cloud.contract.spec.internal.Request +import org.springframework.cloud.contract.verifier.converter.YamlContract +import org.springframework.cloud.contract.verifier.converter.YamlContractConverter +import org.springframework.cloud.contract.verifier.dsl.ContractVerifierMetadata import org.springframework.cloud.contract.verifier.file.SingleContractMetadata import org.springframework.cloud.contract.verifier.util.ContentType import org.springframework.cloud.contract.verifier.util.ContentUtils @@ -52,6 +56,7 @@ import org.springframework.cloud.contract.verifier.util.JsonPaths import org.springframework.cloud.contract.verifier.util.JsonToJsonPathsConverter import org.springframework.cloud.contract.verifier.util.MapConverter import org.springframework.cloud.contract.verifier.util.xml.XmlToXPathsConverter +import org.springframework.util.StringUtils import static org.springframework.cloud.contract.spec.internal.MatchingStrategy.Type.BINARY_EQUAL_TO import static org.springframework.cloud.contract.spec.internal.MatchingType.COMMAND @@ -105,6 +110,28 @@ class WireMockRequestStubStrategy extends BaseWireMockStubStrategy { return requestPatternBuilder.build() } + private void appendBody(RequestPatternBuilder requestPatternBuilder) { + if (contract.metadata.containsKey(ContractVerifierMetadata.METADATA_KEY)) { + ContractVerifierMetadata metadata = ContractVerifierMetadata.fromMetadata(contract.getMetadata()) + appendSpringCloudContractMatcher(metadata, requestPatternBuilder) + if (StringUtils.isEmpty(metadata.getTool())) { + doAppendBody(requestPatternBuilder) + } + } + else { + doAppendBody(requestPatternBuilder) + } + } + + private void appendSpringCloudContractMatcher(ContractVerifierMetadata metadata, RequestPatternBuilder requestPatternBuilder) { + Parameters parameters = Parameters.one("tool", metadata.getTool() ?: "unknown"); + YamlContractConverter converter = new YamlContractConverter(); + List contracts = converter.convertTo(Collections.singleton(contract)); + Map store = converter.store(contracts); + parameters.put("contract", new String(store.entrySet().iterator().next().value)) + requestPatternBuilder.andMatching(SpringCloudContractRequestMatcher.NAME, parameters) + } + private RequestPatternBuilder appendMethodAndUrl() { if (!request.method) { return null @@ -115,7 +142,7 @@ class WireMockRequestStubStrategy extends BaseWireMockStubStrategy { return RequestPatternBuilder.newRequestPattern(requestMethod, urlPattern) } - private void appendBody(RequestPatternBuilder requestPattern) { + private void doAppendBody(RequestPatternBuilder requestPattern) { if (!request.body) { return } diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/dsl/ContractVerifierMetadata.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/dsl/ContractVerifierMetadata.java new file mode 100644 index 0000000000..ab97ded801 --- /dev/null +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/dsl/ContractVerifierMetadata.java @@ -0,0 +1,71 @@ +/* + * Copyright 2012-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.dsl; + +import java.util.Map; + +import org.springframework.cloud.contract.verifier.util.MetadataUtil; +import org.springframework.cloud.contract.verifier.util.SpringCloudContractMetadata; +import org.springframework.lang.NonNull; + +/** + * Metadata representation of the Contract Verifier. + * + * @author Marcin Grzejszczak + * @since 3.0.0 + */ +public class ContractVerifierMetadata implements SpringCloudContractMetadata { + + /** + * Metadata entry in the contract. + */ + public static final String METADATA_KEY = "verifier"; + + public ContractVerifierMetadata(String tool) { + this.tool = tool; + } + + public ContractVerifierMetadata() { + } + + private String tool; + + public String getTool() { + return this.tool; + } + + public void setTool(String tool) { + this.tool = tool; + } + + @NonNull + public static ContractVerifierMetadata fromMetadata(Map metadata) { + return MetadataUtil.fromMetadata(metadata, METADATA_KEY, + new ContractVerifierMetadata()); + } + + @Override + public String key() { + return METADATA_KEY; + } + + @Override + public String description() { + return "Metadata entries used by the framework"; + } + +} diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/dsl/wiremock/SpringCloudContractRequestMatcher.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/dsl/wiremock/SpringCloudContractRequestMatcher.java new file mode 100644 index 0000000000..52fd4787bc --- /dev/null +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/dsl/wiremock/SpringCloudContractRequestMatcher.java @@ -0,0 +1,193 @@ +/* + * 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.dsl.wiremock; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.extension.Parameters; +import com.github.tomakehurst.wiremock.http.Request; +import com.github.tomakehurst.wiremock.matching.MatchResult; +import com.github.tomakehurst.wiremock.matching.RequestMatcherExtension; +import net.javacrumbs.jsonunit.assertj.JsonAssertions; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.assertj.core.api.Assertions; +import org.codehaus.plexus.util.StringUtils; + +import org.springframework.cloud.contract.verifier.converter.YamlContract; +import org.springframework.cloud.contract.verifier.converter.YamlContractConverter; + +/** + * Provides custom matching for WireMock's stub requests. + * + * @author Marcin Grzejszczak + * @since 3.0.0 + */ +public class SpringCloudContractRequestMatcher extends RequestMatcherExtension { + + private static final List SUPPORTED_TOOLS = Arrays + .asList(GraphQlMatcher.NAME); + + /** + * Name of the transformer inside the stub. + */ + public static final String NAME = "spring-cloud-contract"; + + private static final Log log = LogFactory + .getLog(SpringCloudContractRequestMatcher.class); + + @Override + public MatchResult match(Request request, Parameters parameters) { + if (!parameters.containsKey("contract") || !parameters.containsKey("tool")) { + return MatchResult.noMatch(); + } + String tool = parameters.getString("tool"); + if (!SUPPORTED_TOOLS.contains(tool)) { + if (log.isWarnEnabled()) { + log.warn("The tool [" + tool + "] is not supported"); + } + return MatchResult.noMatch(); + } + String string = parameters.getString("contract"); + List contracts; + try { + contracts = YamlContractConverter.INSTANCE.read(string.getBytes()); + } + catch (Exception e) { + if (log.isWarnEnabled()) { + log.warn("An exception occurred while trying to parse the contract", e); + } + return MatchResult.noMatch(); + } + return new RequestMatcherFactory(matchers()).pick(tool).match(contracts, request, + parameters); + } + + List matchers() { + return Arrays.asList(new GraphQlMatcher()); + } + + @Override + public String getName() { + return NAME; + } + +} + +class RequestMatcherFactory { + + private final List matchers; + + RequestMatcherFactory(List matchers) { + this.matchers = matchers; + } + + RequestMatcher pick(String tool) { + return this.matchers.stream().filter(m -> m.isApplicable(tool)).findFirst() + .orElse(new NotMatchingRequestMatcher()); + } + +} + +interface RequestMatcher { + + MatchResult match(List contracts, Request request, + Parameters parameters); + + default boolean assertThat(Runnable runnable) { + try { + runnable.run(); + return true; + } + catch (Exception | AssertionError er) { + return false; + } + } + + default boolean isApplicable(String tool) { + return false; + } + +} + +class NotMatchingRequestMatcher implements RequestMatcher { + + @Override + public MatchResult match(List contracts, Request request, + Parameters parameters) { + return MatchResult.noMatch(); + } + + @Override + public boolean isApplicable(String tool) { + return true; + } + +} + +class GraphQlMatcher implements RequestMatcher { + + static final String NAME = "graphql"; + + private static final Log log = LogFactory.getLog(GraphQlMatcher.class); + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public MatchResult match(List contracts, Request request, + Parameters parameters) { + YamlContract contract = contracts.get(0); + // TODO: What if the body is in files? + Map body = (Map) contract.request.body; + try { + Map jsonBodyFromContract = body; + Map jsonBodyFromRequest = this.objectMapper.readerForMapOf(Object.class) + .readValue(request.getBody()); + String query = (String) jsonBodyFromContract.get("query"); + String queryFromRequest = (String) jsonBodyFromRequest.get("query"); + Map variables = (Map) jsonBodyFromContract.get("variables"); + Map variablesFromRequest = (Map) jsonBodyFromRequest.get("variables"); + String operationName = (String) jsonBodyFromContract.get("operationName"); + String operationNameFromRequest = (String) jsonBodyFromRequest + .get("operationName"); + boolean queryMatches = assertThat(() -> Assertions.assertThat(query) + .isEqualToIgnoringWhitespace(queryFromRequest)); + boolean variablesMatch = assertThat(() -> JsonAssertions + .assertThatJson(variables).isEqualTo(variablesFromRequest)); + boolean operationMatches = StringUtils.equals(operationName, + operationNameFromRequest); + return MatchResult.of(queryMatches && variablesMatch && operationMatches); + } + catch (Exception e) { + if (log.isWarnEnabled()) { + log.warn( + "An exception occurred while trying to parse the graphql entries", + e); + } + return MatchResult.noMatch(); + } + } + + @Override + public boolean isApplicable(String tool) { + return NAME.equals(tool); + } + +} diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockResponseStubStrategy.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockResponseStubStrategy.java index 0f968d781f..90500a62f2 100755 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockResponseStubStrategy.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/dsl/wiremock/WireMockResponseStubStrategy.java @@ -84,7 +84,8 @@ class WireMockResponseStubStrategy extends BaseWireMockStubStrategy { .flatMap(Collection::stream).map(Extension::getName) .toArray(String[]::new); } - return new String[] { new DefaultResponseTransformer().getName() }; + return new String[] { new DefaultResponseTransformer().getName(), + SpringCloudContractRequestMatcher.NAME }; } private void appendHeaders(ResponseDefinitionBuilder builder) { diff --git a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/messaging/internal/ContractVerifierMessageMetadata.java b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/messaging/internal/ContractVerifierMessageMetadata.java index 3b53110470..f7d721c9f8 100644 --- a/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/messaging/internal/ContractVerifierMessageMetadata.java +++ b/spring-cloud-contract-verifier/src/main/java/org/springframework/cloud/contract/verifier/messaging/internal/ContractVerifierMessageMetadata.java @@ -64,7 +64,7 @@ public class ContractVerifierMessageMetadata implements SpringCloudContractMetad @Override public String description() { - return "Internal metadata entries used by the framework"; + return "Internal metadata entries used by the framework, related to messaging"; } /** diff --git a/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/dsl/ContractVerifierMetadataTests.java b/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/dsl/ContractVerifierMetadataTests.java new file mode 100644 index 0000000000..50befc5991 --- /dev/null +++ b/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/dsl/ContractVerifierMetadataTests.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020-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.dsl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import org.assertj.core.api.BDDAssertions; +import org.junit.jupiter.api.Test; + +class ContractVerifierMetadataTests { + + YAMLMapper mapper = new YAMLMapper(); + + @Test + void should_parse_the_metadata_entry() throws JsonProcessingException { + // @formatter:off + String yamlEntry = "verifier:\n" + + " tool: graphql"; + // @formatter:on + + ContractVerifierMetadata metadata = ContractVerifierMetadata.fromMetadata( + this.mapper.readerForMapOf(Object.class).readValue(yamlEntry)); + + String serialized = this.mapper.writer().forType(ContractVerifierMetadata.class) + .writeValueAsString(metadata); + BDDAssertions.then(serialized).isEqualToNormalizingPunctuationAndWhitespace( + yamlEntry.replace("verifier:\n", "")); + } + +} diff --git a/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/dsl/wiremock/GraphQLRequestMatcherTests.java b/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/dsl/wiremock/GraphQLRequestMatcherTests.java new file mode 100644 index 0000000000..45cd479b43 --- /dev/null +++ b/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/dsl/wiremock/GraphQLRequestMatcherTests.java @@ -0,0 +1,217 @@ +/* + * Copyright 2020-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.dsl.wiremock; + +import com.github.tomakehurst.wiremock.http.Request; +import com.github.tomakehurst.wiremock.matching.MatchResult; +import org.assertj.core.api.BDDAssertions; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; + +import org.springframework.cloud.contract.verifier.converter.YamlContractConverter; + +class GraphQLRequestMatcherTests { + + // @formatter:off + private static final String YAML_WITH_INVALID_VARIABLES = "---\n" + + "request:\n" + + " method: \"POST\"\n" + + " url: \"/graphql\"\n" + + " headers:\n" + + " Content-Type: \"application/json\"\n" + + " body:\n" + + " query: \"query queryName($personName: String!) {\\n personToCheck(name: $personName)\\\n" + + " \\ {\\n name\\n age\\n }\\n}\\n\\n\\n\\n\"\n" + + " variables: This should actually be a map not a string\n" + + " operationName: \"queryName\"\n" + + " matchers:\n" + + " headers:\n" + + " - key: \"Content-Type\"\n" + + " regex: \"application/json.*\"\n" + + " regexType: \"as_string\"\n" + + "response:\n" + + " status: 200\n" + + " headers:\n" + + " Content-Type: \"application/json\"\n" + + " body:\n" + + " data:\n" + + " personToCheck:\n" + + " name: \"Old Enough\"\n" + + " age: \"40\"\n" + + " matchers:\n" + + " headers:\n" + + " - key: \"Content-Type\"\n" + + " regex: \"application/json.*\"\n" + + " regexType: \"as_string\"\n" + + "name: \"shouldRetrieveOldEnoughPerson\"\n" + + "metadata:\n" + + " verifier:\n" + + " tool: \"graphql\"\n"; + // @formatter:on + + @Test + void should_not_match_when_exception_occurs_while_trying_to_read_missing_request_body() { + GraphQlMatcher matcher = new GraphQlMatcher(); + + MatchResult result = matcher.match( + YamlContractConverter.INSTANCE + .read(YAML_WITH_INVALID_VARIABLES.getBytes()), + BDDMockito.mock(Request.class), null); + + BDDAssertions.then(result.isExactMatch()).isFalse(); + } + + @Test + void should_not_match_when_exception_occurs_while_trying_to_parse_graphql_entries() { + GraphQlMatcher matcher = new GraphQlMatcher(); + + MatchResult result = matcher.match(YamlContractConverter.INSTANCE + .read(YAML_WITH_INVALID_VARIABLES.getBytes()), request(), null); + + BDDAssertions.then(result.isExactMatch()).isFalse(); + } + + // @formatter:off + private static final String PROPER_YAML = "---\n" + + "request:\n" + + " method: \"POST\"\n" + + " url: \"/graphql\"\n" + + " headers:\n" + + " Content-Type: \"application/json\"\n" + + " body:\n" + + " query: \"query queryName($personName: String!) { personToCheck(name: $personName)" + + " { name age } }\"\n" + + " variables:\n" + + " personName: \"Old Enough\"\n" + + " operationName: \"queryName\"\n" + + " matchers:\n" + + " headers:\n" + + " - key: \"Content-Type\"\n" + + " regex: \"application/json.*\"\n" + + " regexType: \"as_string\"\n" + + "response:\n" + + " status: 200\n" + + " headers:\n" + + " Content-Type: \"application/json\"\n" + + " body:\n" + + " data:\n" + + " personToCheck:\n" + + " name: \"Old Enough\"\n" + + " age: \"40\"\n" + + " matchers:\n" + + " headers:\n" + + " - key: \"Content-Type\"\n" + + " regex: \"application/json.*\"\n" + + " regexType: \"as_string\"\n" + + "name: \"shouldRetrieveOldEnoughPerson\"\n" + + "metadata:\n" + + " verifier:\n" + + " tool: \"graphql\"\n"; + // @formatter:on + + @Test + void should_not_match_when_unsupported_tool() { + BDDAssertions.then(new GraphQlMatcher().isApplicable("unknown")).isFalse(); + } + + @Test + void should_match_when_the_graphql_part_matches_regardless_of_whitespace_entries_in_the_query() { + GraphQlMatcher matcher = new GraphQlMatcher(); + + MatchResult result = matcher.match( + YamlContractConverter.INSTANCE.read(PROPER_YAML.getBytes()), request(), + null); + + BDDAssertions.then(result.isExactMatch()).isTrue(); + } + + // @formatter:off + private static final String NOT_MATCHING_QUERY_BODY = "{\n" + + "\"query\":\"this should not match\",\n" + + "\"variables\":{\"personName\":\"Old Enough\"},\n" + + "\"operationName\":\"queryName\"\n" + + "}"; + // @formatter:on + + @Test + void should_not_match_when_the_query_does_not_match() { + GraphQlMatcher matcher = new GraphQlMatcher(); + + MatchResult result = matcher.match( + YamlContractConverter.INSTANCE.read(PROPER_YAML.getBytes()), + request(NOT_MATCHING_QUERY_BODY), null); + + BDDAssertions.then(result.isExactMatch()).isFalse(); + } + + // @formatter:off + private static final String NOT_MATCHING_VARIABLES_BODY = "{\n" + + "\"query\":\"query queryName($personName: String!) {\\n personToCheck(name: $personName) {\\n name\\n age\\n }\\n}\\n\\n\\n\\n\",\n" + + "\"variables\":{\"Not matching key\":\"Not matching value\"},\n" + + "\"operationName\":\"queryName\"\n" + + "}"; + // @formatter:on + + @Test + void should_not_match_when_the_variables_does_not_match() { + GraphQlMatcher matcher = new GraphQlMatcher(); + + MatchResult result = matcher.match( + YamlContractConverter.INSTANCE.read(PROPER_YAML.getBytes()), + request(NOT_MATCHING_VARIABLES_BODY), null); + + BDDAssertions.then(result.isExactMatch()).isFalse(); + } + + // @formatter:off + private static final String NOT_MATCHING_OPERATION_NAME_BODY = "{\n" + + "\"query\":\"query queryName($personName: String!) {\\n personToCheck(name: $personName) {\\n name\\n age\\n }\\n}\\n\\n\\n\\n\",\n" + + "\"variables\":{\"personName\":\"Old Enough\"},\n" + + "\"operationName\":\"not matching operation name\"\n" + + "}"; + // @formatter:on + + @Test + void should_not_match_when_the_operation_name_does_not_match() { + GraphQlMatcher matcher = new GraphQlMatcher(); + + MatchResult result = matcher.match( + YamlContractConverter.INSTANCE.read(PROPER_YAML.getBytes()), + request(NOT_MATCHING_OPERATION_NAME_BODY), null); + + BDDAssertions.then(result.isExactMatch()).isFalse(); + } + + // @formatter:off + private static final String REQUEST_BODY = "{\n" + + "\"query\":\"query queryName($personName: String!) {\\n personToCheck(name: $personName) {\\n name\\n age\\n }\\n}\\n\\n\\n\\n\",\n" + + "\"variables\":{\"personName\":\"Old Enough\"},\n" + + "\"operationName\":\"queryName\"\n" + + "}"; + // @formatter:on + + private Request request() { + return request(REQUEST_BODY); + } + + private Request request(String body) { + Request request = BDDMockito.mock(Request.class); + BDDMockito.given(request.getBody()).willReturn(body.getBytes()); + return request; + } + +} diff --git a/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/dsl/wiremock/SpringCloudContractRequestMatcherTests.java b/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/dsl/wiremock/SpringCloudContractRequestMatcherTests.java new file mode 100644 index 0000000000..374f008418 --- /dev/null +++ b/spring-cloud-contract-verifier/src/test/java/org/springframework/cloud/contract/verifier/dsl/wiremock/SpringCloudContractRequestMatcherTests.java @@ -0,0 +1,184 @@ +/* + * Copyright 2020-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.dsl.wiremock; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.github.tomakehurst.wiremock.extension.Parameters; +import com.github.tomakehurst.wiremock.http.Request; +import com.github.tomakehurst.wiremock.matching.MatchResult; +import org.assertj.core.api.BDDAssertions; +import org.junit.jupiter.api.Test; +import org.mockito.BDDMockito; +import reactor.util.function.Tuple2; +import reactor.util.function.Tuples; + +import org.springframework.cloud.contract.verifier.converter.YamlContract; + +class SpringCloudContractRequestMatcherTests { + + @Test + void should_not_match_when_contract_missing() { + SpringCloudContractRequestMatcher matcher = new SpringCloudContractRequestMatcher() { + + @Override + List matchers() { + return Collections.singletonList((contracts, request, parameters) -> { + throw new UnsupportedOperationException("This should not be called"); + }); + } + }; + + MatchResult result = matcher.match(BDDMockito.mock(Request.class), + Parameters.one("tool", "foo")); + + BDDAssertions.then(result.isExactMatch()).isFalse(); + } + + @Test + void should_not_match_when_tool_missing() { + SpringCloudContractRequestMatcher matcher = new SpringCloudContractRequestMatcher() { + + @Override + List matchers() { + return Collections.singletonList((contracts, request, parameters) -> { + throw new UnsupportedOperationException("This should not be called"); + }); + } + }; + + MatchResult result = matcher.match(BDDMockito.mock(Request.class), + Parameters.one("contract", "value")); + + BDDAssertions.then(result.isExactMatch()).isFalse(); + } + + @Test + void should_not_match_when_unsupported_tool() { + SpringCloudContractRequestMatcher matcher = new SpringCloudContractRequestMatcher() { + + @Override + List matchers() { + return Collections.singletonList((contracts, request, parameters) -> { + throw new UnsupportedOperationException("This should not be called"); + }); + } + }; + + MatchResult result = matcher.match(BDDMockito.mock(Request.class), + Parameters.one("contract", "value")); + + BDDAssertions.then(result.isExactMatch()).isFalse(); + } + + private static final String PROPER_YAML = "---\n" + + "request:\n" + + " method: \"POST\"\n" + + " url: \"/graphql\"\n" + + " headers:\n" + + " Content-Type: \"application/json\"\n" + + " body:\n" + + " query: \"query queryName($personName: String!) { personToCheck(name: $personName)" + + " { name age } }\"\n" + + " variables:\n" + + " personName: \"Old Enough\"\n" + + " operationName: \"queryName\"\n" + + " matchers:\n" + + " headers:\n" + + " - key: \"Content-Type\"\n" + + " regex: \"application/json.*\"\n" + + " regexType: \"as_string\"\n" + + "response:\n" + + " status: 200\n" + + " headers:\n" + + " Content-Type: \"application/json\"\n" + + " body:\n" + + " data:\n" + + " personToCheck:\n" + + " name: \"Old Enough\"\n" + + " age: \"40\"\n" + + " matchers:\n" + + " headers:\n" + + " - key: \"Content-Type\"\n" + + " regex: \"application/json.*\"\n" + + " regexType: \"as_string\"\n" + + "name: \"shouldRetrieveOldEnoughPerson\"\n" + + "metadata:\n" + + " verifier:\n" + + " tool: \"graphql\"\n"; + + @Test + void should_not_match_when_exception_occurs_while_trying_to_parse_contract() { + SpringCloudContractRequestMatcher matcher = new SpringCloudContractRequestMatcher() { + @Override + List matchers() { + return Collections.singletonList((contracts, request, parameters) -> { + throw new UnsupportedOperationException("This should not be called"); + }); + } + }; + + MatchResult result = matcher.match(BDDMockito.mock(Request.class), + toMap(Tuples.of("tool", "unsupported"), Tuples.of("contract", PROPER_YAML))); + + BDDAssertions.then(result.isExactMatch()).isFalse(); + } + // @formatter:off + // @formatter:on + + @Test + void should_delegate_to_an_applicable_request_matcher() { + SpringCloudContractRequestMatcher matcher = new SpringCloudContractRequestMatcher() { + @Override + List matchers() { + return Collections.singletonList(new ApplicableRequestMatcher()); + } + }; + + MatchResult result = matcher.match(BDDMockito.mock(Request.class), + toMap(Tuples.of("tool", "graphql"), Tuples.of("contract", PROPER_YAML))); + + BDDAssertions.then(result.isExactMatch()).isTrue(); + } + + private Parameters toMap(Tuple2... tuple2) { + Map map = new HashMap<>(); + for (Tuple2 tuple : tuple2) { + map.put(tuple.getT1(), tuple.getT2()); + } + return Parameters.from(map); + } + +} + +class ApplicableRequestMatcher implements RequestMatcher { + + @Override + public MatchResult match(List contracts, Request request, + Parameters parameters) { + return MatchResult.of(true); + } + + @Override + public boolean isApplicable(String tool) { + return true; + } + +}