Added GraphQL support (#1506)

fixes #670
This commit is contained in:
Marcin Grzejszczak
2020-09-11 09:32:11 +02:00
committed by GitHub
parent 2c5b71222a
commit a2fac97148
19 changed files with 1135 additions and 132 deletions

View File

@@ -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
----
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<version>${spring-cloud-contract.version}</version>
<extensions>true</extensions>
<configuration>
<testMode>EXPLICIT</testMode>
<baseClassForTests>com.example.BaseClass</baseClassForTests>
</configuration>
</plugin>
----
[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<String> 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);
}
}
----
====

View File

@@ -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 <<advanced.adoc#customization-custom-stub-runner, Custom Stub Runner>> section.
The latter example is described in the <<advanced.html#customization-custom-stub-runner, Custom Stub Runner>> section.
[[features-stub-runner-downloading-stub]]
===== Downloading Stubs

View File

@@ -111,18 +111,18 @@ link:docker-project.html[Docker]
Finally, we have a few topics for more advanced users:
* *Customizing the DSL:*
<<advanced.adoc#contract-dsl-customization, DSL Customization>> |
<<advanced.adoc#contract-dsl-extending-common-jar, Common JAR>> |
<<advanced.adoc#contract-dsl-test-dep, Test Dependency>> |
<<advanced.adoc#contract-dsl-plugin-dep, Plugin Dependency>> |
<<advanced.adoc#contract-dsl-referencing, Referencing the DSL>>
<<advanced.html#contract-dsl-customization, DSL Customization>> |
<<advanced.html#contract-dsl-extending-common-jar, Common JAR>> |
<<advanced.html#contract-dsl-test-dep, Test Dependency>> |
<<advanced.html#contract-dsl-plugin-dep, Plugin Dependency>> |
<<advanced.html#contract-dsl-referencing, Referencing the DSL>>
* *Customizing WireMock:*
<<advanced.adoc#customization-wiremock-extension, Extensions>> |
<<advanced.adoc#customization-wiremock-configuration, Configuration>>
<<advanced.html#customization-wiremock-extension, Extensions>> |
<<advanced.html#customization-wiremock-configuration, Configuration>>
* *Customizing {project-full-name}:*
<<advanced.adoc#contract-dsl-pluggable-architecture, Pluggable Architecture>> |
<<advanced.adoc#contract-dsl-custom-contract-converter, Contract Converter>> |
<<advanced.adoc#contract-dsl-custom-test-generator, Test Generator>> |
<<advanced.adoc#contract-dsl-custom-stub-generator, Stub Generator>> |
<<advanced.adoc#contract-dsl-custom-stub-runner, Stub Runner>> |
<<advanced.adoc#contract-dsl-custom-stub-downloader, Stub Downloader>>
<<advanced.html#contract-dsl-pluggable-architecture, Pluggable Architecture>> |
<<advanced.html#contract-dsl-custom-contract-converter, Contract Converter>> |
<<advanced.html#contract-dsl-custom-test-generator, Test Generator>> |
<<advanced.html#contract-dsl-custom-stub-generator, Stub Generator>> |
<<advanced.html#contract-dsl-custom-stub-runner, Stub Runner>> |
<<advanced.html#contract-dsl-custom-stub-downloader, Stub Downloader>>

View File

@@ -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
<<advanced.adoc, {project-full-name}'s advanced features>>.
<<advanced.html, {project-full-name}'s advanced features>>.

View File

@@ -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<Class> 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<Class> 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<Class> 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("+++ <details><summary> +++\nClick here to expand the JSON schema:\n+++ </summary><div> +++\n")
.append("```json\n").append(generateJsonSchemaForClass(metadatum)).append("\n```\n")
.append("+++ </div></details> +++\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<Class> classesToLookAt(Class metadatum,
SpringCloudContractMetadata newInstance) {
List<Class> additionalClasses = new ArrayList<>();
additionalClasses.add(metadatum);
additionalClasses.addAll(newInstance.additionalClassesToLookAt());
return additionalClasses;
}
private List<Class> 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<Class> 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;
}
}

View File

@@ -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<Class> metadata = metadataClasses();
File doc = new File("target/metadata.adoc");
StringBuilder sb = adocWithMetadata(metadata);
Files.write(doc.toPath(), sb.toString().getBytes());
}
private StringBuilder adocWithMetadata(List<Class> 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<Class> 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("+++ <details><summary> +++\nClick here to expand the JSON schema:\n+++ </summary><div> +++\n")
.append("```json\n").append(generateJsonSchemaForClass(metadatum)).append("\n```\n")
.append("+++ </div></details> +++\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<Class> classesToLookAt(Class metadatum,
SpringCloudContractMetadata newInstance) {
List<Class> additionalClasses = new ArrayList<>();
additionalClasses.add(metadatum);
additionalClasses.addAll(newInstance.additionalClassesToLookAt());
return additionalClasses;
}
private List<Class> 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<Class> 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;
}
}