From e5e157528b735b9b81fe77e32b87588a01e239f5 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:32:09 +0100 Subject: [PATCH 01/19] Add dependency management for Spring GraphQL This commit adds dependency management in Spring Boot for Spring GraphQL and GraphQL Java. See gh-29140 --- .../spring-boot-dependencies/build.gradle | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/spring-boot-project/spring-boot-dependencies/build.gradle b/spring-boot-project/spring-boot-dependencies/build.gradle index 4ce7bffb48..74d24d1b6a 100644 --- a/spring-boot-project/spring-boot-dependencies/build.gradle +++ b/spring-boot-project/spring-boot-dependencies/build.gradle @@ -372,6 +372,13 @@ bom { ] } } + library("GraphQL Java", "17.3") { + group("com.graphql-java") { + modules = [ + "graphql-java" + ] + } + } library("Groovy", "3.0.9") { group("org.codehaus.groovy") { imports = [ @@ -1655,6 +1662,14 @@ bom { ] } } + library("Spring GraphQL", "1.0.0-SNAPSHOT") { + group("org.springframework.graphql") { + modules = [ + "spring-graphql", + "spring-graphql-test" + ] + } + } library("Spring HATEOAS", "1.4.0") { group("org.springframework.hateoas") { modules = [ From de808834f588eb93647af809bc0e9727522b246c Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:32:37 +0100 Subject: [PATCH 02/19] Auto-configure Spring GraphQL base infrastructure This commit adds the auto-configuration for setting up the base Spring GraphQL infrastructure. Because GraphQL doesn't depend on any particular transport, we must have a separate configuration for creating: * the `GraphQlSource`, which holds the schema and the `GraphQL` instance * the `GraphQlService` for executing incoming requests * the `BatchLoaderRegistry` for batch loading support * the `AnnotatedControllerConfigurer` for supporting the annotated controllers programming model This comes with a starting point for the `"spring.graphql.*"` configuration properties; we can now configure the locations and file extensions of GraphQL schema files we should load and configure at startup. See gh-29140 --- .../DocumentConfigurationProperties.java | 1 + .../spring-boot-autoconfigure/build.gradle | 1 + .../graphql/GraphQlAutoConfiguration.java | 130 ++++++++++ .../graphql/GraphQlProperties.java | 73 ++++++ .../GraphQlSourceBuilderCustomizer.java | 38 +++ .../InvalidSchemaLocationsException.java | 101 ++++++++ .../autoconfigure/graphql/package-info.java | 20 ++ ...itional-spring-configuration-metadata.json | 8 + .../main/resources/META-INF/spring.factories | 1 + .../GraphQlAutoConfigurationTests.java | 230 ++++++++++++++++++ .../InvalidSchemaLocationsExceptionTests.java | 71 ++++++ .../test/resources/graphql/schema.graphqls | 4 + .../resources/graphql/types/book.graphqls | 6 + .../resources/graphql/types/person.custom | 4 + 14 files changed, 688 insertions(+) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlSourceBuilderCustomizer.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsException.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/package-info.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfigurationTests.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsExceptionTests.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/schema.graphqls create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/types/book.graphqls create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/types/person.custom diff --git a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java index 1ea7b3dcf4..cec783ed1c 100644 --- a/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java +++ b/buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java @@ -174,6 +174,7 @@ public class DocumentConfigurationProperties extends DefaultTask { } private void webPrefixes(Config prefix) { + prefix.accept("spring.graphql"); prefix.accept("spring.hateoas"); prefix.accept("spring.http"); prefix.accept("spring.servlet"); diff --git a/spring-boot-project/spring-boot-autoconfigure/build.gradle b/spring-boot-project/spring-boot-autoconfigure/build.gradle index fe176b12d4..114b137b11 100644 --- a/spring-boot-project/spring-boot-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-autoconfigure/build.gradle @@ -187,6 +187,7 @@ dependencies { optional("org.springframework.data:spring-data-neo4j") optional("org.springframework.data:spring-data-r2dbc") optional("org.springframework.data:spring-data-redis") + optional("org.springframework.graphql:spring-graphql") optional("org.springframework.hateoas:spring-hateoas") optional("org.springframework.security:spring-security-acl") optional("org.springframework.security:spring-security-config") diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.java new file mode 100644 index 0000000000..6b2327d2d0 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.java @@ -0,0 +1,130 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import graphql.GraphQL; +import graphql.execution.instrumentation.Instrumentation; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.graphql.GraphQlService; +import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer; +import org.springframework.graphql.execution.BatchLoaderRegistry; +import org.springframework.graphql.execution.DataFetcherExceptionResolver; +import org.springframework.graphql.execution.DefaultBatchLoaderRegistry; +import org.springframework.graphql.execution.ExecutionGraphQlService; +import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.execution.MissingSchemaException; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for creating a Spring GraphQL base + * infrastructure. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ GraphQL.class, GraphQlSource.class }) +@EnableConfigurationProperties(GraphQlProperties.class) +public class GraphQlAutoConfiguration { + + private static final Log logger = LogFactory.getLog(GraphQlAutoConfiguration.class); + + private final BatchLoaderRegistry batchLoaderRegistry = new DefaultBatchLoaderRegistry(); + + @Bean + @ConditionalOnMissingBean + public GraphQlSource graphQlSource(ResourcePatternResolver resourcePatternResolver, GraphQlProperties properties, + ObjectProvider exceptionResolversProvider, + ObjectProvider instrumentationsProvider, + ObjectProvider wiringConfigurers, + ObjectProvider sourceCustomizers) { + + List schemaResources = resolveSchemaResources(resourcePatternResolver, + properties.getSchema().getLocations(), properties.getSchema().getFileExtensions()); + GraphQlSource.Builder builder = GraphQlSource.builder() + .schemaResources(schemaResources.toArray(new Resource[0])) + .exceptionResolvers(exceptionResolversProvider.orderedStream().collect(Collectors.toList())) + .instrumentation(instrumentationsProvider.orderedStream().collect(Collectors.toList())); + wiringConfigurers.orderedStream().forEach(builder::configureRuntimeWiring); + sourceCustomizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); + try { + return builder.build(); + } + catch (MissingSchemaException exc) { + throw new InvalidSchemaLocationsException(properties.getSchema().getLocations(), resourcePatternResolver, + exc); + } + } + + @Bean + @ConditionalOnMissingBean + public BatchLoaderRegistry batchLoaderRegistry() { + return this.batchLoaderRegistry; + } + + @Bean + @ConditionalOnMissingBean + public GraphQlService graphQlService(GraphQlSource graphQlSource) { + ExecutionGraphQlService service = new ExecutionGraphQlService(graphQlSource); + service.addDataLoaderRegistrar(this.batchLoaderRegistry); + return service; + } + + @Bean + @ConditionalOnMissingBean + public AnnotatedControllerConfigurer annotatedControllerConfigurer() { + AnnotatedControllerConfigurer annotatedControllerConfigurer = new AnnotatedControllerConfigurer(); + annotatedControllerConfigurer.setConversionService(new DefaultFormattingConversionService()); + return annotatedControllerConfigurer; + } + + private List resolveSchemaResources(ResourcePatternResolver resolver, String[] schemaLocations, + String[] fileExtensions) { + List schemaResources = new ArrayList<>(); + for (String location : schemaLocations) { + for (String extension : fileExtensions) { + String resourcePattern = location + "*" + extension; + try { + schemaResources.addAll(Arrays.asList(resolver.getResources(resourcePattern))); + } + catch (IOException ex) { + logger.debug("Could not resolve schema location: '" + resourcePattern + "'", ex); + } + } + } + return schemaResources; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java new file mode 100644 index 0000000000..cb9699ef0b --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql; + +import java.util.Arrays; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * {@link ConfigurationProperties properties} for Spring GraphQL. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@ConfigurationProperties(prefix = "spring.graphql") +public class GraphQlProperties { + + private final Schema schema = new Schema(); + + public Schema getSchema() { + return this.schema; + } + + public static class Schema { + + /** + * Locations of GraphQL schema files. + */ + private String[] locations = new String[] { "classpath:graphql/**/" }; + + /** + * File extensions for GraphQL schema files. + */ + private String[] fileExtensions = new String[] { ".graphqls", ".gqls" }; + + public String[] getLocations() { + return this.locations; + } + + public void setLocations(String[] locations) { + this.locations = appendSlashIfNecessary(locations); + } + + public String[] getFileExtensions() { + return this.fileExtensions; + } + + public void setFileExtensions(String[] fileExtensions) { + this.fileExtensions = fileExtensions; + } + + private String[] appendSlashIfNecessary(String[] locations) { + return Arrays.stream(locations).map((location) -> location.endsWith("/") ? location : location + "/") + .toArray(String[]::new); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlSourceBuilderCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlSourceBuilderCustomizer.java new file mode 100644 index 0000000000..5157d7a7f9 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlSourceBuilderCustomizer.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql; + +import org.springframework.graphql.execution.GraphQlSource; + +/** + * Callback interface that can be implemented by beans wishing to customize properties of + * {@link org.springframework.graphql.execution.GraphQlSource.Builder} whilst retaining + * default auto-configuration. + * + * @author Rossen Stoyanchev + * @since 2.7.0 + */ +@FunctionalInterface +public interface GraphQlSourceBuilderCustomizer { + + /** + * Customize the {@link GraphQlSource.Builder} instance. + * @param builder builder the builder to customize + */ + void customize(GraphQlSource.Builder builder); + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsException.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsException.java new file mode 100644 index 0000000000..71f15f9861 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsException.java @@ -0,0 +1,101 @@ +/* + * Copyright 2020-2021 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.boot.autoconfigure.graphql; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.core.NestedRuntimeException; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.util.Assert; + +/** + * {@link InvalidSchemaLocationsException} thrown when no schema file could be found in + * the provided locations. + * + * @author Brian Clozel + * @since 2.7.0 + */ +public class InvalidSchemaLocationsException extends NestedRuntimeException { + + private final List schemaLocations; + + public InvalidSchemaLocationsException(String[] locations, ResourcePatternResolver resolver) { + this(locations, resolver, null); + } + + public InvalidSchemaLocationsException(String[] locations, ResourcePatternResolver resolver, Throwable cause) { + super("No schema file could be found in the provided locations.", cause); + Assert.notEmpty(locations, "locations should not be empty"); + Assert.notNull(resolver, "resolver should not be null"); + List providedLocations = new ArrayList<>(); + for (String location : locations) { + try { + String uri = resolver.getResource(location).getURI().toASCIIString(); + providedLocations.add(new SchemaLocation(location, uri)); + } + catch (IOException ex) { + providedLocations.add(new SchemaLocation(location, "")); + } + } + this.schemaLocations = Collections.unmodifiableList(providedLocations); + } + + /** + * Return the list of provided locations where to look for schemas. + * @return the list of locations + */ + public List getSchemaLocations() { + return this.schemaLocations; + } + + /** + * The location where to look for schemas. + */ + public static class SchemaLocation { + + private final String location; + + private final String uri; + + SchemaLocation(String location, String uri) { + this.location = location; + this.uri = uri; + } + + /** + * Return the location String to be resolved by a {@link ResourcePatternResolver}. + * @return the location + */ + public String getLocation() { + return this.location; + } + + /** + * Return the resolved URI String for this location, an empty String if resolution + * failed. + * @return the resolved location or an empty String + */ + public String getUri() { + return this.uri; + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/package-info.java new file mode 100644 index 0000000000..a44fbef233 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2021 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. + */ + +/** + * Auto-configuration for Spring GraphQL. + */ +package org.springframework.boot.autoconfigure.graphql; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 4ee9a6122f..8ab79d02b5 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -951,6 +951,14 @@ "level": "error" } }, + { + "name": "spring.graphql.schema.locations", + "defaultValue": "classpath:graphql/**/" + }, + { + "name": "spring.graphql.schema.file-extensions", + "defaultValue": ".graphqls,.gqls" + }, { "name": "spring.groovy.template.prefix", "defaultValue": "" diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories index e368ec72bd..5931fe4350 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories @@ -68,6 +68,7 @@ org.springframework.boot.autoconfigure.data.web.SpringDataWebAutoConfiguration,\ org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAutoConfiguration,\ org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration,\ org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration,\ +org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration,\ org.springframework.boot.autoconfigure.groovy.template.GroovyTemplateAutoConfiguration,\ org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration,\ org.springframework.boot.autoconfigure.h2.H2ConsoleAutoConfiguration,\ diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfigurationTests.java new file mode 100644 index 0000000000..5f1f388f9a --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfigurationTests.java @@ -0,0 +1,230 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql; + +import graphql.GraphQL; +import graphql.execution.instrumentation.ChainedInstrumentation; +import graphql.execution.instrumentation.Instrumentation; +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.RuntimeWiring; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.graphql.GraphQlService; +import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer; +import org.springframework.graphql.execution.BatchLoaderRegistry; +import org.springframework.graphql.execution.DataFetcherExceptionResolver; +import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.execution.MissingSchemaException; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GraphQlAutoConfiguration}. + */ +class GraphQlAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GraphQlAutoConfiguration.class)); + + @Test + void shouldContributeDefaultBeans() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(GraphQlSource.class); + assertThat(context).hasSingleBean(BatchLoaderRegistry.class); + assertThat(context).hasSingleBean(GraphQlService.class); + assertThat(context).hasSingleBean(AnnotatedControllerConfigurer.class); + }); + } + + @Test + void schemaShouldScanNestedFolders() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(GraphQlSource.class); + GraphQlSource graphQlSource = context.getBean(GraphQlSource.class); + GraphQLSchema schema = graphQlSource.schema(); + assertThat(schema.getObjectType("Book")).isNotNull(); + }); + } + + @Test + void shouldFailWhenSchemaFileIsMissing() { + this.contextRunner.withPropertyValues("spring.graphql.schema.locations:classpath:missing/").run((context) -> { + assertThat(context).hasFailed(); + assertThat(context).getFailure().getRootCause().isInstanceOf(MissingSchemaException.class); + }); + } + + @Test + void shouldUseProgrammaticallyDefinedBuilder() { + this.contextRunner.withUserConfiguration(CustomGraphQlBuilderConfiguration.class).run((context) -> { + assertThat(context).hasBean("customGraphQlSourceBuilder"); + assertThat(context).hasSingleBean(GraphQlSource.Builder.class); + }); + } + + @Test + void shouldScanLocationsWithCustomExtension() { + this.contextRunner.withPropertyValues("spring.graphql.schema.file-extensions:.graphqls,.custom") + .run((context) -> { + assertThat(context).hasSingleBean(GraphQlSource.class); + GraphQlSource graphQlSource = context.getBean(GraphQlSource.class); + GraphQLSchema schema = graphQlSource.schema(); + assertThat(schema.getObjectType("Book")).isNotNull(); + assertThat(schema.getObjectType("Person")).isNotNull(); + }); + } + + @Test + void shouldBackOffWithCustomGraphQlSource() { + this.contextRunner.withUserConfiguration(CustomGraphQlSourceConfiguration.class).run((context) -> { + assertThat(context).getBeanNames(GraphQlSource.class).containsOnly("customGraphQlSource"); + assertThat(context).hasSingleBean(GraphQlProperties.class); + }); + } + + @Test + void shouldConfigureDataFetcherExceptionResolvers() { + this.contextRunner.withUserConfiguration(DataFetcherExceptionResolverConfiguration.class).run((context) -> { + GraphQlSource graphQlSource = context.getBean(GraphQlSource.class); + GraphQL graphQL = graphQlSource.graphQl(); + assertThat(graphQL.getQueryStrategy()).extracting("dataFetcherExceptionHandler") + .satisfies((exceptionHandler) -> assertThat(exceptionHandler.getClass().getName()) + .endsWith("ExceptionResolversExceptionHandler")); + }); + } + + @Test + void shouldConfigureInstrumentation() { + this.contextRunner.withUserConfiguration(InstrumentationConfiguration.class).run((context) -> { + GraphQlSource graphQlSource = context.getBean(GraphQlSource.class); + Instrumentation customInstrumentation = context.getBean("customInstrumentation", Instrumentation.class); + GraphQL graphQL = graphQlSource.graphQl(); + assertThat(graphQL).extracting("instrumentation").isInstanceOf(ChainedInstrumentation.class) + .extracting("instrumentations", InstanceOfAssertFactories.iterable(Instrumentation.class)) + .contains(customInstrumentation); + }); + } + + @Test + void shouldApplyRuntimeWiringConfigurers() { + this.contextRunner.withUserConfiguration(RuntimeWiringConfigurerConfiguration.class).run((context) -> { + RuntimeWiringConfigurerConfiguration.CustomRuntimeWiringConfigurer configurer = context + .getBean(RuntimeWiringConfigurerConfiguration.CustomRuntimeWiringConfigurer.class); + assertThat(configurer.applied).isTrue(); + }); + } + + @Test + void shouldApplyGraphQlSourceBuilderCustomizer() { + this.contextRunner.withUserConfiguration(GraphQlSourceBuilderCustomizerConfiguration.class).run((context) -> { + GraphQlSourceBuilderCustomizerConfiguration.CustomGraphQlSourceBuilderCustomizer customizer = context + .getBean(GraphQlSourceBuilderCustomizerConfiguration.CustomGraphQlSourceBuilderCustomizer.class); + assertThat(customizer.applied).isTrue(); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomGraphQlBuilderConfiguration { + + @Bean + GraphQlSource.Builder customGraphQlSourceBuilder() { + return GraphQlSource.builder().schemaResources(new ClassPathResource("graphql/schema.graphqls"), + new ClassPathResource("graphql/types/book.graphqls")); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomGraphQlSourceConfiguration { + + @Bean + GraphQlSource customGraphQlSource() { + return mock(GraphQlSource.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class DataFetcherExceptionResolverConfiguration { + + @Bean + DataFetcherExceptionResolver customDataFetcherExceptionResolver() { + return mock(DataFetcherExceptionResolver.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class InstrumentationConfiguration { + + @Bean + Instrumentation customInstrumentation() { + return mock(Instrumentation.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class RuntimeWiringConfigurerConfiguration { + + @Bean + CustomRuntimeWiringConfigurer customRuntimeWiringConfigurer() { + return new CustomRuntimeWiringConfigurer(); + } + + public static class CustomRuntimeWiringConfigurer implements RuntimeWiringConfigurer { + + public boolean applied = false; + + @Override + public void configure(RuntimeWiring.Builder builder) { + this.applied = true; + } + + } + + } + + static class GraphQlSourceBuilderCustomizerConfiguration { + + @Bean + CustomGraphQlSourceBuilderCustomizer customGraphQlSourceBuilderCustomizer() { + return new CustomGraphQlSourceBuilderCustomizer(); + } + + public static class CustomGraphQlSourceBuilderCustomizer implements GraphQlSourceBuilderCustomizer { + + public boolean applied = false; + + @Override + public void customize(GraphQlSource.Builder builder) { + this.applied = true; + } + + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsExceptionTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsExceptionTests.java new file mode 100644 index 0000000000..b61e3cb50d --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsExceptionTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link InvalidSchemaLocationsException}. + * + * @author Brian Clozel + */ +class InvalidSchemaLocationsExceptionTests { + + private final String schemaFolder = "graphql/"; + + private final String[] locations = new String[] { "classpath:" + this.schemaFolder }; + + @Test + void shouldRejectEmptyLocations() { + assertThatIllegalArgumentException().isThrownBy( + () -> new InvalidSchemaLocationsException(new String[] {}, new PathMatchingResourcePatternResolver())) + .isInstanceOf(IllegalArgumentException.class).withMessage("locations should not be empty"); + } + + @Test + void shouldRejectNullResolver() { + assertThatIllegalArgumentException().isThrownBy(() -> new InvalidSchemaLocationsException(this.locations, null)) + .isInstanceOf(IllegalArgumentException.class).withMessage("resolver should not be null"); + } + + @Test + void shouldExposeConfiguredLocations() { + InvalidSchemaLocationsException exception = new InvalidSchemaLocationsException(this.locations, + new PathMatchingResourcePatternResolver()); + assertThat(exception.getSchemaLocations()).hasSize(1); + InvalidSchemaLocationsException.SchemaLocation schemaLocation = exception.getSchemaLocations().get(0); + assertThat(schemaLocation.getLocation()).isEqualTo(this.locations[0]); + assertThat(schemaLocation.getUri()).endsWith(this.schemaFolder); + } + + @Test + void shouldNotFailWithUnresolvableLocations() { + String unresolved = "classpath:unresolved/"; + InvalidSchemaLocationsException exception = new InvalidSchemaLocationsException(new String[] { unresolved }, + new PathMatchingResourcePatternResolver()); + assertThat(exception.getSchemaLocations()).hasSize(1); + InvalidSchemaLocationsException.SchemaLocation schemaLocation = exception.getSchemaLocations().get(0); + assertThat(schemaLocation.getLocation()).isEqualTo(unresolved); + assertThat(schemaLocation.getUri()).isEmpty(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/schema.graphqls b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/schema.graphqls new file mode 100644 index 0000000000..c2009b57ff --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/schema.graphqls @@ -0,0 +1,4 @@ +type Query { + greeting(name: String! = "Spring"): String! + bookById(id: ID): Book +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/types/book.graphqls b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/types/book.graphqls new file mode 100644 index 0000000000..16217d759b --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/types/book.graphqls @@ -0,0 +1,6 @@ +type Book { + id: ID + name: String + pageCount: Int + author: String +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/types/person.custom b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/types/person.custom new file mode 100644 index 0000000000..8cff728ad1 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/types/person.custom @@ -0,0 +1,4 @@ +type Person { + id: ID + name: String +} \ No newline at end of file From 5cf0a4db9427e7da1dedf8eb6f0a28a6282c2e98 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:32:48 +0100 Subject: [PATCH 03/19] Add FailureAnalyzer for InvalidSchemaLocationsException This commit adds a new `FailureAnalyzer` for exceptions thrown when no GraphQL schema file could be found. This lists configured locations (with `"classpath:..."` notation) and also resolved locations - this helps developers figure out how the pattern are resolved and why schema files cannot be found. See gh-29140 --- ...hemaLocationsExceptionFailureAnalyzer.java | 42 +++++++++++ .../main/resources/META-INF/spring.factories | 1 + ...ocationsExceptionFailureAnalyzerTests.java | 69 +++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsExceptionFailureAnalyzer.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsExceptionFailureAnalyzerTests.java diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsExceptionFailureAnalyzer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsExceptionFailureAnalyzer.java new file mode 100644 index 0000000000..66b8aa34bf --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsExceptionFailureAnalyzer.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql; + +import org.springframework.boot.diagnostics.AbstractFailureAnalyzer; +import org.springframework.boot.diagnostics.FailureAnalysis; + +/** + * An implementation of {@link AbstractFailureAnalyzer} to analyze failures caused by + * {@link InvalidSchemaLocationsException}. + * + * @author Brian Clozel + */ +class InvalidSchemaLocationsExceptionFailureAnalyzer extends AbstractFailureAnalyzer { + + @Override + protected FailureAnalysis analyze(Throwable rootFailure, InvalidSchemaLocationsException cause) { + String message = "Could not find any GraphQL schema file under configured locations."; + StringBuilder action = new StringBuilder( + "Check that the following locations contain schema files: " + System.lineSeparator()); + for (InvalidSchemaLocationsException.SchemaLocation schemaLocation : cause.getSchemaLocations()) { + action.append(String.format("- '%s' (%s)" + System.lineSeparator(), schemaLocation.getUri(), + schemaLocation.getLocation())); + } + return new FailureAnalysis(message, action.toString(), cause); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories index 5931fe4350..71dda4d197 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories @@ -163,6 +163,7 @@ org.springframework.boot.diagnostics.FailureAnalyzer=\ org.springframework.boot.autoconfigure.data.redis.RedisUrlSyntaxFailureAnalyzer,\ org.springframework.boot.autoconfigure.diagnostics.analyzer.NoSuchBeanDefinitionFailureAnalyzer,\ org.springframework.boot.autoconfigure.flyway.FlywayMigrationScriptMissingFailureAnalyzer,\ +org.springframework.boot.autoconfigure.graphql.InvalidSchemaLocationsExceptionFailureAnalyzer,\ org.springframework.boot.autoconfigure.jdbc.DataSourceBeanCreationFailureAnalyzer,\ org.springframework.boot.autoconfigure.jdbc.HikariDriverConfigurationFailureAnalyzer,\ org.springframework.boot.autoconfigure.jooq.NoDslContextBeanFailureAnalyzer,\ diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsExceptionFailureAnalyzerTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsExceptionFailureAnalyzerTests.java new file mode 100644 index 0000000000..24bc06be73 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/InvalidSchemaLocationsExceptionFailureAnalyzerTests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.diagnostics.FailureAnalysis; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link InvalidSchemaLocationsExceptionFailureAnalyzer} + * + * @author Brian Clozel + */ +class InvalidSchemaLocationsExceptionFailureAnalyzerTests { + + private final InvalidSchemaLocationsExceptionFailureAnalyzer analyzer = new InvalidSchemaLocationsExceptionFailureAnalyzer(); + + private final ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + + private final String[] missingLocation = new String[] { + "classpath:org/springframework/boot/autoconfigure/graphql/missing/" }; + + private final String[] existingLocation = new String[] { + "classpath:org/springframework/boot/autoconfigure/graphql/" }; + + @Test + void shouldReportCause() { + InvalidSchemaLocationsException exception = new InvalidSchemaLocationsException(this.existingLocation, + this.resolver); + FailureAnalysis analysis = this.analyzer.analyze(exception); + assertThat(analysis.getCause()).isInstanceOf(InvalidSchemaLocationsException.class); + assertThat(analysis.getAction()).contains("Check that the following locations contain schema files:"); + } + + @Test + void shouldListUnresolvableLocation() { + InvalidSchemaLocationsException exception = new InvalidSchemaLocationsException(this.missingLocation, + this.resolver); + FailureAnalysis analysis = this.analyzer.analyze(exception); + assertThat(analysis.getAction()).contains(this.existingLocation[0]).doesNotContain("file:"); + } + + @Test + void shouldListExistingLocation() { + InvalidSchemaLocationsException exception = new InvalidSchemaLocationsException(this.existingLocation, + this.resolver); + FailureAnalysis analysis = this.analyzer.analyze(exception); + assertThat(analysis.getAction()).contains(this.existingLocation[0]).contains("file:"); + } + +} From b38d04556e9969a36517e32953f2778d48f42e7a Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:32:56 +0100 Subject: [PATCH 04/19] Auto-configure HTTP transport for MVC and WebFlux This commit adds two new auto-configuration classes for Spring GraphQL support. Once the base GraphQL infrastructure is in place, we can now expose the `GraphQlService` over an HTTP transport. Spring GraphQL supports both MVC and WebFlux, so this commit ships with one auto-configuration for each. Developers can configure the HTTP path where the GraphQL resource is exposed using the `spring.graphql.path` configuration property (this defaults to `"/graphql"`). See gh-29140 --- .../graphql/GraphQlProperties.java | 13 ++ .../GraphQlWebFluxAutoConfiguration.java | 102 +++++++++++++ .../graphql/reactive/package-info.java | 20 +++ .../GraphQlWebMvcAutoConfiguration.java | 103 +++++++++++++ .../graphql/servlet/package-info.java | 20 +++ .../main/resources/META-INF/spring.factories | 2 + .../boot/autoconfigure/graphql/Book.java | 76 ++++++++++ .../graphql/GraphQlTestDataFetchers.java | 59 ++++++++ .../GraphQlWebFluxAutoConfigurationTests.java | 128 ++++++++++++++++ .../GraphQlWebMvcAutoConfigurationTests.java | 139 ++++++++++++++++++ 10 files changed, 662 insertions(+) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/package-info.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/package-info.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/Book.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlTestDataFetchers.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java index cb9699ef0b..2fc1a76429 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java @@ -29,8 +29,21 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "spring.graphql") public class GraphQlProperties { + /** + * Path at which to expose a GraphQL request HTTP endpoint. + */ + private String path = "/graphql"; + private final Schema schema = new Schema(); + public String getPath() { + return this.path; + } + + public void setPath(String path) { + this.path = path; + } + public Schema getSchema() { return this.schema; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java new file mode 100644 index 0000000000..53a4ccd96e --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java @@ -0,0 +1,102 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql.reactive; + +import java.util.Collections; +import java.util.stream.Collectors; + +import graphql.GraphQL; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ResourceLoader; +import org.springframework.graphql.GraphQlService; +import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.web.WebGraphQlHandler; +import org.springframework.graphql.web.WebInterceptor; +import org.springframework.graphql.web.webflux.GraphQlHttpHandler; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; + +import static org.springframework.web.reactive.function.server.RequestPredicates.accept; +import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for enabling Spring GraphQL over + * WebFlux. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +@ConditionalOnClass({ GraphQL.class, GraphQlHttpHandler.class }) +@ConditionalOnBean(GraphQlService.class) +@AutoConfigureAfter(GraphQlAutoConfiguration.class) +public class GraphQlWebFluxAutoConfiguration { + + private static final Log logger = LogFactory.getLog(GraphQlWebFluxAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public GraphQlHttpHandler graphQlHttpHandler(WebGraphQlHandler webGraphQlHandler) { + return new GraphQlHttpHandler(webGraphQlHandler); + } + + @Bean + @ConditionalOnMissingBean + public WebGraphQlHandler webGraphQlHandler(GraphQlService service, + ObjectProvider interceptorsProvider) { + return WebGraphQlHandler.builder(service) + .interceptors(interceptorsProvider.orderedStream().collect(Collectors.toList())).build(); + } + + @Bean + public RouterFunction graphQlEndpoint(GraphQlHttpHandler handler, GraphQlSource graphQlSource, + GraphQlProperties properties, ResourceLoader resourceLoader) { + + String graphQLPath = properties.getPath(); + if (logger.isInfoEnabled()) { + logger.info("GraphQL endpoint HTTP POST " + graphQLPath); + } + + RouterFunctions.Builder builder = RouterFunctions.route() + .GET(graphQLPath, + (request) -> ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED) + .headers((headers) -> headers.setAllow(Collections.singleton(HttpMethod.POST))).build()) + .POST(graphQLPath, accept(MediaType.APPLICATION_JSON).and(contentType(MediaType.APPLICATION_JSON)), + handler::handleRequest); + + return builder.build(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/package-info.java new file mode 100644 index 0000000000..c0b8ec565e --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020-2021 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. + */ + +/** + * Auto-configuration classes for WebFlux support in Spring GraphQL. + */ +package org.springframework.boot.autoconfigure.graphql.reactive; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java new file mode 100644 index 0000000000..281eacac44 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java @@ -0,0 +1,103 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql.servlet; + +import java.util.Collections; +import java.util.stream.Collectors; + +import graphql.GraphQL; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ResourceLoader; +import org.springframework.graphql.GraphQlService; +import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.execution.ThreadLocalAccessor; +import org.springframework.graphql.web.WebGraphQlHandler; +import org.springframework.graphql.web.WebInterceptor; +import org.springframework.graphql.web.webmvc.GraphQlHttpHandler; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.function.RequestPredicates; +import org.springframework.web.servlet.function.RouterFunction; +import org.springframework.web.servlet.function.RouterFunctions; +import org.springframework.web.servlet.function.ServerResponse; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for enabling Spring GraphQL over + * Spring MVC. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnClass({ GraphQL.class, GraphQlHttpHandler.class }) +@ConditionalOnBean(GraphQlService.class) +@AutoConfigureAfter(GraphQlAutoConfiguration.class) +public class GraphQlWebMvcAutoConfiguration { + + private static final Log logger = LogFactory.getLog(GraphQlWebMvcAutoConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public GraphQlHttpHandler graphQlHttpHandler(WebGraphQlHandler webGraphQlHandler) { + return new GraphQlHttpHandler(webGraphQlHandler); + } + + @Bean + @ConditionalOnMissingBean + public WebGraphQlHandler webGraphQlHandler(GraphQlService service, + ObjectProvider interceptorsProvider, + ObjectProvider accessorsProvider) { + return WebGraphQlHandler.builder(service) + .interceptors(interceptorsProvider.orderedStream().collect(Collectors.toList())) + .threadLocalAccessors(accessorsProvider.orderedStream().collect(Collectors.toList())).build(); + } + + @Bean + public RouterFunction graphQlRouterFunction(GraphQlHttpHandler handler, GraphQlSource graphQlSource, + GraphQlProperties properties, ResourceLoader resourceLoader) { + + String graphQLPath = properties.getPath(); + if (logger.isInfoEnabled()) { + logger.info("GraphQL endpoint HTTP POST " + graphQLPath); + } + + RouterFunctions.Builder builder = RouterFunctions.route() + .GET(graphQLPath, + (request) -> ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED) + .headers((headers) -> headers.setAllow(Collections.singleton(HttpMethod.POST))).build()) + .POST(graphQLPath, RequestPredicates.contentType(MediaType.APPLICATION_JSON) + .and(RequestPredicates.accept(MediaType.APPLICATION_JSON)), handler::handleRequest); + + return builder.build(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/package-info.java new file mode 100644 index 0000000000..04cf403792 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020-2021 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. + */ + +/** + * Auto-configuration classes for MVC support in Spring GraphQL. + */ +package org.springframework.boot.autoconfigure.graphql.servlet; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories index 71dda4d197..bad6ab475c 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories @@ -69,6 +69,8 @@ org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAuto org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration,\ org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration,\ org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration,\ +org.springframework.boot.autoconfigure.graphql.reactive.GraphQlWebFluxAutoConfiguration,\ +org.springframework.boot.autoconfigure.graphql.servlet.GraphQlWebMvcAutoConfiguration,\ org.springframework.boot.autoconfigure.groovy.template.GroovyTemplateAutoConfiguration,\ org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration,\ org.springframework.boot.autoconfigure.h2.H2ConsoleAutoConfiguration,\ diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/Book.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/Book.java new file mode 100644 index 0000000000..ecd0468216 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/Book.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql; + +/** + * Sample class for + * + * @author Brian Clozel + */ +public class Book { + + String id; + + String name; + + int pageCount; + + String author; + + public Book() { + } + + public Book(String id, String name, int pageCount, String author) { + this.id = id; + this.name = name; + this.pageCount = pageCount; + this.author = author; + } + + public String getId() { + return this.id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public int getPageCount() { + return this.pageCount; + } + + public void setPageCount(int pageCount) { + this.pageCount = pageCount; + } + + public String getAuthor() { + return this.author; + } + + public void setAuthor(String author) { + this.author = author; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlTestDataFetchers.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlTestDataFetchers.java new file mode 100644 index 0000000000..c7dbc44238 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlTestDataFetchers.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql; + +import java.util.Arrays; +import java.util.List; + +import graphql.schema.DataFetcher; +import reactor.core.publisher.Flux; + +import org.springframework.lang.Nullable; + +/** + * Test utility class holding {@link DataFetcher} implementations. + * + * @author Brian Clozel + */ +public final class GraphQlTestDataFetchers { + + private static List books = Arrays.asList(new Book("book-1", "GraphQL for beginners", 100, "John GraphQL"), + new Book("book-2", "Harry Potter and the Philosopher's Stone", 223, "Joanne Rowling"), + new Book("book-3", "Moby Dick", 635, "Moby Dick"), new Book("book-3", "Moby Dick", 635, "Moby Dick")); + + private GraphQlTestDataFetchers() { + + } + + public static DataFetcher getBookByIdDataFetcher() { + return (environment) -> getBookById(environment.getArgument("id")); + } + + public static DataFetcher getBooksOnSaleDataFetcher() { + return (environment) -> getBooksOnSale(environment.getArgument("minPages")); + } + + @Nullable + public static Book getBookById(String id) { + return books.stream().filter((book) -> book.getId().equals(id)).findFirst().orElse(null); + } + + public static Flux getBooksOnSale(int minPages) { + return Flux.fromIterable(books).filter((book) -> book.getPageCount() >= minPages); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java new file mode 100644 index 0000000000..2f4ed179ab --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java @@ -0,0 +1,128 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql.reactive; + +import java.util.Collections; +import java.util.function.Consumer; + +import graphql.schema.idl.TypeRuntimeWiring; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlTestDataFetchers; +import org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.web.WebInterceptor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; + +/** + * Tests for {@link GraphQlWebFluxAutoConfiguration} + * + * @author Brian Clozel + */ +class GraphQlWebFluxAutoConfigurationTests { + + private static final String BASE_URL = "https://spring.example.org/graphql"; + + private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(HttpHandlerAutoConfiguration.class, WebFluxAutoConfiguration.class, + CodecsAutoConfiguration.class, JacksonAutoConfiguration.class, GraphQlAutoConfiguration.class, + GraphQlWebFluxAutoConfiguration.class)) + .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class) + .withPropertyValues("spring.main.web-application-type=reactive"); + + @Test + void simpleQueryShouldWork() { + testWithWebClient((client) -> { + String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author } }"; + client.post().uri("").bodyValue("{ \"query\": \"" + query + "\"}").exchange().expectStatus().isOk() + .expectBody().jsonPath("data.bookById.name").isEqualTo("GraphQL for beginners"); + }); + } + + @Test + void httpGetQueryShouldBeSupported() { + testWithWebClient((client) -> { + String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author } }"; + client.get().uri("?query={query}", "{ \"query\": \"" + query + "\"}").exchange().expectStatus() + .isEqualTo(HttpStatus.METHOD_NOT_ALLOWED).expectHeader().valueEquals("Allow", "POST"); + }); + } + + @Test + void shouldRejectMissingQuery() { + testWithWebClient((client) -> client.post().uri("").bodyValue("{}").exchange().expectStatus().isBadRequest()); + } + + @Test + void shouldRejectQueryWithInvalidJson() { + testWithWebClient((client) -> client.post().uri("").bodyValue(":)").exchange().expectStatus().isBadRequest()); + } + + @Test + void shouldConfigureWebInterceptors() { + testWithWebClient((client) -> { + String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author } }"; + + client.post().uri("").bodyValue("{ \"query\": \"" + query + "\"}").exchange().expectStatus().isOk() + .expectHeader().valueEquals("X-Custom-Header", "42"); + }); + } + + private void testWithWebClient(Consumer consumer) { + this.contextRunner.run((context) -> { + WebTestClient client = WebTestClient.bindToApplicationContext(context).configureClient() + .defaultHeaders((headers) -> { + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + }).baseUrl(BASE_URL).build(); + consumer.accept(client); + }); + } + + @Configuration(proxyBeanMethods = false) + static class DataFetchersConfiguration { + + @Bean + RuntimeWiringConfigurer bookDataFetcher() { + return (builder) -> builder.type(TypeRuntimeWiring.newTypeWiring("Query").dataFetcher("bookById", + GraphQlTestDataFetchers.getBookByIdDataFetcher())); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomWebInterceptor { + + @Bean + WebInterceptor customWebInterceptor() { + return (webInput, interceptorChain) -> interceptorChain.next(webInput) + .map((output) -> output.transform((builder) -> builder.responseHeader("X-Custom-Header", "42"))); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java new file mode 100644 index 0000000000..f9e19a9df8 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java @@ -0,0 +1,139 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql.servlet; + +import graphql.schema.idl.TypeRuntimeWiring; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlTestDataFetchers; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.web.WebInterceptor; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for {@link GraphQlWebMvcAutoConfiguration}. + * + * @author Brian Clozel + */ +class GraphQlWebMvcAutoConfigurationTests { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(DispatcherServletAutoConfiguration.class, WebMvcAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, JacksonAutoConfiguration.class, + GraphQlAutoConfiguration.class, GraphQlWebMvcAutoConfiguration.class)) + .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class) + .withPropertyValues("spring.main.web-application-type=servlet"); + + @Test + void simpleQueryShouldWork() { + testWith((mockMvc) -> { + String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author } }"; + MvcResult result = mockMvc.perform(post("/graphql").content("{\"query\": \"" + query + "\"}")).andReturn(); + mockMvc.perform(asyncDispatch(result)).andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("data.bookById.name").value("GraphQL for beginners")); + }); + } + + @Test + void httpGetQueryShouldBeSupported() { + testWith((mockMvc) -> { + String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author } }"; + mockMvc.perform(get("/graphql?query={query}", "{\"query\": \"" + query + "\"}")) + .andExpect(status().isMethodNotAllowed()).andExpect(header().string("Allow", "POST")); + }); + } + + @Test + void shouldRejectMissingQuery() { + testWith((mockMvc) -> mockMvc.perform(post("/graphql").content("{}")).andExpect(status().isBadRequest())); + } + + @Test + void shouldRejectQueryWithInvalidJson() { + testWith((mockMvc) -> mockMvc.perform(post("/graphql").content(":)")).andExpect(status().isBadRequest())); + } + + @Test + void shouldConfigureWebInterceptors() { + testWith((mockMvc) -> { + String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author } }"; + MvcResult result = mockMvc.perform(post("/graphql").content("{\"query\": \"" + query + "\"}")).andReturn(); + mockMvc.perform(asyncDispatch(result)).andExpect(status().isOk()) + .andExpect(header().string("X-Custom-Header", "42")); + }); + } + + private void testWith(MockMvcConsumer mockMvcConsumer) { + this.contextRunner.run((context) -> { + MediaType mediaType = MediaType.APPLICATION_JSON; + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context) + .defaultRequest(post("/graphql").contentType(mediaType).accept(mediaType)).build(); + mockMvcConsumer.accept(mockMvc); + }); + } + + private interface MockMvcConsumer { + + void accept(MockMvc mockMvc) throws Exception; + + } + + @Configuration(proxyBeanMethods = false) + static class DataFetchersConfiguration { + + @Bean + RuntimeWiringConfigurer bookDataFetcher() { + return (builder) -> builder.type(TypeRuntimeWiring.newTypeWiring("Query").dataFetcher("bookById", + GraphQlTestDataFetchers.getBookByIdDataFetcher())); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomWebInterceptor { + + @Bean + WebInterceptor customWebInterceptor() { + return (webInput, interceptorChain) -> interceptorChain.next(webInput) + .map((output) -> output.transform((builder) -> builder.responseHeader("X-Custom-Header", "42"))); + } + + } + +} From ff9a421786901ddd37732bf8b01111d61df240eb Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:33:05 +0100 Subject: [PATCH 05/19] Auto-configure schema printer endpoint This commit configuresa new endpoint for printing in text format the resolved GraphQL schema. This endpoint is exposed by default under "/graphql/schema" and must be enabled with "spring.graphql.schema.printer=true". See gh-29140 --- .../graphql/GraphQlProperties.java | 24 +++++++++++++++++++ .../GraphQlWebFluxAutoConfiguration.java | 6 +++++ .../GraphQlWebMvcAutoConfiguration.java | 6 +++++ .../GraphQlWebFluxAutoConfigurationTests.java | 13 ++++++++-- .../GraphQlWebMvcAutoConfigurationTests.java | 12 ++++++++-- 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java index 2fc1a76429..16cb18749d 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java @@ -60,6 +60,8 @@ public class GraphQlProperties { */ private String[] fileExtensions = new String[] { ".graphqls", ".gqls" }; + private final Printer printer = new Printer(); + public String[] getLocations() { return this.locations; } @@ -81,6 +83,28 @@ public class GraphQlProperties { .toArray(String[]::new); } + public Printer getPrinter() { + return this.printer; + } + + public static class Printer { + + /** + * Whether the endpoint that prints the schema is enabled. Schema is available + * under spring.graphql.path + "/schema". + */ + private boolean enabled = false; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + } + } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java index 53a4ccd96e..0c068bbe08 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java @@ -40,6 +40,7 @@ import org.springframework.graphql.execution.GraphQlSource; import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.graphql.web.WebInterceptor; import org.springframework.graphql.web.webflux.GraphQlHttpHandler; +import org.springframework.graphql.web.webflux.SchemaHandler; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -96,6 +97,11 @@ public class GraphQlWebFluxAutoConfiguration { .POST(graphQLPath, accept(MediaType.APPLICATION_JSON).and(contentType(MediaType.APPLICATION_JSON)), handler::handleRequest); + if (properties.getSchema().getPrinter().isEnabled()) { + SchemaHandler schemaHandler = new SchemaHandler(graphQlSource); + builder = builder.GET(graphQLPath + "/schema", schemaHandler::handleRequest); + } + return builder.build(); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java index 281eacac44..6820c83b16 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java @@ -41,6 +41,7 @@ import org.springframework.graphql.execution.ThreadLocalAccessor; import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.graphql.web.WebInterceptor; import org.springframework.graphql.web.webmvc.GraphQlHttpHandler; +import org.springframework.graphql.web.webmvc.SchemaHandler; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -97,6 +98,11 @@ public class GraphQlWebMvcAutoConfiguration { .POST(graphQLPath, RequestPredicates.contentType(MediaType.APPLICATION_JSON) .and(RequestPredicates.accept(MediaType.APPLICATION_JSON)), handler::handleRequest); + if (properties.getSchema().getPrinter().isEnabled()) { + SchemaHandler schemaHandler = new SchemaHandler(graphQlSource); + builder = builder.GET(graphQLPath + "/schema", schemaHandler::handleRequest); + } + return builder.build(); } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java index 2f4ed179ab..c39d997fc5 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java @@ -38,6 +38,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; +import static org.hamcrest.Matchers.containsString; + /** * Tests for {@link GraphQlWebFluxAutoConfiguration} * @@ -51,8 +53,8 @@ class GraphQlWebFluxAutoConfigurationTests { .withConfiguration(AutoConfigurations.of(HttpHandlerAutoConfiguration.class, WebFluxAutoConfiguration.class, CodecsAutoConfiguration.class, JacksonAutoConfiguration.class, GraphQlAutoConfiguration.class, GraphQlWebFluxAutoConfiguration.class)) - .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class) - .withPropertyValues("spring.main.web-application-type=reactive"); + .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class).withPropertyValues( + "spring.main.web-application-type=reactive", "spring.graphql.schema.printer.enabled=true"); @Test void simpleQueryShouldWork() { @@ -92,6 +94,13 @@ class GraphQlWebFluxAutoConfigurationTests { }); } + @Test + void shouldExposeSchemaEndpoint() { + testWithWebClient((client) -> client.get().uri("/schema").accept(MediaType.ALL).exchange() + .expectStatus().isOk().expectHeader().contentType(MediaType.TEXT_PLAIN).expectBody(String.class) + .value(containsString("type Book"))); + } + private void testWithWebClient(Consumer consumer) { this.contextRunner.run((context) -> { WebTestClient client = WebTestClient.bindToApplicationContext(context).configureClient() diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java index f9e19a9df8..37c1f16f29 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java @@ -17,6 +17,7 @@ package org.springframework.boot.autoconfigure.graphql.servlet; import graphql.schema.idl.TypeRuntimeWiring; +import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.AutoConfigurations; @@ -56,8 +57,8 @@ class GraphQlWebMvcAutoConfigurationTests { AutoConfigurations.of(DispatcherServletAutoConfiguration.class, WebMvcAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class, JacksonAutoConfiguration.class, GraphQlAutoConfiguration.class, GraphQlWebMvcAutoConfiguration.class)) - .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class) - .withPropertyValues("spring.main.web-application-type=servlet"); + .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class).withPropertyValues( + "spring.main.web-application-type=servlet", "spring.graphql.schema.printer.enabled=true"); @Test void simpleQueryShouldWork() { @@ -99,6 +100,13 @@ class GraphQlWebMvcAutoConfigurationTests { }); } + @Test + void shouldExposeSchemaEndpoint() { + testWith((mockMvc) -> mockMvc.perform(get("/graphql/schema")).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.TEXT_PLAIN)) + .andExpect(content().string(Matchers.containsString("type Book")))); + } + private void testWith(MockMvcConsumer mockMvcConsumer) { this.contextRunner.run((context) -> { MediaType mediaType = MediaType.APPLICATION_JSON; From 0099460155f37fd555f5efedc2c65f8633a83527 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:33:11 +0100 Subject: [PATCH 06/19] Auto-configure graphiql endpoint Spring GraphQL ships with a static version of the graphiql IDE for exploring and querying GraphQL endpoints. See https://github.com/graphql/graphiql for more information. This commit auto-configures the GraphiQL handler for both MVC and WebFlux and points GraphiQL to the GraphQL HTTP endpoint exposed by the application. This feature is disabled by default and can be switched on with "spring.graphql.graphiql.enabled=true". See gh-29140 --- .../graphql/GraphQlProperties.java | 36 +++++++++++++++++++ .../GraphQlWebFluxAutoConfiguration.java | 6 ++++ .../GraphQlWebMvcAutoConfiguration.java | 6 ++++ .../GraphQlWebFluxAutoConfigurationTests.java | 31 +++++++++++----- .../GraphQlWebMvcAutoConfigurationTests.java | 16 +++++++-- 5 files changed, 84 insertions(+), 11 deletions(-) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java index 16cb18749d..c9d7e8bdfa 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java @@ -34,8 +34,14 @@ public class GraphQlProperties { */ private String path = "/graphql"; + private final Graphiql graphiql = new Graphiql(); + private final Schema schema = new Schema(); + public Graphiql getGraphiql() { + return this.graphiql; + } + public String getPath() { return this.path; } @@ -107,4 +113,34 @@ public class GraphQlProperties { } + public static class Graphiql { + + /** + * Path to the GraphiQL UI endpoint. + */ + private String path = "/graphiql"; + + /** + * Whether the default GraphiQL UI is enabled. + */ + private boolean enabled = false; + + public String getPath() { + return this.path; + } + + public void setPath(String path) { + this.path = path; + } + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java index 0c068bbe08..fd55b2d9c2 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java @@ -40,6 +40,7 @@ import org.springframework.graphql.execution.GraphQlSource; import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.graphql.web.WebInterceptor; import org.springframework.graphql.web.webflux.GraphQlHttpHandler; +import org.springframework.graphql.web.webflux.GraphiQlHandler; import org.springframework.graphql.web.webflux.SchemaHandler; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -97,6 +98,11 @@ public class GraphQlWebFluxAutoConfiguration { .POST(graphQLPath, accept(MediaType.APPLICATION_JSON).and(contentType(MediaType.APPLICATION_JSON)), handler::handleRequest); + if (properties.getGraphiql().isEnabled()) { + GraphiQlHandler graphiQlHandler = new GraphiQlHandler(graphQLPath); + builder = builder.GET(properties.getGraphiql().getPath(), graphiQlHandler::handleRequest); + } + if (properties.getSchema().getPrinter().isEnabled()) { SchemaHandler schemaHandler = new SchemaHandler(graphQlSource); builder = builder.GET(graphQLPath + "/schema", schemaHandler::handleRequest); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java index 6820c83b16..534b495657 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java @@ -41,6 +41,7 @@ import org.springframework.graphql.execution.ThreadLocalAccessor; import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.graphql.web.WebInterceptor; import org.springframework.graphql.web.webmvc.GraphQlHttpHandler; +import org.springframework.graphql.web.webmvc.GraphiQlHandler; import org.springframework.graphql.web.webmvc.SchemaHandler; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -98,6 +99,11 @@ public class GraphQlWebMvcAutoConfiguration { .POST(graphQLPath, RequestPredicates.contentType(MediaType.APPLICATION_JSON) .and(RequestPredicates.accept(MediaType.APPLICATION_JSON)), handler::handleRequest); + if (properties.getGraphiql().isEnabled()) { + GraphiQlHandler graphiQLHandler = new GraphiQlHandler(graphQLPath); + builder = builder.GET(properties.getGraphiql().getPath(), graphiQLHandler::handleRequest); + } + if (properties.getSchema().getPrinter().isEnabled()) { SchemaHandler schemaHandler = new SchemaHandler(graphQlSource); builder = builder.GET(graphQLPath + "/schema", schemaHandler::handleRequest); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java index c39d997fc5..e7a05476bf 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java @@ -47,20 +47,21 @@ import static org.hamcrest.Matchers.containsString; */ class GraphQlWebFluxAutoConfigurationTests { - private static final String BASE_URL = "https://spring.example.org/graphql"; + private static final String BASE_URL = "https://spring.example.org/"; private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() .withConfiguration(AutoConfigurations.of(HttpHandlerAutoConfiguration.class, WebFluxAutoConfiguration.class, CodecsAutoConfiguration.class, JacksonAutoConfiguration.class, GraphQlAutoConfiguration.class, GraphQlWebFluxAutoConfiguration.class)) - .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class).withPropertyValues( - "spring.main.web-application-type=reactive", "spring.graphql.schema.printer.enabled=true"); + .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class) + .withPropertyValues("spring.main.web-application-type=reactive", "spring.graphql.graphiql.enabled=true", + "spring.graphql.schema.printer.enabled=true"); @Test void simpleQueryShouldWork() { testWithWebClient((client) -> { String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author } }"; - client.post().uri("").bodyValue("{ \"query\": \"" + query + "\"}").exchange().expectStatus().isOk() + client.post().uri("/graphql").bodyValue("{ \"query\": \"" + query + "\"}").exchange().expectStatus().isOk() .expectBody().jsonPath("data.bookById.name").isEqualTo("GraphQL for beginners"); }); } @@ -69,19 +70,21 @@ class GraphQlWebFluxAutoConfigurationTests { void httpGetQueryShouldBeSupported() { testWithWebClient((client) -> { String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author } }"; - client.get().uri("?query={query}", "{ \"query\": \"" + query + "\"}").exchange().expectStatus() + client.get().uri("/graphql?query={query}", "{ \"query\": \"" + query + "\"}").exchange().expectStatus() .isEqualTo(HttpStatus.METHOD_NOT_ALLOWED).expectHeader().valueEquals("Allow", "POST"); }); } @Test void shouldRejectMissingQuery() { - testWithWebClient((client) -> client.post().uri("").bodyValue("{}").exchange().expectStatus().isBadRequest()); + testWithWebClient( + (client) -> client.post().uri("/graphql").bodyValue("{}").exchange().expectStatus().isBadRequest()); } @Test void shouldRejectQueryWithInvalidJson() { - testWithWebClient((client) -> client.post().uri("").bodyValue(":)").exchange().expectStatus().isBadRequest()); + testWithWebClient( + (client) -> client.post().uri("/graphql").bodyValue(":)").exchange().expectStatus().isBadRequest()); } @Test @@ -89,18 +92,28 @@ class GraphQlWebFluxAutoConfigurationTests { testWithWebClient((client) -> { String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author } }"; - client.post().uri("").bodyValue("{ \"query\": \"" + query + "\"}").exchange().expectStatus().isOk() + client.post().uri("/graphql").bodyValue("{ \"query\": \"" + query + "\"}").exchange().expectStatus().isOk() .expectHeader().valueEquals("X-Custom-Header", "42"); }); } @Test void shouldExposeSchemaEndpoint() { - testWithWebClient((client) -> client.get().uri("/schema").accept(MediaType.ALL).exchange() + testWithWebClient((client) -> client.get().uri("/graphql/schema").accept(MediaType.ALL).exchange() .expectStatus().isOk().expectHeader().contentType(MediaType.TEXT_PLAIN).expectBody(String.class) .value(containsString("type Book"))); } + @Test + void shouldExposeGraphiqlEndpoint() { + testWithWebClient((client) -> { + client.get().uri("/graphiql").exchange().expectStatus().is3xxRedirection().expectHeader() + .location("https://spring.example.org/graphiql?path=/graphql"); + client.get().uri("/graphiql?path=/graphql").accept(MediaType.ALL).exchange().expectStatus().isOk() + .expectHeader().contentType(MediaType.TEXT_HTML); + }); + } + private void testWithWebClient(Consumer consumer) { this.contextRunner.run((context) -> { WebTestClient client = WebTestClient.bindToApplicationContext(context).configureClient() diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java index 37c1f16f29..1b9212d16b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java @@ -43,6 +43,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -57,8 +58,9 @@ class GraphQlWebMvcAutoConfigurationTests { AutoConfigurations.of(DispatcherServletAutoConfiguration.class, WebMvcAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class, JacksonAutoConfiguration.class, GraphQlAutoConfiguration.class, GraphQlWebMvcAutoConfiguration.class)) - .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class).withPropertyValues( - "spring.main.web-application-type=servlet", "spring.graphql.schema.printer.enabled=true"); + .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class) + .withPropertyValues("spring.main.web-application-type=servlet", "spring.graphql.graphiql.enabled=true", + "spring.graphql.schema.printer.enabled=true"); @Test void simpleQueryShouldWork() { @@ -107,6 +109,16 @@ class GraphQlWebMvcAutoConfigurationTests { .andExpect(content().string(Matchers.containsString("type Book")))); } + @Test + void shouldExposeGraphiqlEndpoint() { + testWith((mockMvc) -> { + mockMvc.perform(get("/graphiql")).andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/graphiql?path=/graphql")); + mockMvc.perform(get("/graphiql?path=/graphql")).andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.TEXT_HTML)); + }); + } + private void testWith(MockMvcConsumer mockMvcConsumer) { this.contextRunner.run((context) -> { MediaType mediaType = MediaType.APPLICATION_JSON; From 4ef9b9e3e5492ed2a71ca65fac6a5f5664a30269 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:33:19 +0100 Subject: [PATCH 07/19] Auto-configure CORS options for GraphQL web endpoints This commit adds `"spring.graphql.cors.*"` configuration properties to customize the CORS configuration for GraphQL web endpoints. See gh-29140 --- .../graphql/GraphQlCorsProperties.java | 160 ++++++++++++++++++ .../GraphQlWebFluxAutoConfiguration.java | 28 +++ .../GraphQlWebMvcAutoConfiguration.java | 28 +++ ...itional-spring-configuration-metadata.json | 39 +++++ .../GraphQlWebFluxAutoConfigurationTests.java | 18 +- .../GraphQlWebMvcAutoConfigurationTests.java | 20 ++- 6 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlCorsProperties.java diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlCorsProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlCorsProperties.java new file mode 100644 index 0000000000..01021b3b06 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlCorsProperties.java @@ -0,0 +1,160 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.convert.DurationUnit; +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; +import org.springframework.web.cors.CorsConfiguration; + +/** + * Configuration properties for GraphQL endpoint's CORS support. + * + * @author Andy Wilkinson + * @author Brian Clozel + * @since 2.7.0 + */ +@ConfigurationProperties(prefix = "spring.graphql.cors") +public class GraphQlCorsProperties { + + /** + * Comma-separated list of origins to allow with '*' allowing all origins. When + * allow-credentials is enabled, '*' cannot be used, and setting origin patterns + * should be considered instead. When neither allowed origins nor allowed origin + * patterns are set, cross-origin requests are effectively disabled. + */ + private List allowedOrigins = new ArrayList<>(); + + /** + * Comma-separated list of origin patterns to allow. Unlike allowed origins which only + * support '*', origin patterns are more flexible, e.g. 'https://*.example.com', and + * can be used with allow-credentials. When neither allowed origins nor allowed origin + * patterns are set, cross-origin requests are effectively disabled. + */ + private List allowedOriginPatterns = new ArrayList<>(); + + /** + * Comma-separated list of HTTP methods to allow. '*' allows all methods. When not + * set, defaults to GET. + */ + private List allowedMethods = new ArrayList<>(); + + /** + * Comma-separated list of HTTP headers to allow in a request. '*' allows all headers. + */ + private List allowedHeaders = new ArrayList<>(); + + /** + * Comma-separated list of headers to include in a response. + */ + private List exposedHeaders = new ArrayList<>(); + + /** + * Whether credentials are supported. When not set, credentials are not supported. + */ + @Nullable + private Boolean allowCredentials; + + /** + * How long the response from a pre-flight request can be cached by clients. If a + * duration suffix is not specified, seconds will be used. + */ + @DurationUnit(ChronoUnit.SECONDS) + private Duration maxAge = Duration.ofSeconds(1800); + + public List getAllowedOrigins() { + return this.allowedOrigins; + } + + public void setAllowedOrigins(List allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } + + public List getAllowedOriginPatterns() { + return this.allowedOriginPatterns; + } + + public void setAllowedOriginPatterns(List allowedOriginPatterns) { + this.allowedOriginPatterns = allowedOriginPatterns; + } + + public List getAllowedMethods() { + return this.allowedMethods; + } + + public void setAllowedMethods(List allowedMethods) { + this.allowedMethods = allowedMethods; + } + + public List getAllowedHeaders() { + return this.allowedHeaders; + } + + public void setAllowedHeaders(List allowedHeaders) { + this.allowedHeaders = allowedHeaders; + } + + public List getExposedHeaders() { + return this.exposedHeaders; + } + + public void setExposedHeaders(List exposedHeaders) { + this.exposedHeaders = exposedHeaders; + } + + @Nullable + public Boolean getAllowCredentials() { + return this.allowCredentials; + } + + public void setAllowCredentials(Boolean allowCredentials) { + this.allowCredentials = allowCredentials; + } + + public Duration getMaxAge() { + return this.maxAge; + } + + public void setMaxAge(Duration maxAge) { + this.maxAge = maxAge; + } + + @Nullable + public CorsConfiguration toCorsConfiguration() { + if (CollectionUtils.isEmpty(this.allowedOrigins) && CollectionUtils.isEmpty(this.allowedOriginPatterns)) { + return null; + } + PropertyMapper map = PropertyMapper.get(); + CorsConfiguration config = new CorsConfiguration(); + map.from(this::getAllowedOrigins).to(config::setAllowedOrigins); + map.from(this::getAllowedOriginPatterns).to(config::setAllowedOriginPatterns); + map.from(this::getAllowedHeaders).whenNot(CollectionUtils::isEmpty).to(config::setAllowedHeaders); + map.from(this::getAllowedMethods).whenNot(CollectionUtils::isEmpty).to(config::setAllowedMethods); + map.from(this::getExposedHeaders).whenNot(CollectionUtils::isEmpty).to(config::setExposedHeaders); + map.from(this::getMaxAge).whenNonNull().as(Duration::getSeconds).to(config::setMaxAge); + map.from(this::getAllowCredentials).whenNonNull().to(config::setAllowCredentials); + return config; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java index fd55b2d9c2..3cba5822b1 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java @@ -31,7 +31,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlCorsProperties; import org.springframework.boot.autoconfigure.graphql.GraphQlProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ResourceLoader; @@ -45,6 +47,9 @@ import org.springframework.graphql.web.webflux.SchemaHandler; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.reactive.config.CorsRegistry; +import org.springframework.web.reactive.config.WebFluxConfigurer; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; @@ -64,6 +69,7 @@ import static org.springframework.web.reactive.function.server.RequestPredicates @ConditionalOnClass({ GraphQL.class, GraphQlHttpHandler.class }) @ConditionalOnBean(GraphQlService.class) @AutoConfigureAfter(GraphQlAutoConfiguration.class) +@EnableConfigurationProperties(GraphQlCorsProperties.class) public class GraphQlWebFluxAutoConfiguration { private static final Log logger = LogFactory.getLog(GraphQlWebFluxAutoConfiguration.class); @@ -111,4 +117,26 @@ public class GraphQlWebFluxAutoConfiguration { return builder.build(); } + @Configuration(proxyBeanMethods = false) + public static class GraphQlEndpointCorsConfiguration implements WebFluxConfigurer { + + final GraphQlProperties graphQlProperties; + + final GraphQlCorsProperties corsProperties; + + public GraphQlEndpointCorsConfiguration(GraphQlProperties graphQlProps, GraphQlCorsProperties corsProps) { + this.graphQlProperties = graphQlProps; + this.corsProperties = corsProps; + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + CorsConfiguration configuration = this.corsProperties.toCorsConfiguration(); + if (configuration != null) { + registry.addMapping(this.graphQlProperties.getPath()).combine(configuration); + } + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java index 534b495657..2c7ef2b9fe 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java @@ -31,7 +31,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlCorsProperties; import org.springframework.boot.autoconfigure.graphql.GraphQlProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ResourceLoader; @@ -46,6 +48,9 @@ import org.springframework.graphql.web.webmvc.SchemaHandler; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.function.RequestPredicates; import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.servlet.function.RouterFunctions; @@ -63,6 +68,7 @@ import org.springframework.web.servlet.function.ServerResponse; @ConditionalOnClass({ GraphQL.class, GraphQlHttpHandler.class }) @ConditionalOnBean(GraphQlService.class) @AutoConfigureAfter(GraphQlAutoConfiguration.class) +@EnableConfigurationProperties(GraphQlCorsProperties.class) public class GraphQlWebMvcAutoConfiguration { private static final Log logger = LogFactory.getLog(GraphQlWebMvcAutoConfiguration.class); @@ -112,4 +118,26 @@ public class GraphQlWebMvcAutoConfiguration { return builder.build(); } + @Configuration(proxyBeanMethods = false) + public static class GraphQlEndpointCorsConfiguration implements WebMvcConfigurer { + + final GraphQlProperties graphQlProperties; + + final GraphQlCorsProperties corsProperties; + + public GraphQlEndpointCorsConfiguration(GraphQlProperties graphQlProps, GraphQlCorsProperties corsProps) { + this.graphQlProperties = graphQlProps; + this.corsProperties = corsProps; + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + CorsConfiguration configuration = this.corsProperties.toCorsConfiguration(); + if (configuration != null) { + registry.addMapping(this.graphQlProperties.getPath()).combine(configuration); + } + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 8ab79d02b5..6a194f74c3 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -2210,6 +2210,45 @@ } ] }, + { + "name": "spring.graphql.cors.allowed-headers", + "values": [ + { + "value": "*" + } + ], + "providers": [ + { + "name": "any" + } + ] + }, + { + "name": "spring.graphql.cors.allowed-methods", + "values": [ + { + "value": "*" + } + ], + "providers": [ + { + "name": "any" + } + ] + }, + { + "name": "spring.graphql.cors.allowed-origins", + "values": [ + { + "value": "*" + } + ], + "providers": [ + { + "name": "any" + } + ] + }, { "name": "spring.jmx.server", "providers": [ diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java index e7a05476bf..d1a4f9433e 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java @@ -34,6 +34,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.graphql.execution.RuntimeWiringConfigurer; import org.springframework.graphql.web.WebInterceptor; +import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; @@ -55,7 +56,9 @@ class GraphQlWebFluxAutoConfigurationTests { GraphQlWebFluxAutoConfiguration.class)) .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class) .withPropertyValues("spring.main.web-application-type=reactive", "spring.graphql.graphiql.enabled=true", - "spring.graphql.schema.printer.enabled=true"); + "spring.graphql.schema.printer.enabled=true", + "spring.graphql.cors.allowed-origins=https://example.com", + "spring.graphql.cors.allowed-methods=POST", "spring.graphql.cors.allow-credentials=true"); @Test void simpleQueryShouldWork() { @@ -114,6 +117,19 @@ class GraphQlWebFluxAutoConfigurationTests { }); } + @Test + void shouldSupportCors() { + testWithWebClient((client) -> { + String query = "{" + " bookById(id: \\\"book-1\\\"){ " + " id" + " name" + " pageCount" + + " author" + " }" + "}"; + client.post().uri("/graphql").bodyValue("{ \"query\": \"" + query + "\"}") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST") + .header(HttpHeaders.ORIGIN, "https://example.com").exchange().expectStatus().isOk().expectHeader() + .valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://example.com").expectHeader() + .valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"); + }); + } + private void testWithWebClient(Consumer consumer) { this.contextRunner.run((context) -> { WebTestClient client = WebTestClient.bindToApplicationContext(context).configureClient() diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java index 1b9212d16b..838662c67e 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java @@ -32,6 +32,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.graphql.execution.RuntimeWiringConfigurer; import org.springframework.graphql.web.WebInterceptor; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -60,7 +61,9 @@ class GraphQlWebMvcAutoConfigurationTests { GraphQlAutoConfiguration.class, GraphQlWebMvcAutoConfiguration.class)) .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class) .withPropertyValues("spring.main.web-application-type=servlet", "spring.graphql.graphiql.enabled=true", - "spring.graphql.schema.printer.enabled=true"); + "spring.graphql.schema.printer.enabled=true", + "spring.graphql.cors.allowed-origins=https://example.com", + "spring.graphql.cors.allowed-methods=POST", "spring.graphql.cors.allow-credentials=true"); @Test void simpleQueryShouldWork() { @@ -119,6 +122,21 @@ class GraphQlWebMvcAutoConfigurationTests { }); } + @Test + void shouldSupportCors() { + testWith((mockMvc) -> { + String query = "{" + " bookById(id: \\\"book-1\\\"){ " + " id" + " name" + " pageCount" + + " author" + " }" + "}"; + MvcResult result = mockMvc.perform(post("/graphql") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST") + .header(HttpHeaders.ORIGIN, "https://example.com").content("{\"query\": \"" + query + "\"}")) + .andReturn(); + mockMvc.perform(asyncDispatch(result)).andExpect(status().isOk()) + .andExpect(header().stringValues(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://example.com")) + .andExpect(header().stringValues(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true")); + }); + } + private void testWith(MockMvcConsumer mockMvcConsumer) { this.contextRunner.run((context) -> { MediaType mediaType = MediaType.APPLICATION_JSON; From 6ef1ed0dd0c25fcf3e90778c175d9dc99e85f5de Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:33:25 +0100 Subject: [PATCH 08/19] Auto-configure GraphQL WebSocket endpoint This commit auto-configures a GraphQL WebSocket endpoint for both Spring MVC and Spring WebFlux. This is only enabled if the required libraries are on the classpath and if the `"spring.graphql.websocket.path"` property is defined. See gh-29140 --- .../graphql/GraphQlProperties.java | 38 ++++++++++++++ .../GraphQlWebFluxAutoConfiguration.java | 34 +++++++++++++ .../GraphQlWebMvcAutoConfiguration.java | 50 +++++++++++++++++++ .../GraphQlWebFluxAutoConfigurationTests.java | 16 ++++++ .../GraphQlWebMvcAutoConfigurationTests.java | 16 ++++++ 5 files changed, 154 insertions(+) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java index c9d7e8bdfa..3df070e745 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java @@ -16,6 +16,7 @@ package org.springframework.boot.autoconfigure.graphql; +import java.time.Duration; import java.util.Arrays; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -38,6 +39,8 @@ public class GraphQlProperties { private final Schema schema = new Schema(); + private final Websocket websocket = new Websocket(); + public Graphiql getGraphiql() { return this.graphiql; } @@ -54,6 +57,10 @@ public class GraphQlProperties { return this.schema; } + public Websocket getWebsocket() { + return this.websocket; + } + public static class Schema { /** @@ -143,4 +150,35 @@ public class GraphQlProperties { } + public static class Websocket { + + /** + * Path of the GraphQL WebSocket subscription endpoint. + */ + private String path; + + /** + * Time within which the initial {@code CONNECTION_INIT} type message must be + * received. + */ + private Duration connectionInitTimeout = Duration.ofSeconds(60); + + public String getPath() { + return this.path; + } + + public void setPath(String path) { + this.path = path; + } + + public Duration getConnectionInitTimeout() { + return this.connectionInitTimeout; + } + + public void setConnectionInitTimeout(Duration connectionInitTimeout) { + this.connectionInitTimeout = connectionInitTimeout; + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java index 3cba5822b1..b7f8136d7f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java @@ -29,6 +29,7 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; import org.springframework.boot.autoconfigure.graphql.GraphQlCorsProperties; @@ -42,17 +43,22 @@ import org.springframework.graphql.execution.GraphQlSource; import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.graphql.web.WebInterceptor; import org.springframework.graphql.web.webflux.GraphQlHttpHandler; +import org.springframework.graphql.web.webflux.GraphQlWebSocketHandler; import org.springframework.graphql.web.webflux.GraphiQlHandler; import org.springframework.graphql.web.webflux.SchemaHandler; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.reactive.HandlerMapping; import org.springframework.web.reactive.config.CorsRegistry; import org.springframework.web.reactive.config.WebFluxConfigurer; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping; +import org.springframework.web.reactive.socket.server.support.WebSocketUpgradeHandlerPredicate; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; @@ -139,4 +145,32 @@ public class GraphQlWebFluxAutoConfiguration { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(prefix = "spring.graphql.websocket", name = "path") + public static class WebSocketConfiguration { + + @Bean + @ConditionalOnMissingBean + public GraphQlWebSocketHandler graphQlWebSocketHandler(WebGraphQlHandler webGraphQlHandler, + GraphQlProperties properties, ServerCodecConfigurer configurer) { + return new GraphQlWebSocketHandler(webGraphQlHandler, configurer, + properties.getWebsocket().getConnectionInitTimeout()); + } + + @Bean + public HandlerMapping graphQlWebSocketEndpoint(GraphQlWebSocketHandler graphQlWebSocketHandler, + GraphQlProperties properties) { + String path = properties.getWebsocket().getPath(); + if (logger.isInfoEnabled()) { + logger.info("GraphQL endpoint WebSocket " + path); + } + SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping(); + mapping.setHandlerPredicate(new WebSocketUpgradeHandlerPredicate()); + mapping.setUrlMap(Collections.singletonMap(path, graphQlWebSocketHandler)); + mapping.setOrder(-2); // Ahead of HTTP endpoint ("routerFunctionMapping" bean) + return mapping; + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java index 2c7ef2b9fe..fd5a2210fe 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java @@ -17,8 +17,11 @@ package org.springframework.boot.autoconfigure.graphql.servlet; import java.util.Collections; +import java.util.Map; import java.util.stream.Collectors; +import javax.websocket.server.ServerContainer; + import graphql.GraphQL; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -29,10 +32,12 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; import org.springframework.boot.autoconfigure.graphql.GraphQlCorsProperties; import org.springframework.boot.autoconfigure.graphql.GraphQlProperties; +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -43,18 +48,25 @@ import org.springframework.graphql.execution.ThreadLocalAccessor; import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.graphql.web.WebInterceptor; import org.springframework.graphql.web.webmvc.GraphQlHttpHandler; +import org.springframework.graphql.web.webmvc.GraphQlWebSocketHandler; import org.springframework.graphql.web.webmvc.GraphiQlHandler; import org.springframework.graphql.web.webmvc.SchemaHandler; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.converter.GenericHttpMessageConverter; import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.function.RequestPredicates; import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.servlet.function.RouterFunctions; import org.springframework.web.servlet.function.ServerResponse; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; +import org.springframework.web.socket.server.support.WebSocketHandlerMapping; +import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler; /** * {@link EnableAutoConfiguration Auto-configuration} for enabling Spring GraphQL over @@ -140,4 +152,42 @@ public class GraphQlWebMvcAutoConfiguration { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ ServerContainer.class, WebSocketHandler.class }) + @ConditionalOnProperty(prefix = "spring.graphql.websocket", name = "path") + public static class WebSocketConfiguration { + + @Bean + @ConditionalOnMissingBean + public GraphQlWebSocketHandler graphQlWebSocketHandler(WebGraphQlHandler webGraphQlHandler, + GraphQlProperties properties, HttpMessageConverters converters) { + + return new GraphQlWebSocketHandler(webGraphQlHandler, getJsonConverter(converters), + properties.getWebsocket().getConnectionInitTimeout()); + } + + @SuppressWarnings("unchecked") + private static GenericHttpMessageConverter getJsonConverter(HttpMessageConverters converters) { + return converters.getConverters().stream() + .filter((candidate) -> candidate.canRead(Map.class, MediaType.APPLICATION_JSON)).findFirst() + .map((converter) -> (GenericHttpMessageConverter) converter) + .orElseThrow(() -> new IllegalStateException("No JSON converter")); + } + + @Bean + public HandlerMapping graphQlWebSocketMapping(GraphQlWebSocketHandler handler, GraphQlProperties properties) { + String path = properties.getWebsocket().getPath(); + if (logger.isInfoEnabled()) { + logger.info("GraphQL endpoint WebSocket " + path); + } + WebSocketHandlerMapping mapping = new WebSocketHandlerMapping(); + mapping.setWebSocketUpgradeMatch(true); + mapping.setUrlMap(Collections.singletonMap(path, + new WebSocketHttpRequestHandler(handler, new DefaultHandshakeHandler()))); + mapping.setOrder(2); // Ahead of HTTP endpoint ("routerFunctionMapping" bean) + return mapping; + } + + } + } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java index d1a4f9433e..64aadfe805 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java @@ -33,12 +33,16 @@ import org.springframework.boot.test.context.runner.ReactiveWebApplicationContex import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.graphql.web.WebInterceptor; +import org.springframework.graphql.web.webflux.GraphQlHttpHandler; +import org.springframework.graphql.web.webflux.GraphQlWebSocketHandler; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; /** @@ -60,6 +64,12 @@ class GraphQlWebFluxAutoConfigurationTests { "spring.graphql.cors.allowed-origins=https://example.com", "spring.graphql.cors.allowed-methods=POST", "spring.graphql.cors.allow-credentials=true"); + @Test + void shouldContributeDefaultBeans() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(GraphQlHttpHandler.class) + .hasSingleBean(WebGraphQlHandler.class).doesNotHaveBean(GraphQlWebSocketHandler.class)); + } + @Test void simpleQueryShouldWork() { testWithWebClient((client) -> { @@ -130,6 +140,12 @@ class GraphQlWebFluxAutoConfigurationTests { }); } + @Test + void shouldConfigureWebSocketBeans() { + this.contextRunner.withPropertyValues("spring.graphql.websocket.path=/ws") + .run((context) -> assertThat(context).hasSingleBean(GraphQlWebSocketHandler.class)); + } + private void testWithWebClient(Consumer consumer) { this.contextRunner.run((context) -> { WebTestClient client = WebTestClient.bindToApplicationContext(context).configureClient() diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java index 838662c67e..3e1f57a16a 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java @@ -31,13 +31,17 @@ import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.graphql.web.WebInterceptor; +import org.springframework.graphql.web.webmvc.GraphQlHttpHandler; +import org.springframework.graphql.web.webmvc.GraphQlWebSocketHandler; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -65,6 +69,12 @@ class GraphQlWebMvcAutoConfigurationTests { "spring.graphql.cors.allowed-origins=https://example.com", "spring.graphql.cors.allowed-methods=POST", "spring.graphql.cors.allow-credentials=true"); + @Test + void shouldContributeDefaultBeans() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(GraphQlHttpHandler.class) + .hasSingleBean(WebGraphQlHandler.class).doesNotHaveBean(GraphQlWebSocketHandler.class)); + } + @Test void simpleQueryShouldWork() { testWith((mockMvc) -> { @@ -137,6 +147,12 @@ class GraphQlWebMvcAutoConfigurationTests { }); } + @Test + void shouldConfigureWebSocketBeans() { + this.contextRunner.withPropertyValues("spring.graphql.websocket.path=/ws") + .run((context) -> assertThat(context).hasSingleBean(GraphQlWebSocketHandler.class)); + } + private void testWith(MockMvcConsumer mockMvcConsumer) { this.contextRunner.run((context) -> { MediaType mediaType = MediaType.APPLICATION_JSON; From ce6747ccd6aca878be3be671cba693a874e5b174 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:33:31 +0100 Subject: [PATCH 09/19] Enable GraphiQL UI by default with Devtools GraphiQL is useful when working on a GraphQL API - it allows developers to craft queries and test newly developed feature. This is not enabled by default, as this should not be exposed in production without the developer's knowledge. This commit flips this value when Spring Boot devtools is active for a better developer experience. See gh-29140 --- .../boot/devtools/env/DevToolsPropertyDefaultsPostProcessor.java | 1 + 1 file changed, 1 insertion(+) diff --git a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/env/DevToolsPropertyDefaultsPostProcessor.java b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/env/DevToolsPropertyDefaultsPostProcessor.java index bd99a3e21c..8a4852b702 100755 --- a/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/env/DevToolsPropertyDefaultsPostProcessor.java +++ b/spring-boot-project/spring-boot-devtools/src/main/java/org/springframework/boot/devtools/env/DevToolsPropertyDefaultsPostProcessor.java @@ -63,6 +63,7 @@ public class DevToolsPropertyDefaultsPostProcessor implements EnvironmentPostPro Map properties = new HashMap<>(); properties.put("spring.thymeleaf.cache", "false"); properties.put("spring.freemarker.cache", "false"); + properties.put("spring.graphql.graphiql.enabled", "true"); properties.put("spring.groovy.template.cache", "false"); properties.put("spring.mustache.cache", "false"); properties.put("server.servlet.session.persistent", "true"); From 9954b4c63e0ba24493a8afe04248e136e6e4d77f Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:33:43 +0100 Subject: [PATCH 10/19] Auto-configure Query* support for GraphQL This commit auto-configures Spring Data Querydsl and QueryByExample support for Spring GraphQL. See gh-29140 --- .../spring-boot-autoconfigure/build.gradle | 2 + ...raphQlQueryByExampleAutoConfiguration.java | 71 ++++++++++++++++ .../GraphQlQuerydslAutoConfiguration.java | 71 ++++++++++++++++ ...activeQueryByExampleAutoConfiguration.java | 67 +++++++++++++++ ...phQlReactiveQuerydslAutoConfiguration.java | 68 ++++++++++++++++ .../graphql/data/package-info.java | 20 +++++ .../main/resources/META-INF/spring.factories | 4 + .../boot/autoconfigure/graphql/Book.java | 3 + .../boot/autoconfigure/graphql/QBook.java | 56 +++++++++++++ ...lQueryByExampleAutoConfigurationTests.java | 81 +++++++++++++++++++ ...GraphQlQuerydslAutoConfigurationTests.java | 81 +++++++++++++++++++ ...eQueryByExampleAutoConfigurationTests.java | 80 ++++++++++++++++++ ...eactiveQuerydslAutoConfigurationTests.java | 80 ++++++++++++++++++ 13 files changed, 684 insertions(+) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/package-info.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/QBook.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfigurationTests.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfigurationTests.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfigurationTests.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfigurationTests.java diff --git a/spring-boot-project/spring-boot-autoconfigure/build.gradle b/spring-boot-project/spring-boot-autoconfigure/build.gradle index 114b137b11..456481a5ac 100644 --- a/spring-boot-project/spring-boot-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-autoconfigure/build.gradle @@ -225,6 +225,7 @@ dependencies { testImplementation("com.github.h-thurow:simple-jndi") testImplementation("com.ibm.db2:jcc") testImplementation("com.jayway.jsonpath:json-path") + testImplementation("com.querydsl:querydsl-core") testImplementation("com.squareup.okhttp3:mockwebserver") testImplementation("com.sun.xml.messaging.saaj:saaj-impl") testImplementation("io.projectreactor:reactor-test") @@ -243,6 +244,7 @@ dependencies { testImplementation("org.mockito:mockito-core") testImplementation("org.mockito:mockito-junit-jupiter") testImplementation("org.springframework:spring-test") + testImplementation("org.springframework.graphql:spring-graphql-test") testImplementation("org.springframework.kafka:spring-kafka-test") testImplementation("org.springframework.security:spring-security-test") testImplementation("org.testcontainers:cassandra") diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfiguration.java new file mode 100644 index 0000000000..504d4bf024 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfiguration.java @@ -0,0 +1,71 @@ +/* + * Copyright 2002-2021 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.boot.autoconfigure.graphql.data; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import graphql.GraphQL; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.repository.query.QueryByExampleExecutor; +import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor; +import org.springframework.graphql.data.query.QueryByExampleDataFetcher; +import org.springframework.graphql.execution.GraphQlSource; + +/** + * {@link EnableAutoConfiguration Auto-configuration} that creates a + * {@link GraphQlSourceBuilderCustomizer}s to detect Spring Data repositories with Query + * By Example support and register them as {@code DataFetcher}s for any queries with a + * matching return type. + * + * @author Rossen Stoyanchev + * @since 2.7.0 + * @see QueryByExampleDataFetcher#autoRegistrationTypeVisitor(List, List) + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ GraphQL.class, QueryByExampleDataFetcher.class, QueryByExampleExecutor.class }) +@ConditionalOnBean(GraphQlSource.class) +@AutoConfigureAfter(GraphQlAutoConfiguration.class) +public class GraphQlQueryByExampleAutoConfiguration { + + @Bean + public GraphQlSourceBuilderCustomizer queryByExampleRegistrar( + ObjectProvider> executorsProvider, + ObjectProvider> reactiveExecutorsProvider) { + + return (builder) -> { + List> executors = executorsProvider.stream().collect(Collectors.toList()); + List> reactiveExecutors = reactiveExecutorsProvider.stream() + .collect(Collectors.toList()); + if (!executors.isEmpty()) { + builder.typeVisitors(Collections.singletonList( + QueryByExampleDataFetcher.autoRegistrationTypeVisitor(executors, reactiveExecutors))); + } + }; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfiguration.java new file mode 100644 index 0000000000..d10c0dcab0 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfiguration.java @@ -0,0 +1,71 @@ +/* + * Copyright 2002-2021 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.boot.autoconfigure.graphql.data; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import graphql.GraphQL; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.data.querydsl.ReactiveQuerydslPredicateExecutor; +import org.springframework.graphql.data.query.QuerydslDataFetcher; +import org.springframework.graphql.execution.GraphQlSource; + +/** + * {@link EnableAutoConfiguration Auto-configuration} that creates a + * {@link GraphQlSourceBuilderCustomizer}s to detect Spring Data repositories with + * Querydsl support and register them as {@code DataFetcher}s for any queries with a + * matching return type. + * + * @author Rossen Stoyanchev + * @since 2.7.0 + * @see QuerydslDataFetcher#autoRegistrationTypeVisitor(List, List) + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ GraphQL.class, QuerydslDataFetcher.class, QuerydslPredicateExecutor.class }) +@ConditionalOnBean(GraphQlSource.class) +@AutoConfigureAfter(GraphQlAutoConfiguration.class) +public class GraphQlQuerydslAutoConfiguration { + + @Bean + public GraphQlSourceBuilderCustomizer querydslRegistrar( + ObjectProvider> executorsProvider, + ObjectProvider> reactiveExecutorsProvider) { + + return (builder) -> { + List> executors = executorsProvider.stream().collect(Collectors.toList()); + List> reactiveExecutors = reactiveExecutorsProvider.stream() + .collect(Collectors.toList()); + if (!executors.isEmpty()) { + builder.typeVisitors(Collections + .singletonList(QuerydslDataFetcher.autoRegistrationTypeVisitor(executors, reactiveExecutors))); + } + }; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfiguration.java new file mode 100644 index 0000000000..e36ec5ba03 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfiguration.java @@ -0,0 +1,67 @@ +/* + * Copyright 2002-2021 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.boot.autoconfigure.graphql.data; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import graphql.GraphQL; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor; +import org.springframework.graphql.data.query.QueryByExampleDataFetcher; +import org.springframework.graphql.execution.GraphQlSource; + +/** + * {@link EnableAutoConfiguration Auto-configuration} that creates a + * {@link GraphQlSourceBuilderCustomizer}s to detect Spring Data repositories with Query + * By Example support and register them as {@code DataFetcher}s for any queries with a + * matching return type. + * + * @author Rossen Stoyanchev + * @since 2.7.0 + * @see QueryByExampleDataFetcher#autoRegistrationTypeVisitor(List, List) + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ GraphQL.class, QueryByExampleDataFetcher.class, ReactiveQueryByExampleExecutor.class }) +@ConditionalOnBean(GraphQlSource.class) +@AutoConfigureAfter(GraphQlAutoConfiguration.class) +public class GraphQlReactiveQueryByExampleAutoConfiguration { + + @Bean + public GraphQlSourceBuilderCustomizer reactiveQueryByExampleRegistrar( + ObjectProvider> executorsProvider) { + + return (builder) -> { + List> executors = executorsProvider.stream().collect(Collectors.toList()); + if (!executors.isEmpty()) { + builder.typeVisitors(Collections.singletonList( + QueryByExampleDataFetcher.autoRegistrationTypeVisitor(Collections.emptyList(), executors))); + } + }; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfiguration.java new file mode 100644 index 0000000000..91b5689855 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfiguration.java @@ -0,0 +1,68 @@ +/* + * Copyright 2002-2021 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.boot.autoconfigure.graphql.data; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import graphql.GraphQL; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.querydsl.ReactiveQuerydslPredicateExecutor; +import org.springframework.graphql.data.query.QuerydslDataFetcher; +import org.springframework.graphql.execution.GraphQlSource; + +/** + * {@link EnableAutoConfiguration Auto-configuration} that creates a + * {@link GraphQlSourceBuilderCustomizer}s to detect Spring Data repositories with + * Querydsl support and register them as {@code DataFetcher}s for any queries with a + * matching return type. + * + * @author Rossen Stoyanchev + * @since 2.7.0 + * @see QuerydslDataFetcher#autoRegistrationTypeVisitor(List, List) + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ GraphQL.class, QuerydslDataFetcher.class, ReactiveQuerydslPredicateExecutor.class }) +@ConditionalOnBean(GraphQlSource.class) +@AutoConfigureAfter(GraphQlAutoConfiguration.class) +public class GraphQlReactiveQuerydslAutoConfiguration { + + @Bean + public GraphQlSourceBuilderCustomizer reactiveQuerydslRegistrar( + ObjectProvider> executorsProvider) { + + return (builder) -> { + List> executors = executorsProvider.stream() + .collect(Collectors.toList()); + if (!executors.isEmpty()) { + builder.typeVisitors(Collections.singletonList( + QuerydslDataFetcher.autoRegistrationTypeVisitor(Collections.emptyList(), executors))); + } + }; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/package-info.java new file mode 100644 index 0000000000..0f1a57d4f4 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020-2021 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. + */ + +/** + * Auto-configuration classes for data integrations with GraphQL. + */ +package org.springframework.boot.autoconfigure.graphql.data; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories index bad6ab475c..e4b771a4cb 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories @@ -69,6 +69,10 @@ org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAuto org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration,\ org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration,\ org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration,\ +org.springframework.boot.autoconfigure.graphql.data.GraphQlReactiveQueryByExampleAutoConfiguration,\ +org.springframework.boot.autoconfigure.graphql.data.GraphQlReactiveQuerydslAutoConfiguration,\ +org.springframework.boot.autoconfigure.graphql.data.GraphQlQueryByExampleAutoConfiguration,\ +org.springframework.boot.autoconfigure.graphql.data.GraphQlQuerydslAutoConfiguration,\ org.springframework.boot.autoconfigure.graphql.reactive.GraphQlWebFluxAutoConfiguration,\ org.springframework.boot.autoconfigure.graphql.servlet.GraphQlWebMvcAutoConfiguration,\ org.springframework.boot.autoconfigure.groovy.template.GroovyTemplateAutoConfiguration,\ diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/Book.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/Book.java index ecd0468216..0dbea1be83 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/Book.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/Book.java @@ -16,6 +16,8 @@ package org.springframework.boot.autoconfigure.graphql; +import org.springframework.data.annotation.Id; + /** * Sample class for * @@ -23,6 +25,7 @@ package org.springframework.boot.autoconfigure.graphql; */ public class Book { + @Id String id; String name; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/QBook.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/QBook.java new file mode 100644 index 0000000000..c7e95ac4e7 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/QBook.java @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql; + +import com.querydsl.core.types.Path; +import com.querydsl.core.types.PathMetadata; +import com.querydsl.core.types.PathMetadataFactory; +import com.querydsl.core.types.dsl.EntityPathBase; +import com.querydsl.core.types.dsl.NumberPath; +import com.querydsl.core.types.dsl.StringPath; + +/** + * QBook is a Querydsl query type for Book. This class is usually generated by the + * Querydsl annotation processor. + */ +public class QBook extends EntityPathBase { + + private static final long serialVersionUID = -1932588188L; + + public static final QBook book = new QBook("book"); + + public final StringPath author = createString("author"); + + public final StringPath id = createString("id"); + + public final StringPath name = createString("name"); + + public final NumberPath pageCount = createNumber("pageCount", Integer.class); + + public QBook(String variable) { + super(Book.class, PathMetadataFactory.forVariable(variable)); + } + + public QBook(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QBook(PathMetadata metadata) { + super(Book.class, metadata); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfigurationTests.java new file mode 100644 index 0000000000..be28b3e908 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfigurationTests.java @@ -0,0 +1,81 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql.data; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.graphql.Book; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.QueryByExampleExecutor; +import org.springframework.graphql.GraphQlService; +import org.springframework.graphql.data.GraphQlRepository; +import org.springframework.graphql.test.tester.GraphQlTester; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GraphQlQueryByExampleAutoConfiguration} + * + * @author Brian Clozel + */ +class GraphQlQueryByExampleAutoConfigurationTests { + + private static final Book book = new Book("42", "Test title", 42, "Test Author"); + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(GraphQlAutoConfiguration.class, GraphQlQueryByExampleAutoConfiguration.class)) + .withUserConfiguration(MockRepositoryConfig.class) + .withPropertyValues("spring.main.web-application-type=reactive"); + + @Test + void shouldRegisterDataFetcherForQueryByExampleRepositories() { + this.contextRunner.run((context) -> { + GraphQlService graphQlService = context.getBean(GraphQlService.class); + GraphQlTester graphQlTester = GraphQlTester.create(graphQlService); + graphQlTester.query("{ bookById(id: 1) {name}}").execute().path("bookById.name").entity(String.class) + .isEqualTo("Test title"); + }); + } + + @Configuration(proxyBeanMethods = false) + static class MockRepositoryConfig { + + @Bean + MockRepository mockRepository() { + MockRepository mockRepository = mock(MockRepository.class); + given(mockRepository.findBy(any(), any())).willReturn(Optional.of(book)); + return mockRepository; + } + + } + + @GraphQlRepository + interface MockRepository extends CrudRepository, QueryByExampleExecutor { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfigurationTests.java new file mode 100644 index 0000000000..c687d0834e --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfigurationTests.java @@ -0,0 +1,81 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql.data; + +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.graphql.Book; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.data.repository.CrudRepository; +import org.springframework.graphql.GraphQlService; +import org.springframework.graphql.data.GraphQlRepository; +import org.springframework.graphql.test.tester.GraphQlTester; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GraphQlQuerydslAutoConfiguration}. + * + * @author Brian Clozel + */ +class GraphQlQuerydslAutoConfigurationTests { + + private static final Book book = new Book("42", "Test title", 42, "Test Author"); + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(GraphQlAutoConfiguration.class, GraphQlQuerydslAutoConfiguration.class)) + .withUserConfiguration(MockRepositoryConfig.class) + .withPropertyValues("spring.main.web-application-type=reactive"); + + @Test + void shouldRegisterDataFetcherForQueryDslRepositories() { + this.contextRunner.run((context) -> { + GraphQlService graphQlService = context.getBean(GraphQlService.class); + GraphQlTester graphQlTester = GraphQlTester.create(graphQlService); + graphQlTester.query("{ bookById(id: 1) {name}}").execute().path("bookById.name").entity(String.class) + .isEqualTo("Test title"); + }); + } + + @Configuration(proxyBeanMethods = false) + static class MockRepositoryConfig { + + @Bean + MockRepository mockRepository() { + MockRepository mockRepository = mock(MockRepository.class); + given(mockRepository.findBy(any(), any())).willReturn(Optional.of(book)); + return mockRepository; + } + + } + + @GraphQlRepository + interface MockRepository extends CrudRepository, QuerydslPredicateExecutor { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfigurationTests.java new file mode 100644 index 0000000000..20eb88e797 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfigurationTests.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql.data; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.graphql.Book; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.graphql.GraphQlService; +import org.springframework.graphql.data.GraphQlRepository; +import org.springframework.graphql.test.tester.GraphQlTester; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GraphQlReactiveQueryByExampleAutoConfiguration} + * + * @author Brian Clozel + */ +class GraphQlReactiveQueryByExampleAutoConfigurationTests { + + private static final Mono bookPublisher = Mono.just(new Book("42", "Test title", 42, "Test Author")); + + private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GraphQlAutoConfiguration.class, + GraphQlReactiveQueryByExampleAutoConfiguration.class)) + .withUserConfiguration(MockRepositoryConfig.class) + .withPropertyValues("spring.main.web-application-type=reactive"); + + @Test + void shouldRegisterDataFetcherForQueryByExampleRepositories() { + this.contextRunner.run((context) -> { + GraphQlService graphQlService = context.getBean(GraphQlService.class); + GraphQlTester graphQlTester = GraphQlTester.create(graphQlService); + graphQlTester.query("{ bookById(id: 1) {name}}").execute().path("bookById.name").entity(String.class) + .isEqualTo("Test title"); + }); + } + + @Configuration(proxyBeanMethods = false) + static class MockRepositoryConfig { + + @Bean + MockRepository mockRepository() { + MockRepository mockRepository = mock(MockRepository.class); + given(mockRepository.findBy(any(), any())).willReturn(bookPublisher); + return mockRepository; + } + + } + + @GraphQlRepository + interface MockRepository extends ReactiveCrudRepository, ReactiveQueryByExampleExecutor { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfigurationTests.java new file mode 100644 index 0000000000..4d5d461462 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfigurationTests.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-2021 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.boot.autoconfigure.graphql.data; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.graphql.Book; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.querydsl.ReactiveQuerydslPredicateExecutor; +import org.springframework.data.repository.reactive.ReactiveCrudRepository; +import org.springframework.graphql.GraphQlService; +import org.springframework.graphql.data.GraphQlRepository; +import org.springframework.graphql.test.tester.GraphQlTester; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GraphQlReactiveQuerydslAutoConfiguration} + * + * @author Brian Clozel + */ +class GraphQlReactiveQuerydslAutoConfigurationTests { + + private static final Mono bookPublisher = Mono.just(new Book("42", "Test title", 42, "Test Author")); + + private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GraphQlAutoConfiguration.class, + GraphQlReactiveQuerydslAutoConfiguration.class)) + .withUserConfiguration(MockRepositoryConfig.class) + .withPropertyValues("spring.main.web-application-type=reactive"); + + @Test + void shouldRegisterDataFetcherForQueryDslRepositories() { + this.contextRunner.run((context) -> { + GraphQlService graphQlService = context.getBean(GraphQlService.class); + GraphQlTester graphQlTester = GraphQlTester.create(graphQlService); + graphQlTester.query("{ bookById(id: 1) {name}}").execute().path("bookById.name").entity(String.class) + .isEqualTo("Test title"); + }); + } + + @Configuration(proxyBeanMethods = false) + static class MockRepositoryConfig { + + @Bean + MockRepository mockRepository() { + MockRepository mockRepository = mock(MockRepository.class); + given(mockRepository.findBy(any(), any())).willReturn(bookPublisher); + return mockRepository; + } + + } + + @GraphQlRepository + interface MockRepository extends ReactiveCrudRepository, ReactiveQuerydslPredicateExecutor { + + } + +} From 6dbcd0e95e6e88e02326a5e4a1a9df3a3bda0bb1 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:33:50 +0100 Subject: [PATCH 11/19] Auto-configure security for Spring GraphQL This commit configures security features for Spring GraphQL. In the case of both MVC and WebFlux, this contributes `DataFetcherExceptionResolver` instances to resolve security exceptions and expose them as proper errors in the GraphQL response. For MVC only, this also configures a `SecurityContextThreadLocalAccessor`. This component ensures that the security context is propagated between `ThreadLocal` and the Reactor asynchronous execution. See gh-29140 --- ...aphQlWebFluxSecurityAutoConfiguration.java | 54 ++++++ ...raphQlWebMvcSecurityAutoConfiguration.java | 61 ++++++ .../graphql/security/package-info.java | 20 ++ .../main/resources/META-INF/spring.factories | 2 + ...WebFluxSecurityAutoConfigurationTests.java | 163 ++++++++++++++++ ...lWebMvcSecurityAutoConfigurationTests.java | 179 ++++++++++++++++++ 6 files changed, 479 insertions(+) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebFluxSecurityAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebMvcSecurityAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/package-info.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebFluxSecurityAutoConfigurationTests.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebMvcSecurityAutoConfigurationTests.java diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebFluxSecurityAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebFluxSecurityAutoConfiguration.java new file mode 100644 index 0000000000..6773857e70 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebFluxSecurityAutoConfiguration.java @@ -0,0 +1,54 @@ +/* + * Copyright 2020-2021 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.boot.autoconfigure.graphql.security; + +import graphql.GraphQL; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.graphql.reactive.GraphQlWebFluxAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.security.ReactiveSecurityDataFetcherExceptionResolver; +import org.springframework.graphql.web.webflux.GraphQlHttpHandler; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for enabling Security support for + * Spring GraphQL with WebFlux. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +@ConditionalOnClass({ GraphQL.class, GraphQlHttpHandler.class, EnableWebFluxSecurity.class }) +@ConditionalOnBean(GraphQlHttpHandler.class) +@AutoConfigureAfter(GraphQlWebFluxAutoConfiguration.class) +public class GraphQlWebFluxSecurityAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public ReactiveSecurityDataFetcherExceptionResolver reactiveSecurityDataFetcherExceptionResolver() { + return new ReactiveSecurityDataFetcherExceptionResolver(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebMvcSecurityAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebMvcSecurityAutoConfiguration.java new file mode 100644 index 0000000000..bff7bfc7d8 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebMvcSecurityAutoConfiguration.java @@ -0,0 +1,61 @@ +/* + * Copyright 2020-2021 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.boot.autoconfigure.graphql.security; + +import graphql.GraphQL; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.graphql.servlet.GraphQlWebMvcAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.security.SecurityContextThreadLocalAccessor; +import org.springframework.graphql.security.SecurityDataFetcherExceptionResolver; +import org.springframework.graphql.web.webmvc.GraphQlHttpHandler; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for enabling Security support for + * Spring GraphQL with MVC. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnClass({ GraphQL.class, GraphQlHttpHandler.class, EnableWebSecurity.class }) +@ConditionalOnBean(GraphQlHttpHandler.class) +@AutoConfigureAfter(GraphQlWebMvcAutoConfiguration.class) +public class GraphQlWebMvcSecurityAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public SecurityDataFetcherExceptionResolver securityDataFetcherExceptionResolver() { + return new SecurityDataFetcherExceptionResolver(); + } + + @Bean + @ConditionalOnMissingBean + public SecurityContextThreadLocalAccessor securityContextThreadLocalAccessor() { + return new SecurityContextThreadLocalAccessor(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/package-info.java new file mode 100644 index 0000000000..6d1c5e30bf --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020-2021 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. + */ + +/** + * Auto-configuration classes for Security support in Spring GraphQL. + */ +package org.springframework.boot.autoconfigure.graphql.security; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories index e4b771a4cb..aef3e3399f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories @@ -75,6 +75,8 @@ org.springframework.boot.autoconfigure.graphql.data.GraphQlQueryByExampleAutoCon org.springframework.boot.autoconfigure.graphql.data.GraphQlQuerydslAutoConfiguration,\ org.springframework.boot.autoconfigure.graphql.reactive.GraphQlWebFluxAutoConfiguration,\ org.springframework.boot.autoconfigure.graphql.servlet.GraphQlWebMvcAutoConfiguration,\ +org.springframework.boot.autoconfigure.graphql.security.GraphQlWebFluxSecurityAutoConfiguration,\ +org.springframework.boot.autoconfigure.graphql.security.GraphQlWebMvcSecurityAutoConfiguration,\ org.springframework.boot.autoconfigure.groovy.template.GroovyTemplateAutoConfiguration,\ org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration,\ org.springframework.boot.autoconfigure.h2.H2ConsoleAutoConfiguration,\ diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebFluxSecurityAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebFluxSecurityAutoConfigurationTests.java new file mode 100644 index 0000000000..9b6f1db534 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebFluxSecurityAutoConfigurationTests.java @@ -0,0 +1,163 @@ +/* + * Copyright 2020-2021 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.boot.autoconfigure.graphql.security; + +import java.util.Collections; +import java.util.function.Consumer; + +import graphql.schema.idl.TypeRuntimeWiring; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.graphql.Book; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlTestDataFetchers; +import org.springframework.boot.autoconfigure.graphql.reactive.GraphQlWebFluxAutoConfiguration; +import org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.execution.ErrorType; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.security.ReactiveSecurityDataFetcherExceptionResolver; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.config.annotation.method.configuration.EnableReactiveMethodSecurity; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.config.Customizer.withDefaults; + +/** + * Tests for {@link GraphQlWebFluxSecurityAutoConfiguration}. + * + * @author Brian Clozel + */ +class GraphQlWebFluxSecurityAutoConfigurationTests { + + private static final String BASE_URL = "https://spring.example.org/graphql"; + + private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(HttpHandlerAutoConfiguration.class, WebFluxAutoConfiguration.class, + CodecsAutoConfiguration.class, JacksonAutoConfiguration.class, GraphQlAutoConfiguration.class, + GraphQlWebFluxAutoConfiguration.class, GraphQlWebFluxSecurityAutoConfiguration.class, + ReactiveSecurityAutoConfiguration.class)) + .withUserConfiguration(DataFetchersConfiguration.class, SecurityConfig.class) + .withPropertyValues("spring.main.web-application-type=reactive"); + + @Test + void contributesExceptionResolver() { + this.contextRunner.run( + (context) -> assertThat(context).hasSingleBean(ReactiveSecurityDataFetcherExceptionResolver.class)); + } + + @Test + void anonymousUserShouldBeUnauthorized() { + testWithWebClient((client) -> { + String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author }}"; + client.post().uri("").bodyValue("{ \"query\": \"" + query + "\"}").exchange().expectStatus().isOk() + .expectBody().jsonPath("data.bookById.name").doesNotExist() + .jsonPath("errors[0].extensions.classification").isEqualTo(ErrorType.UNAUTHORIZED.toString()); + }); + } + + @Test + void authenticatedUserShouldGetData() { + testWithWebClient((client) -> { + String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author }}"; + client.post().uri("").headers((headers) -> headers.setBasicAuth("rob", "rob")) + .bodyValue("{ \"query\": \"" + query + "\"}").exchange().expectStatus().isOk().expectBody() + .jsonPath("data.bookById.name").isEqualTo("GraphQL for beginners") + .jsonPath("errors[0].extensions.classification").doesNotExist(); + }); + } + + private void testWithWebClient(Consumer consumer) { + this.contextRunner.run((context) -> { + WebTestClient client = WebTestClient.bindToApplicationContext(context).configureClient() + .defaultHeaders((headers) -> { + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + }).baseUrl(BASE_URL).build(); + consumer.accept(client); + }); + } + + @Configuration(proxyBeanMethods = false) + static class DataFetchersConfiguration { + + @Bean + RuntimeWiringConfigurer bookDataFetcher(BookService bookService) { + return (builder) -> builder.type(TypeRuntimeWiring.newTypeWiring("Query").dataFetcher("bookById", + (env) -> bookService.getBookdById(env.getArgument("id")))); + } + + @Bean + BookService bookService() { + return new BookService(); + } + + } + + static class BookService { + + @PreAuthorize("hasRole('USER')") + @Nullable + Mono getBookdById(String id) { + return Mono.justOrEmpty(GraphQlTestDataFetchers.getBookById(id)); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableWebFluxSecurity + @EnableReactiveMethodSecurity + static class SecurityConfig { + + @Bean + SecurityWebFilterChain springWebFilterChain(ServerHttpSecurity http) throws Exception { + return http.csrf((spec) -> spec.disable()) + // Demonstrate that method security works + // Best practice to use both for defense in depth + .authorizeExchange((requests) -> requests.anyExchange().permitAll()).httpBasic(withDefaults()) + .build(); + } + + @Bean + @SuppressWarnings("deprecation") + MapReactiveUserDetailsService userDetailsService() { + User.UserBuilder userBuilder = User.withDefaultPasswordEncoder(); + UserDetails rob = userBuilder.username("rob").password("rob").roles("USER").build(); + UserDetails admin = userBuilder.username("admin").password("admin").roles("USER", "ADMIN").build(); + return new MapReactiveUserDetailsService(rob, admin); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebMvcSecurityAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebMvcSecurityAutoConfigurationTests.java new file mode 100644 index 0000000000..ebdf9a7d40 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebMvcSecurityAutoConfigurationTests.java @@ -0,0 +1,179 @@ +/* + * Copyright 2020-2021 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.boot.autoconfigure.graphql.security; + +import graphql.schema.idl.TypeRuntimeWiring; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.graphql.Book; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlTestDataFetchers; +import org.springframework.boot.autoconfigure.graphql.servlet.GraphQlWebMvcAutoConfiguration; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.execution.ErrorType; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.security.SecurityContextThreadLocalAccessor; +import org.springframework.graphql.security.SecurityDataFetcherExceptionResolver; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.DefaultSecurityFilterChain; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.config.Customizer.withDefaults; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; +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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for {@link GraphQlWebMvcSecurityAutoConfiguration}. + * + * @author Brian Clozel + */ +class GraphQlWebMvcSecurityAutoConfigurationTests { + + private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(DispatcherServletAutoConfiguration.class, WebMvcAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, JacksonAutoConfiguration.class, + GraphQlAutoConfiguration.class, GraphQlWebMvcAutoConfiguration.class, + GraphQlWebMvcSecurityAutoConfiguration.class, SecurityAutoConfiguration.class)) + .withUserConfiguration(DataFetchersConfiguration.class, SecurityConfig.class) + .withPropertyValues("spring.main.web-application-type=servlet"); + + @Test + void contributesSecurityComponents() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(SecurityDataFetcherExceptionResolver.class); + assertThat(context).hasSingleBean(SecurityContextThreadLocalAccessor.class); + }); + } + + @Test + void anonymousUserShouldBeUnauthorized() { + testWith((mockMvc) -> { + String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author }}"; + MvcResult result = mockMvc.perform(post("/graphql").content("{\"query\": \"" + query + "\"}")).andReturn(); + mockMvc.perform(asyncDispatch(result)).andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("data.bookById.name").doesNotExist()).andExpect( + jsonPath("errors[0].extensions.classification").value(ErrorType.UNAUTHORIZED.toString())); + }); + } + + @Test + void authenticatedUserShouldGetData() { + testWith((mockMvc) -> { + String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author }}"; + MvcResult result = mockMvc + .perform(post("/graphql").content("{\"query\": \"" + query + "\"}").with(user("rob"))).andReturn(); + mockMvc.perform(asyncDispatch(result)).andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("data.bookById.name").value("GraphQL for beginners")) + .andExpect(jsonPath("errors").doesNotExist()); + }); + + } + + private void testWith(MockMvcConsumer mockMvcConsumer) { + this.contextRunner.run((context) -> { + MediaType mediaType = MediaType.APPLICATION_JSON; + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context) + .defaultRequest(post("/graphql").contentType(mediaType).accept(mediaType)).apply(springSecurity()) + .build(); + mockMvcConsumer.accept(mockMvc); + }); + } + + private interface MockMvcConsumer { + + void accept(MockMvc mockMvc) throws Exception; + + } + + @Configuration(proxyBeanMethods = false) + static class DataFetchersConfiguration { + + @Bean + RuntimeWiringConfigurer bookDataFetcher(BookService bookService) { + return (builder) -> builder.type(TypeRuntimeWiring.newTypeWiring("Query").dataFetcher("bookById", + (env) -> bookService.getBookdById(env.getArgument("id")))); + } + + @Bean + BookService bookService() { + return new BookService(); + } + + } + + static class BookService { + + @PreAuthorize("hasRole('USER')") + @Nullable + Book getBookdById(String id) { + return GraphQlTestDataFetchers.getBookById(id); + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableWebSecurity + @EnableGlobalMethodSecurity(prePostEnabled = true) + static class SecurityConfig { + + @Bean + DefaultSecurityFilterChain springWebFilterChain(HttpSecurity http) throws Exception { + return http.csrf((c) -> c.disable()) + // Demonstrate that method security works + // Best practice to use both for defense in depth + .authorizeRequests((requests) -> requests.anyRequest().permitAll()).httpBasic(withDefaults()) + .build(); + } + + @Bean + static InMemoryUserDetailsManager userDetailsService() { + User.UserBuilder userBuilder = User.withDefaultPasswordEncoder(); + UserDetails rob = userBuilder.username("rob").password("rob").roles("USER").build(); + UserDetails admin = userBuilder.username("admin").password("admin").roles("USER", "ADMIN").build(); + return new InMemoryUserDetailsManager(rob, admin); + } + + } + +} From 9a92a9c91db084124d592a97b59f521cb07ef1ab Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:34:00 +0100 Subject: [PATCH 12/19] Add AutoConfigureGraphQl test annotation This commit adds the `@AutoConfigureGraphQl` test annotation. It can be used to import the relevant auto-configurations when testing a GraphQL application. Currently, it will get the main `GraphQlAutoConfiguration`, but also what's required for configuring codecs and validation support. See gh-29140 --- .../build.gradle | 1 + .../graphql/AutoConfigureGraphQl.java | 47 +++++++++++++++++++ .../autoconfigure/graphql/package-info.java | 20 ++++++++ .../main/resources/META-INF/spring.factories | 6 +++ 4 files changed, 74 insertions(+) create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/AutoConfigureGraphQl.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/package-info.java diff --git a/spring-boot-project/spring-boot-test-autoconfigure/build.gradle b/spring-boot-project/spring-boot-test-autoconfigure/build.gradle index cf2508f877..303461e450 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-test-autoconfigure/build.gradle @@ -55,6 +55,7 @@ dependencies { optional("org.springframework.data:spring-data-neo4j") optional("org.springframework.data:spring-data-r2dbc") optional("org.springframework.data:spring-data-redis") + optional("org.springframework.graphql:spring-graphql-test") optional("org.springframework.restdocs:spring-restdocs-mockmvc") { exclude group: "javax.servlet", module: "javax.servlet-api" } diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/AutoConfigureGraphQl.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/AutoConfigureGraphQl.java new file mode 100644 index 0000000000..cfea9dfdcc --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/AutoConfigureGraphQl.java @@ -0,0 +1,47 @@ +/* + * Copyright 2020-2021 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.boot.test.autoconfigure.graphql; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; + +/** + * {@link ImportAutoConfiguration Auto-configuration imports} for typical Spring GraphQL + * tests. Most tests should consider using {@link GraphQlTest @GraphQlTest} rather than + * using this annotation directly. + * + * @author Brian Clozel + * @since 2.7.0 + * @see GraphQlTest + * @see org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration + * @see org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration + * @see org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@ImportAutoConfiguration +public @interface AutoConfigureGraphQl { + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/package-info.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/package-info.java new file mode 100644 index 0000000000..ea4880ea00 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020-2021 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. + */ + +/** + * Auto-configuration for GraphQL testing. + */ +package org.springframework.boot.test.autoconfigure.graphql; diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories index c0a4874dbd..26149a57fe 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories @@ -188,6 +188,12 @@ org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfigurati org.springframework.boot.autoconfigure.web.servlet.HttpEncodingAutoConfiguration,\ org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration +# AutoConfigureGraphQl auto-configuration imports +org.springframework.boot.test.autoconfigure.graphql.AutoConfigureGraphQl=\ +org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration,\ +org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration,\ +org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration + # AutoConfigureWebServiceClient org.springframework.boot.test.autoconfigure.webservices.client.AutoConfigureWebServiceClient=\ org.springframework.boot.test.autoconfigure.webservices.client.WebServiceClientTemplateAutoConfiguration,\ From c0707e4f5bf548f2356b5753287be963009127dc Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:34:06 +0100 Subject: [PATCH 13/19] Auto-configure GraphQlTester This commit adds the required infrastructure to auto-configure a `GraphQlTester` or `WebGraphQlTester` in Spring Boot tests. Specific annotations like `AutoConfigureGraphQlTester` and `AutoConfigureWebGraphQlTester` will contribute pre-configured beans for testing a GraphQL with the tester. This also ships a `ContextCustomize` for contributing a `GraphQlTester` in the case of a full `@SpringBootTest` integration test against a live server. See gh-29140 --- .../GraphQlTestContextBootstrapper.java | 36 +++ .../tester/AutoConfigureGraphQlTester.java | 43 ++++ .../tester/AutoConfigureWebGraphQlTester.java | 52 +++++ .../GraphQlTesterAutoConfiguration.java | 49 ++++ .../WebGraphQlTesterAutoConfiguration.java | 51 ++++ .../graphql/tester/package-info.java | 20 ++ .../main/resources/META-INF/spring.factories | 8 + .../GraphQlTesterAutoConfigurationTests.java | 62 +++++ .../spring-boot-test/build.gradle | 1 + .../GraphQlTesterContextCustomizer.java | 218 ++++++++++++++++++ ...GraphQlTesterContextCustomizerFactory.java | 54 +++++ .../test/graphql/tester/package-info.java | 20 ++ .../main/resources/META-INF/spring.factories | 1 + ...sterContextCustomizerIntegrationTests.java | 88 +++++++ ...textCustomizerWithCustomBasePathTests.java | 88 +++++++ ...tCustomizerWithCustomContextPathTests.java | 80 +++++++ 16 files changed, 871 insertions(+) create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTestContextBootstrapper.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/AutoConfigureGraphQlTester.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/AutoConfigureWebGraphQlTester.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/GraphQlTesterAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/WebGraphQlTesterAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/package-info.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/tester/GraphQlTesterAutoConfigurationTests.java create mode 100644 spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizer.java create mode 100644 spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerFactory.java create mode 100644 spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/package-info.java create mode 100644 spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerIntegrationTests.java create mode 100644 spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerWithCustomBasePathTests.java create mode 100644 spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerWithCustomContextPathTests.java diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTestContextBootstrapper.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTestContextBootstrapper.java new file mode 100644 index 0000000000..5e05495cde --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTestContextBootstrapper.java @@ -0,0 +1,36 @@ +/* + * Copyright 2020-2021 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.boot.test.autoconfigure.graphql; + +import org.springframework.boot.test.context.SpringBootTestContextBootstrapper; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.test.context.TestContextBootstrapper; + +/** + * {@link TestContextBootstrapper} for {@link GraphQlTest @GraphQlTest}. + * + * @author Brian Clozel + */ +class GraphQlTestContextBootstrapper extends SpringBootTestContextBootstrapper { + + @Override + protected String[] getProperties(Class testClass) { + return MergedAnnotations.from(testClass, MergedAnnotations.SearchStrategy.INHERITED_ANNOTATIONS) + .get(GraphQlTest.class).getValue("properties", String[].class).orElse(null); + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/AutoConfigureGraphQlTester.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/AutoConfigureGraphQlTester.java new file mode 100644 index 0000000000..f940d3ed02 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/AutoConfigureGraphQlTester.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020-2021 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.boot.test.autoconfigure.graphql.tester; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.graphql.test.tester.GraphQlTester; + +/** + * Annotation that can be applied to a test class to enable a {@link GraphQlTester}. + * + * @author Brian Clozel + * @since 2.7.0 + * @see GraphQlTesterAutoConfiguration + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@ImportAutoConfiguration +public @interface AutoConfigureGraphQlTester { + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/AutoConfigureWebGraphQlTester.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/AutoConfigureWebGraphQlTester.java new file mode 100644 index 0000000000..5c5a8e1bd6 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/AutoConfigureWebGraphQlTester.java @@ -0,0 +1,52 @@ +/* + * Copyright 2020-2021 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.boot.test.autoconfigure.graphql.tester; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.graphql.test.tester.WebGraphQlTester; + +/** + * Annotation that can be applied to a test class to enable a {@link WebGraphQlTester}. + * + *

+ * This annotation should be used with + * {@link org.springframework.boot.test.context.SpringBootTest @SpringBootTest} tests with + * Spring MVC or Spring WebFlux mock infrastructures. + * + * @author Brian Clozel + * @since 2.7.0 + * @see WebGraphQlTesterAutoConfiguration + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@AutoConfigureMockMvc +@AutoConfigureWebTestClient +@ImportAutoConfiguration +public @interface AutoConfigureWebGraphQlTester { + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/GraphQlTesterAutoConfiguration.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/GraphQlTesterAutoConfiguration.java new file mode 100644 index 0000000000..684b3ef6b7 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/GraphQlTesterAutoConfiguration.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020-2021 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.boot.test.autoconfigure.graphql.tester; + +import graphql.GraphQL; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.GraphQlService; +import org.springframework.graphql.test.tester.GraphQlTester; + +/** + * Auto-configuration for {@link GraphQlTester}. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ GraphQL.class, GraphQlTester.class }) +@AutoConfigureAfter(GraphQlAutoConfiguration.class) +public class GraphQlTesterAutoConfiguration { + + @Bean + @ConditionalOnBean(GraphQlService.class) + @ConditionalOnMissingBean + public GraphQlTester graphQlTester(GraphQlService graphQlService) { + return GraphQlTester.create(graphQlService); + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/WebGraphQlTesterAutoConfiguration.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/WebGraphQlTesterAutoConfiguration.java new file mode 100644 index 0000000000..e2f4fa469f --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/WebGraphQlTesterAutoConfiguration.java @@ -0,0 +1,51 @@ +/* + * Copyright 2020-2021 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.boot.test.autoconfigure.graphql.tester; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.graphql.GraphQlProperties; +import org.springframework.boot.test.autoconfigure.web.reactive.WebTestClientAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.MockMvcAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.test.tester.WebGraphQlTester; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Auto-configuration for {@link WebGraphQlTester}. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ WebClient.class, WebTestClient.class, WebGraphQlTester.class }) +@AutoConfigureAfter({ WebTestClientAutoConfiguration.class, MockMvcAutoConfiguration.class }) +public class WebGraphQlTesterAutoConfiguration { + + @Bean + @ConditionalOnBean(WebTestClient.class) + @ConditionalOnMissingBean + public WebGraphQlTester webTestClientGraphQlTester(WebTestClient webTestClient, GraphQlProperties properties) { + WebTestClient mutatedWebTestClient = webTestClient.mutate().baseUrl(properties.getPath()).build(); + return WebGraphQlTester.create(mutatedWebTestClient); + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/package-info.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/package-info.java new file mode 100644 index 0000000000..182468fa0e --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020-2021 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. + */ + +/** + * Auto-configuration for GraphQL tester. + */ +package org.springframework.boot.test.autoconfigure.graphql.tester; diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories index 26149a57fe..0f5028ae3f 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring.factories @@ -194,6 +194,14 @@ org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration,\ org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration,\ org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration +# AutoConfigureGraphQlTester auto-configuration imports +org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureGraphQlTester=\ +org.springframework.boot.test.autoconfigure.graphql.tester.GraphQlTesterAutoConfiguration + +# AutoConfigureWebGraphQlTester auto-configuration imports +org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureWebGraphQlTester=\ +org.springframework.boot.test.autoconfigure.graphql.tester.WebGraphQlTesterAutoConfiguration + # AutoConfigureWebServiceClient org.springframework.boot.test.autoconfigure.webservices.client.AutoConfigureWebServiceClient=\ org.springframework.boot.test.autoconfigure.webservices.client.WebServiceClientTemplateAutoConfiguration,\ diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/tester/GraphQlTesterAutoConfigurationTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/tester/GraphQlTesterAutoConfigurationTests.java new file mode 100644 index 0000000000..322902f430 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/tester/GraphQlTesterAutoConfigurationTests.java @@ -0,0 +1,62 @@ +/* + * Copyright 2012-2021 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.boot.test.autoconfigure.graphql.tester; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.GraphQlService; +import org.springframework.graphql.test.tester.GraphQlTester; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GraphQlTesterAutoConfiguration}. + * + * @author Brian Clozel + */ +class GraphQlTesterAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(GraphQlTesterAutoConfiguration.class)); + + @Test + void shouldNotContributeTesterIfGraphQlServiceNotPresent() { + this.contextRunner.run((context) -> assertThat(context).hasNotFailed().doesNotHaveBean(GraphQlTester.class)); + } + + @Test + void shouldContributeTester() { + this.contextRunner.withUserConfiguration(CustomGraphQlServiceConfiguration.class) + .run((context) -> assertThat(context).hasNotFailed().hasSingleBean(GraphQlTester.class)); + } + + @Configuration(proxyBeanMethods = false) + static class CustomGraphQlServiceConfiguration { + + @Bean + GraphQlService graphQlService() { + return mock(GraphQlService.class); + } + + } + +} diff --git a/spring-boot-project/spring-boot-test/build.gradle b/spring-boot-project/spring-boot-test/build.gradle index 7571c2b34f..8ce8eba2c8 100644 --- a/spring-boot-project/spring-boot-test/build.gradle +++ b/spring-boot-project/spring-boot-test/build.gradle @@ -36,6 +36,7 @@ dependencies { optional("org.springframework:spring-test") optional("org.springframework:spring-web") optional("org.springframework:spring-webflux") + optional("org.springframework.graphql:spring-graphql-test") optional("net.sourceforge.htmlunit:htmlunit") { exclude(group: "commons-logging", module: "commons-logging") } diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizer.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizer.java new file mode 100644 index 0000000000..d079bd5b76 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizer.java @@ -0,0 +1,218 @@ +/* + * Copyright 2020-2021 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.boot.test.graphql.tester; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.beans.factory.BeanFactoryUtils; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.server.AbstractConfigurableWebServerFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.Ordered; +import org.springframework.graphql.test.tester.GraphQlTester; +import org.springframework.graphql.test.tester.WebGraphQlTester; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.MergedContextConfiguration; +import org.springframework.test.context.TestContextAnnotationUtils; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.context.WebApplicationContext; + +/** + * {@link ContextCustomizer} for {@link GraphQlTester}. + * + * @author Brian Clozel + */ +class GraphQlTesterContextCustomizer implements ContextCustomizer { + + @Override + public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) { + SpringBootTest springBootTest = TestContextAnnotationUtils.findMergedAnnotation(mergedConfig.getTestClass(), + SpringBootTest.class); + if (springBootTest.webEnvironment().isEmbedded()) { + registerGraphQlTester(context); + } + } + + private void registerGraphQlTester(ConfigurableApplicationContext context) { + ConfigurableListableBeanFactory beanFactory = context.getBeanFactory(); + if (beanFactory instanceof BeanDefinitionRegistry) { + registerGraphQlTester((BeanDefinitionRegistry) beanFactory); + } + } + + private void registerGraphQlTester(BeanDefinitionRegistry registry) { + RootBeanDefinition definition = new RootBeanDefinition(GraphQlTesterRegistrar.class); + definition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + registry.registerBeanDefinition(GraphQlTesterRegistrar.class.getName(), definition); + } + + @Override + public boolean equals(Object obj) { + return (obj != null) && (obj.getClass() == getClass()); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } + + private static class GraphQlTesterRegistrar + implements BeanDefinitionRegistryPostProcessor, Ordered, BeanFactoryAware { + + private BeanFactory beanFactory; + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.beanFactory = beanFactory; + } + + @Override + public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { + if (BeanFactoryUtils.beanNamesForTypeIncludingAncestors((ListableBeanFactory) this.beanFactory, + GraphQlTester.class, false, false).length == 0) { + registry.registerBeanDefinition(WebGraphQlTester.class.getName(), + new RootBeanDefinition(GraphQlTesterFactory.class)); + } + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE - 1; + } + + } + + public static class GraphQlTesterFactory implements FactoryBean, ApplicationContextAware { + + private static final String SERVLET_APPLICATION_CONTEXT_CLASS = "org.springframework.web.context.WebApplicationContext"; + + private static final String REACTIVE_APPLICATION_CONTEXT_CLASS = "org.springframework.boot.web.reactive.context.ReactiveWebApplicationContext"; + + private ApplicationContext applicationContext; + + private GraphQlTester object; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @Override + public boolean isSingleton() { + return true; + } + + @Override + public Class getObjectType() { + return GraphQlTester.class; + } + + @Override + public GraphQlTester getObject() throws Exception { + if (this.object == null) { + this.object = createGraphQlTester(); + } + return this.object; + } + + private WebGraphQlTester createGraphQlTester() { + WebTestClient webTestClient = this.applicationContext.getBean(WebTestClient.class); + boolean sslEnabled = isSslEnabled(this.applicationContext); + String port = this.applicationContext.getEnvironment().getProperty("local.server.port", "8080"); + WebTestClient mutatedWebClient = webTestClient.mutate().baseUrl(getBaseUrl(sslEnabled, port)).build(); + return WebGraphQlTester.create(mutatedWebClient); + } + + private String getBaseUrl(boolean sslEnabled, String port) { + String basePath = deduceBasePath(); + return (sslEnabled ? "https" : "http") + "://localhost:" + port + basePath; + } + + private String deduceBasePath() { + return deduceServerBasePath() + findConfiguredGraphQlPath(); + } + + private String findConfiguredGraphQlPath() { + String configuredPath = this.applicationContext.getEnvironment().getProperty("spring.graphql.path"); + return StringUtils.hasText(configuredPath) ? configuredPath : "/graphql"; + } + + private String deduceServerBasePath() { + String serverBasePath = ""; + WebApplicationType webApplicationType = deduceFromApplicationContext(this.applicationContext.getClass()); + if (webApplicationType == WebApplicationType.REACTIVE) { + serverBasePath = this.applicationContext.getEnvironment().getProperty("spring.webflux.base-path"); + + } + else if (webApplicationType == WebApplicationType.SERVLET) { + serverBasePath = ((WebApplicationContext) this.applicationContext).getServletContext().getContextPath(); + } + return (serverBasePath != null) ? serverBasePath : ""; + } + + static WebApplicationType deduceFromApplicationContext(Class applicationContextClass) { + if (isAssignable(SERVLET_APPLICATION_CONTEXT_CLASS, applicationContextClass)) { + return WebApplicationType.SERVLET; + } + if (isAssignable(REACTIVE_APPLICATION_CONTEXT_CLASS, applicationContextClass)) { + return WebApplicationType.REACTIVE; + } + return WebApplicationType.NONE; + } + + private static boolean isAssignable(String target, Class type) { + try { + return ClassUtils.resolveClassName(target, null).isAssignableFrom(type); + } + catch (Throwable ex) { + return false; + } + } + + private boolean isSslEnabled(ApplicationContext context) { + try { + AbstractConfigurableWebServerFactory webServerFactory = context + .getBean(AbstractConfigurableWebServerFactory.class); + return webServerFactory.getSsl() != null && webServerFactory.getSsl().isEnabled(); + } + catch (NoSuchBeanDefinitionException ex) { + return false; + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerFactory.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerFactory.java new file mode 100644 index 0000000000..497db17727 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerFactory.java @@ -0,0 +1,54 @@ +/* + * Copyright 2020-2021 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.boot.test.graphql.tester; + +import java.util.List; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.graphql.test.tester.GraphQlTester; +import org.springframework.test.context.ContextConfigurationAttributes; +import org.springframework.test.context.ContextCustomizer; +import org.springframework.test.context.ContextCustomizerFactory; +import org.springframework.test.context.TestContextAnnotationUtils; +import org.springframework.util.ClassUtils; + +/** + * {@link ContextCustomizerFactory} for {@link GraphQlTester}. + * + * @author Brian Clozel + * @see GraphQlTesterContextCustomizer + */ +class GraphQlTesterContextCustomizerFactory implements ContextCustomizerFactory { + + private static final String GRAPHQLTESTER_CLASS = "org.springframework.graphql.test.tester.GraphQlTester"; + + private static final String WEBTESTCLIENT_CLASS = "org.springframework.test.web.reactive.server.WebTestClient"; + + @Override + public ContextCustomizer createContextCustomizer(Class testClass, + List configAttributes) { + SpringBootTest springBootTest = TestContextAnnotationUtils.findMergedAnnotation(testClass, + SpringBootTest.class); + return (springBootTest != null && isGraphQlTesterPresent()) ? new GraphQlTesterContextCustomizer() : null; + } + + private boolean isGraphQlTesterPresent() { + return ClassUtils.isPresent(WEBTESTCLIENT_CLASS, getClass().getClassLoader()) + && ClassUtils.isPresent(GRAPHQLTESTER_CLASS, getClass().getClassLoader()); + } + +} diff --git a/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/package-info.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/package-info.java new file mode 100644 index 0000000000..9eeb1f664d --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020-2021 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. + */ + +/** + * {@link org.springframework.graphql.test.tester.GraphQlTester} utilities. + */ +package org.springframework.boot.test.graphql.tester; diff --git a/spring-boot-project/spring-boot-test/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-test/src/main/resources/META-INF/spring.factories index 5a75a417aa..3dd1783cd9 100644 --- a/spring-boot-project/spring-boot-test/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-test/src/main/resources/META-INF/spring.factories @@ -2,6 +2,7 @@ org.springframework.test.context.ContextCustomizerFactory=\ org.springframework.boot.test.context.ImportsContextCustomizerFactory,\ org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizerFactory,\ +org.springframework.boot.test.graphql.tester.GraphQlTesterContextCustomizerFactory,\ org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory,\ org.springframework.boot.test.mock.mockito.MockitoContextCustomizerFactory,\ org.springframework.boot.test.web.client.TestRestTemplateContextCustomizerFactory,\ diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerIntegrationTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerIntegrationTests.java new file mode 100644 index 0000000000..b9c334ca88 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerIntegrationTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-2021 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.boot.test.graphql.tester; + +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.embedded.tomcat.TomcatReactiveWebServerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.graphql.test.tester.GraphQlTester; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ContextPathCompositeHandler; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.test.annotation.DirtiesContext; + +/** + * Integration test for {@link GraphQlTesterContextCustomizer}. + * + * @author Brian Clozel + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.main.web-application-type=reactive") +@DirtiesContext +class GraphQlTesterContextCustomizerIntegrationTests { + + @Autowired + GraphQlTester graphQlTester; + + @Test + void shouldHandleGraphQlRequests() { + this.graphQlTester.query("{}").executeAndVerify(); + } + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + + @Bean + TomcatReactiveWebServerFactory webServerFactory() { + return new TomcatReactiveWebServerFactory(0); + } + + @Bean + HttpHandler httpHandler() { + TestHandler httpHandler = new TestHandler(); + Map handlersMap = Collections.singletonMap("/graphql", httpHandler); + return new ContextPathCompositeHandler(handlersMap); + } + + } + + static class TestHandler implements HttpHandler { + + private static final DefaultDataBufferFactory factory = new DefaultDataBufferFactory(); + + @Override + public Mono handle(ServerHttpRequest request, ServerHttpResponse response) { + response.setStatusCode(HttpStatus.OK); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + return response.writeWith(Mono.just(factory.wrap("{\"data\":{}}".getBytes()))); + } + + } + +} diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerWithCustomBasePathTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerWithCustomBasePathTests.java new file mode 100644 index 0000000000..6c1ecb4e19 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerWithCustomBasePathTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-2021 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.boot.test.graphql.tester; + +import java.util.Collections; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.embedded.tomcat.TomcatReactiveWebServerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.graphql.test.tester.GraphQlTester; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ContextPathCompositeHandler; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.test.context.TestPropertySource; + +/** + * Tests for {@link GraphQlTesterContextCustomizer} with a custom context path for a + * Reactive web application. + * + * @author Brian Clozel + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = { "spring.main.web-application-type=reactive", "spring.webflux.base-path=/test" }) +class GraphQlTesterContextCustomizerWithCustomBasePathTests { + + @Autowired + GraphQlTester graphQlTester; + + @Test + void shouldHandleGraphQlRequests() { + this.graphQlTester.query("{}").executeAndVerify(); + } + + @Configuration(proxyBeanMethods = false) + static class TestConfig { + + @Bean + TomcatReactiveWebServerFactory webServerFactory() { + return new TomcatReactiveWebServerFactory(0); + } + + @Bean + HttpHandler httpHandler() { + TestHandler httpHandler = new TestHandler(); + Map handlersMap = Collections.singletonMap("/test/graphql", httpHandler); + return new ContextPathCompositeHandler(handlersMap); + } + + } + + static class TestHandler implements HttpHandler { + + private static final DefaultDataBufferFactory factory = new DefaultDataBufferFactory(); + + @Override + public Mono handle(ServerHttpRequest request, ServerHttpResponse response) { + response.setStatusCode(HttpStatus.OK); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + return response.writeWith(Mono.just(factory.wrap("{\"data\":{}}".getBytes()))); + } + + } + +} diff --git a/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerWithCustomContextPathTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerWithCustomContextPathTests.java new file mode 100644 index 0000000000..095e432041 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/GraphQlTesterContextCustomizerWithCustomContextPathTests.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-2021 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.boot.test.graphql.tester; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.graphql.test.tester.GraphQlTester; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.DispatcherServlet; + +/** + * Tests for {@link GraphQlTesterContextCustomizer} with a custom context path for a + * Servlet web application. + * + * @author Brian Clozel + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@TestPropertySource(properties = "server.servlet.context-path=/test") +class GraphQlTesterContextCustomizerWithCustomContextPathTests { + + @Autowired + GraphQlTester graphQlTester; + + @Test + void shouldHandleGraphQlRequests() { + this.graphQlTester.query("{}").executeAndVerify(); + } + + @Configuration(proxyBeanMethods = false) + @Import(TestController.class) + static class TestConfig { + + @Bean + TomcatServletWebServerFactory webServerFactory() { + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0); + factory.setContextPath("/test"); + return factory; + } + + @Bean + DispatcherServlet dispatcherServlet() { + return new DispatcherServlet(); + } + + } + + @RestController + static class TestController { + + @PostMapping(path = "/graphql", produces = MediaType.APPLICATION_JSON_VALUE) + String graphql() { + return "{}"; + } + + } + +} From a34308e5f738872237f21156b9cc61071451e1d6 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:34:15 +0100 Subject: [PATCH 14/19] Add GraphQlTest slice This commit adds the `@GraphQlTest` annotation which brings a new type of sliced test for GraphQL applications. This considers all the required infrastructure brought by `@AutoConfigureGraphQl`, but also brings application components like `@Controller` beans and `RuntimeWiringConfigurer`. With this type of test, we'll only initialize a minimal setup for testing a set of Controllers, without involving any transport-related component. See gh-29140 --- .../autoconfigure/graphql/GraphQlTest.java | 152 +++++++++++++++ .../graphql/GraphQlTypeExcludeFilter.java | 93 +++++++++ .../boot/test/autoconfigure/graphql/Book.java | 71 +++++++ .../autoconfigure/graphql/BookController.java | 36 ++++ .../graphql/ExampleGraphQlApplication.java | 30 +++ .../graphql/GraphQlTestIntegrationTest.java | 41 ++++ .../GraphQlTypeExcludeFilterTests.java | 184 ++++++++++++++++++ .../test/resources/graphql/schema.graphqls | 10 + 8 files changed, 617 insertions(+) create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTest.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTypeExcludeFilter.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/Book.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/BookController.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/ExampleGraphQlApplication.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTestIntegrationTest.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTypeExcludeFilterTests.java create mode 100644 spring-boot-project/spring-boot-test-autoconfigure/src/test/resources/graphql/schema.graphqls diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTest.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTest.java new file mode 100644 index 0000000000..d0cfa2d55e --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTest.java @@ -0,0 +1,152 @@ +/* + * Copyright 2020-2021 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.boot.test.autoconfigure.graphql; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.test.autoconfigure.OverrideAutoConfiguration; +import org.springframework.boot.test.autoconfigure.core.AutoConfigureCache; +import org.springframework.boot.test.autoconfigure.filter.TypeExcludeFilters; +import org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureGraphQlTester; +import org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureWebGraphQlTester; +import org.springframework.boot.test.autoconfigure.json.AutoConfigureJson; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.core.annotation.AliasFor; +import org.springframework.core.env.Environment; +import org.springframework.test.context.BootstrapWith; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +/** + * Annotation to perform GraphQL tests focusing on GraphQL request execution without a Web + * layer, and loading only a subset of the application configuration. + *

+ * The annotation disables full auto-configuration and instead loads only components + * relevant to GraphQL tests, including the following: + *

    + *
  • {@code @Controller} + *
  • {@code RuntimeWiringConfigurer} + *
  • {@code @JsonComponent} + *
  • {@code Converter} + *
  • {@code GenericConverter} + *
+ *

+ * The annotation does not automatically load {@code @Component}, {@code @Service}, + * {@code @Repository}, and other beans. + *

+ * By default, tests annotated with {@code @GraphQlTest} have a + * {@link org.springframework.graphql.test.tester.GraphQlTester} configured. For more + * fine-grained control of the GraphQlTester, use + * {@link AutoConfigureGraphQlTester @AutoConfigureGraphQlTester}. + *

+ * Typically {@code @GraphQlTest} is used in combination with + * {@link org.springframework.boot.test.mock.mockito.MockBean @MockBean} or + * {@link org.springframework.context.annotation.Import @Import} to load any collaborators + * and other components required for the tests. + *

+ * To load your full application configuration instead and test via + * {@code WebGraphQlTester}, consider using + * {@link org.springframework.boot.test.context.SpringBootTest @SpringBootTest} combined + * with {@link AutoConfigureWebGraphQlTester @AutoConfigureWebGraphQlTester}. + * + * @author Brian Clozel + * @since 2.7.0 + * @see AutoConfigureGraphQlTester + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@BootstrapWith(GraphQlTestContextBootstrapper.class) +@ExtendWith(SpringExtension.class) +@OverrideAutoConfiguration(enabled = false) +@TypeExcludeFilters(GraphQlTypeExcludeFilter.class) +@AutoConfigureCache +@AutoConfigureJson +@AutoConfigureGraphQl +@AutoConfigureGraphQlTester +@ImportAutoConfiguration +public @interface GraphQlTest { + + /** + * Properties in form {@literal key=value} that should be added to the Spring + * {@link Environment} before the test runs. + * @return the properties to add + */ + String[] properties() default {}; + + /** + * Specifies the controllers to test. This is an alias of {@link #controllers()} which + * can be used for brevity if no other attributes are defined. See + * {@link #controllers()} for details. + * @see #controllers() + * @return the controllers to test + */ + @AliasFor("controllers") + Class[] value() default {}; + + /** + * Specifies the controllers to test. May be left blank if all {@code @Controller} + * beans should be added to the application context. + * @see #value() + * @return the controllers to test + */ + @AliasFor("value") + Class[] controllers() default {}; + + /** + * Determines if default filtering should be used with + * {@link SpringBootApplication @SpringBootApplication}. By default, only + * {@code @Controller} (when no explicit {@link #controllers() controllers} are + * defined), {@code RuntimeWiringConfigurer}, {@code @JsonComponent}, + * {@code Converter}, and {@code GenericConverter} beans are included. + * @see #includeFilters() + * @see #excludeFilters() + * @return if default filters should be used + */ + boolean useDefaultFilters() default true; + + /** + * A set of include filters which can be used to add otherwise filtered beans to the + * application context. + * @return include filters to apply + */ + ComponentScan.Filter[] includeFilters() default {}; + + /** + * A set of exclude filters which can be used to filter beans that would otherwise be + * added to the application context. + * @return exclude filters to apply + */ + ComponentScan.Filter[] excludeFilters() default {}; + + /** + * Auto-configuration exclusions that should be applied for this test. + * @return auto-configuration exclusions to apply + */ + @AliasFor(annotation = ImportAutoConfiguration.class, attribute = "exclude") + Class[] excludeAutoConfiguration() default {}; + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTypeExcludeFilter.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTypeExcludeFilter.java new file mode 100644 index 0000000000..1ae5b52f60 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTypeExcludeFilter.java @@ -0,0 +1,93 @@ +/* + * Copyright 2020-2021 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.boot.test.autoconfigure.graphql; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +import org.springframework.boot.context.TypeExcludeFilter; +import org.springframework.boot.jackson.JsonComponent; +import org.springframework.boot.test.autoconfigure.filter.StandardAnnotationCustomizableTypeExcludeFilter; +import org.springframework.core.convert.converter.Converter; +import org.springframework.core.convert.converter.GenericConverter; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.stereotype.Controller; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; + +/** + * {@link TypeExcludeFilter} for {@link GraphQlTest @GraphQlTest}. + * + * @author Brian Clozel + * @since 2.7.0 + */ +public class GraphQlTypeExcludeFilter extends StandardAnnotationCustomizableTypeExcludeFilter { + + private static final Class[] NO_CONTROLLERS = {}; + + private static final String[] OPTIONAL_INCLUDES = { "com.fasterxml.jackson.databind.Module" }; + + private static final Set> DEFAULT_INCLUDES; + + static { + Set> includes = new LinkedHashSet<>(); + includes.add(JsonComponent.class); + includes.add(RuntimeWiringConfigurer.class); + includes.add(Converter.class); + includes.add(GenericConverter.class); + for (String optionalInclude : OPTIONAL_INCLUDES) { + try { + includes.add(ClassUtils.forName(optionalInclude, null)); + } + catch (Exception ex) { + // Ignore + } + } + DEFAULT_INCLUDES = Collections.unmodifiableSet(includes); + } + + private static final Set> DEFAULT_INCLUDES_AND_CONTROLLER; + + static { + Set> includes = new LinkedHashSet<>(DEFAULT_INCLUDES); + includes.add(Controller.class); + DEFAULT_INCLUDES_AND_CONTROLLER = Collections.unmodifiableSet(includes); + } + + private final Class[] controllers; + + GraphQlTypeExcludeFilter(Class testClass) { + super(testClass); + this.controllers = getAnnotation().getValue("controllers", Class[].class).orElse(NO_CONTROLLERS); + } + + @Override + protected Set> getDefaultIncludes() { + if (ObjectUtils.isEmpty(this.controllers)) { + return DEFAULT_INCLUDES_AND_CONTROLLER; + } + return DEFAULT_INCLUDES; + } + + @Override + protected Set> getComponentIncludes() { + return new LinkedHashSet<>(Arrays.asList(this.controllers)); + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/Book.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/Book.java new file mode 100644 index 0000000000..9f1cb863f0 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/Book.java @@ -0,0 +1,71 @@ +/* + * Copyright 2020-2021 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.boot.test.autoconfigure.graphql; + +public class Book { + + String id; + + String name; + + int pageCount; + + String author; + + public Book() { + } + + public Book(String id, String name, int pageCount, String author) { + this.id = id; + this.name = name; + this.pageCount = pageCount; + this.author = author; + } + + public String getId() { + return this.id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public int getPageCount() { + return this.pageCount; + } + + public void setPageCount(int pageCount) { + this.pageCount = pageCount; + } + + public String getAuthor() { + return this.author; + } + + public void setAuthor(String author) { + this.author = author; + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/BookController.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/BookController.java new file mode 100644 index 0000000000..9e9fc970a4 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/BookController.java @@ -0,0 +1,36 @@ +/* + * Copyright 2020-2021 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.boot.test.autoconfigure.graphql; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +/** + * Example {@code @Controller} to be tested with {@link GraphQlTest @GraphQlTest}. + * + * @author Brian Clozel + */ +@Controller +public class BookController { + + @QueryMapping + public Book bookById(@Argument String id) { + return new Book("42", "Sample Book", 100, "Jane Spring"); + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/ExampleGraphQlApplication.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/ExampleGraphQlApplication.java new file mode 100644 index 0000000000..798592a56c --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/ExampleGraphQlApplication.java @@ -0,0 +1,30 @@ +/* + * Copyright 2020-2021 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.boot.test.autoconfigure.graphql; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Example {@link SpringBootApplication @SpringBootApplication} used with + * {@link GraphQlTest @GraphQlTest} tests. + * + * @author Brian Clozel + */ +@SpringBootApplication +public class ExampleGraphQlApplication { + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTestIntegrationTest.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTestIntegrationTest.java new file mode 100644 index 0000000000..18807b332b --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTestIntegrationTest.java @@ -0,0 +1,41 @@ +/* + * Copyright 2020-2021 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.boot.test.autoconfigure.graphql; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.graphql.test.tester.GraphQlTester; + +/** + * Integration test for {@link GraphQlTest @GraphQlTest} annotated tests. + * + * @author Brian Clozel + */ +@GraphQlTest(BookController.class) +public class GraphQlTestIntegrationTest { + + @Autowired + private GraphQlTester graphQlTester; + + @Test + void getBookdByIdShouldReturnTestBook() { + String query = "{ bookById(id: \"book-1\"){ id name pageCount author } }"; + this.graphQlTester.query(query).execute().path("data.bookById.id").entity(String.class).isEqualTo("42"); + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTypeExcludeFilterTests.java b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTypeExcludeFilterTests.java new file mode 100644 index 0000000000..53fa09adcb --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTypeExcludeFilterTests.java @@ -0,0 +1,184 @@ +/* + * Copyright 2012-2021 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.boot.test.autoconfigure.graphql; + +import java.io.IOException; + +import com.fasterxml.jackson.databind.module.SimpleModule; +import graphql.schema.idl.RuntimeWiring; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.core.type.classreading.MetadataReader; +import org.springframework.core.type.classreading.MetadataReaderFactory; +import org.springframework.core.type.classreading.SimpleMetadataReaderFactory; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.web.WebInput; +import org.springframework.graphql.web.WebInterceptor; +import org.springframework.graphql.web.WebInterceptorChain; +import org.springframework.graphql.web.WebOutput; +import org.springframework.stereotype.Controller; +import org.springframework.stereotype.Repository; +import org.springframework.stereotype.Service; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GraphQlTypeExcludeFilter} + * + * @author Brian Clozel + */ +class GraphQlTypeExcludeFilterTests { + + private MetadataReaderFactory metadataReaderFactory = new SimpleMetadataReaderFactory(); + + @Test + void matchWhenHasNoControllers() throws Exception { + GraphQlTypeExcludeFilter filter = new GraphQlTypeExcludeFilter(WithNoControllers.class); + assertThat(excludes(filter, Controller1.class)).isFalse(); + assertThat(excludes(filter, Controller2.class)).isFalse(); + assertThat(excludes(filter, ExampleRuntimeWiringConfigurer.class)).isFalse(); + assertThat(excludes(filter, ExampleService.class)).isTrue(); + assertThat(excludes(filter, ExampleRepository.class)).isTrue(); + assertThat(excludes(filter, ExampleWebInterceptor.class)).isTrue(); + assertThat(excludes(filter, ExampleModule.class)).isFalse(); + } + + @Test + void matchWhenHasController() throws Exception { + GraphQlTypeExcludeFilter filter = new GraphQlTypeExcludeFilter(WithController.class); + assertThat(excludes(filter, Controller1.class)).isFalse(); + assertThat(excludes(filter, Controller2.class)).isTrue(); + assertThat(excludes(filter, ExampleRuntimeWiringConfigurer.class)).isFalse(); + assertThat(excludes(filter, ExampleService.class)).isTrue(); + assertThat(excludes(filter, ExampleRepository.class)).isTrue(); + assertThat(excludes(filter, ExampleWebInterceptor.class)).isTrue(); + assertThat(excludes(filter, ExampleModule.class)).isFalse(); + } + + @Test + void matchNotUsingDefaultFilters() throws Exception { + GraphQlTypeExcludeFilter filter = new GraphQlTypeExcludeFilter(NotUsingDefaultFilters.class); + assertThat(excludes(filter, Controller1.class)).isTrue(); + assertThat(excludes(filter, Controller2.class)).isTrue(); + assertThat(excludes(filter, ExampleRuntimeWiringConfigurer.class)).isTrue(); + assertThat(excludes(filter, ExampleService.class)).isTrue(); + assertThat(excludes(filter, ExampleRepository.class)).isTrue(); + assertThat(excludes(filter, ExampleWebInterceptor.class)).isTrue(); + assertThat(excludes(filter, ExampleModule.class)).isTrue(); + } + + @Test + void matchWithIncludeFilter() throws Exception { + GraphQlTypeExcludeFilter filter = new GraphQlTypeExcludeFilter(WithIncludeFilter.class); + assertThat(excludes(filter, Controller1.class)).isFalse(); + assertThat(excludes(filter, Controller2.class)).isFalse(); + assertThat(excludes(filter, ExampleRuntimeWiringConfigurer.class)).isFalse(); + assertThat(excludes(filter, ExampleService.class)).isTrue(); + assertThat(excludes(filter, ExampleRepository.class)).isFalse(); + assertThat(excludes(filter, ExampleWebInterceptor.class)).isTrue(); + assertThat(excludes(filter, ExampleModule.class)).isFalse(); + } + + @Test + void matchWithExcludeFilter() throws Exception { + GraphQlTypeExcludeFilter filter = new GraphQlTypeExcludeFilter(WithExcludeFilter.class); + assertThat(excludes(filter, Controller1.class)).isTrue(); + assertThat(excludes(filter, Controller2.class)).isFalse(); + assertThat(excludes(filter, ExampleRuntimeWiringConfigurer.class)).isFalse(); + assertThat(excludes(filter, ExampleService.class)).isTrue(); + assertThat(excludes(filter, ExampleRepository.class)).isTrue(); + assertThat(excludes(filter, ExampleWebInterceptor.class)).isTrue(); + assertThat(excludes(filter, ExampleModule.class)).isFalse(); + } + + private boolean excludes(GraphQlTypeExcludeFilter filter, Class type) throws IOException { + MetadataReader metadataReader = this.metadataReaderFactory.getMetadataReader(type.getName()); + return filter.match(metadataReader, this.metadataReaderFactory); + } + + @GraphQlTest + static class WithNoControllers { + + } + + @GraphQlTest(Controller1.class) + static class WithController { + + } + + @GraphQlTest(useDefaultFilters = false) + static class NotUsingDefaultFilters { + + } + + @GraphQlTest(includeFilters = @ComponentScan.Filter(Repository.class)) + static class WithIncludeFilter { + + } + + @GraphQlTest(excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = Controller1.class)) + static class WithExcludeFilter { + + } + + @Controller + static class Controller1 { + + } + + @Controller + static class Controller2 { + + } + + @Service + static class ExampleService { + + } + + @Repository + static class ExampleRepository { + + } + + static class ExampleRuntimeWiringConfigurer implements RuntimeWiringConfigurer { + + @Override + public void configure(RuntimeWiring.Builder builder) { + + } + + } + + static class ExampleWebInterceptor implements WebInterceptor { + + @Override + public Mono intercept(WebInput webInput, WebInterceptorChain chain) { + return null; + } + + } + + @SuppressWarnings("serial") + static class ExampleModule extends SimpleModule { + + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/test/resources/graphql/schema.graphqls b/spring-boot-project/spring-boot-test-autoconfigure/src/test/resources/graphql/schema.graphqls new file mode 100644 index 0000000000..975b7ca60d --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/resources/graphql/schema.graphqls @@ -0,0 +1,10 @@ +type Query { + bookById(id: ID): Book +} + +type Book { + id: ID + name: String + pageCount: Int + author: String +} \ No newline at end of file From a7839bc9b96a9a6f0b8afff83cd98aee78dba388 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:34:24 +0100 Subject: [PATCH 15/19] Add Metrics support for Spring GraphQL This commit adds the required infrastructure for instrumenting the GraphQL engine and datafetchers in order to collect metrics. With this infrastructure, we can collect metrics such as: * "graphql.request", a timer for GraphQL query * "graphql.datafetcher", a timer for GraphQL datafetcher calls * "graphql.request.datafetch.count", a distribution summary of datafetcher count per query * "graphql.error", an error counter See gh-29140 --- .../build.gradle | 1 + .../SpringApplicationHierarchyTests.java | 5 +- ...intsAutoConfigurationIntegrationTests.java | 13 +- .../spring-boot-actuator/build.gradle | 1 + .../graphql/DefaultGraphQlTagsProvider.java | 77 ++++++++ .../GraphQlMetricsInstrumentation.java | 160 ++++++++++++++++ .../actuate/metrics/graphql/GraphQlTags.java | 102 ++++++++++ .../graphql/GraphQlTagsContributor.java | 45 +++++ .../metrics/graphql/GraphQlTagsProvider.java | 44 +++++ .../actuate/metrics/graphql/package-info.java | 20 ++ .../GraphQlMetricsInstrumentationTests.java | 176 ++++++++++++++++++ .../metrics/graphql/GraphQlTagsTests.java | 95 ++++++++++ 12 files changed, 731 insertions(+), 8 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/DefaultGraphQlTagsProvider.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentation.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTags.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsContributor.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsProvider.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/package-info.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentationTests.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsTests.java diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle index 5b4e527779..1652458515 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle @@ -146,6 +146,7 @@ dependencies { optional("org.springframework.data:spring-data-elasticsearch") { exclude group: "commons-logging", module: "commons-logging" } + optional("org.springframework.graphql:spring-graphql") optional("org.springframework.integration:spring-integration-core") optional("org.springframework.kafka:spring-kafka") optional("org.springframework.security:spring-security-config") diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/SpringApplicationHierarchyTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/SpringApplicationHierarchyTests.java index 5de0389b31..7675208da0 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/SpringApplicationHierarchyTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/SpringApplicationHierarchyTests.java @@ -32,6 +32,7 @@ import org.springframework.boot.autoconfigure.data.neo4j.Neo4jRepositoriesAutoCo import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; import org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration; import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; import org.springframework.boot.autoconfigure.neo4j.Neo4jAutoConfiguration; import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.test.util.ApplicationContextTestUtils; @@ -69,7 +70,7 @@ class SpringApplicationHierarchyTests { @Configuration @EnableAutoConfiguration(exclude = { ElasticsearchDataAutoConfiguration.class, ElasticsearchRepositoriesAutoConfiguration.class, CassandraAutoConfiguration.class, - CassandraDataAutoConfiguration.class, MongoDataAutoConfiguration.class, + CassandraDataAutoConfiguration.class, GraphQlAutoConfiguration.class, MongoDataAutoConfiguration.class, MongoReactiveDataAutoConfiguration.class, Neo4jAutoConfiguration.class, Neo4jDataAutoConfiguration.class, Neo4jRepositoriesAutoConfiguration.class, RedisAutoConfiguration.class, RedisRepositoriesAutoConfiguration.class, FlywayAutoConfiguration.class, MetricsAutoConfiguration.class }) @@ -80,7 +81,7 @@ class SpringApplicationHierarchyTests { @Configuration @EnableAutoConfiguration(exclude = { ElasticsearchDataAutoConfiguration.class, ElasticsearchRepositoriesAutoConfiguration.class, CassandraAutoConfiguration.class, - CassandraDataAutoConfiguration.class, MongoDataAutoConfiguration.class, + CassandraDataAutoConfiguration.class, GraphQlAutoConfiguration.class, MongoDataAutoConfiguration.class, MongoReactiveDataAutoConfiguration.class, Neo4jAutoConfiguration.class, Neo4jDataAutoConfiguration.class, Neo4jRepositoriesAutoConfiguration.class, RedisAutoConfiguration.class, RedisRepositoriesAutoConfiguration.class, FlywayAutoConfiguration.class, MetricsAutoConfiguration.class }) diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java index c25193341f..1c95dff9de 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebEndpointsAutoConfigurationIntegrationTests.java @@ -34,6 +34,7 @@ import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; import org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration; import org.springframework.boot.autoconfigure.data.rest.RepositoryRestMvcAutoConfiguration; import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; import org.springframework.boot.autoconfigure.hazelcast.HazelcastAutoConfiguration; import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration; import org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration; @@ -75,12 +76,12 @@ class WebEndpointsAutoConfigurationIntegrationTests { } @EnableAutoConfiguration(exclude = { FlywayAutoConfiguration.class, LiquibaseAutoConfiguration.class, - CassandraAutoConfiguration.class, CassandraDataAutoConfiguration.class, Neo4jDataAutoConfiguration.class, - Neo4jRepositoriesAutoConfiguration.class, MongoAutoConfiguration.class, MongoDataAutoConfiguration.class, - MongoReactiveAutoConfiguration.class, MongoReactiveDataAutoConfiguration.class, - RepositoryRestMvcAutoConfiguration.class, HazelcastAutoConfiguration.class, - ElasticsearchDataAutoConfiguration.class, SolrAutoConfiguration.class, RedisAutoConfiguration.class, - RedisRepositoriesAutoConfiguration.class, MetricsAutoConfiguration.class }) + CassandraAutoConfiguration.class, CassandraDataAutoConfiguration.class, GraphQlAutoConfiguration.class, + Neo4jDataAutoConfiguration.class, Neo4jRepositoriesAutoConfiguration.class, MongoAutoConfiguration.class, + MongoDataAutoConfiguration.class, MongoReactiveAutoConfiguration.class, + MongoReactiveDataAutoConfiguration.class, RepositoryRestMvcAutoConfiguration.class, + HazelcastAutoConfiguration.class, ElasticsearchDataAutoConfiguration.class, SolrAutoConfiguration.class, + RedisAutoConfiguration.class, RedisRepositoriesAutoConfiguration.class, MetricsAutoConfiguration.class }) @SpringBootConfiguration static class WebEndpointTestApplication { diff --git a/spring-boot-project/spring-boot-actuator/build.gradle b/spring-boot-project/spring-boot-actuator/build.gradle index a9a6a5c9bd..3ef631eb02 100644 --- a/spring-boot-project/spring-boot-actuator/build.gradle +++ b/spring-boot-project/spring-boot-actuator/build.gradle @@ -78,6 +78,7 @@ dependencies { optional("org.springframework.data:spring-data-mongodb") optional("org.springframework.data:spring-data-redis") optional("org.springframework.data:spring-data-rest-webmvc") + optional("org.springframework.graphql:spring-graphql") optional("org.springframework.integration:spring-integration-core") optional("org.springframework.security:spring-security-core") optional("org.springframework.security:spring-security-web") diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/DefaultGraphQlTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/DefaultGraphQlTagsProvider.java new file mode 100644 index 0000000000..656a841c99 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/DefaultGraphQlTagsProvider.java @@ -0,0 +1,77 @@ +/* + * Copyright 2020-2021 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.boot.actuate.metrics.graphql; + +import java.util.Collections; +import java.util.List; + +import graphql.ExecutionResult; +import graphql.GraphQLError; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.DataFetcher; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; + +/** + * Default implementation for {@link GraphQlTagsProvider}. + * + * @author Brian Clozel + * @since 2.7.0 + */ +public class DefaultGraphQlTagsProvider implements GraphQlTagsProvider { + + private final List contributors; + + public DefaultGraphQlTagsProvider(List contributors) { + this.contributors = contributors; + } + + public DefaultGraphQlTagsProvider() { + this(Collections.emptyList()); + } + + @Override + public Iterable getExecutionTags(InstrumentationExecutionParameters parameters, ExecutionResult result, + Throwable exception) { + Tags tags = Tags.of(GraphQlTags.executionOutcome(result, exception)); + for (GraphQlTagsContributor contributor : this.contributors) { + tags = tags.and(contributor.getExecutionTags(parameters, result, exception)); + } + return tags; + } + + @Override + public Iterable getErrorTags(InstrumentationExecutionParameters parameters, GraphQLError error) { + Tags tags = Tags.of(GraphQlTags.errorType(error), GraphQlTags.errorPath(error)); + for (GraphQlTagsContributor contributor : this.contributors) { + tags = tags.and(contributor.getErrorTags(parameters, error)); + } + return tags; + } + + @Override + public Iterable getDataFetchingTags(DataFetcher dataFetcher, InstrumentationFieldFetchParameters parameters, + Throwable exception) { + Tags tags = Tags.of(GraphQlTags.dataFetchingOutcome(exception), GraphQlTags.dataFetchingPath(parameters)); + for (GraphQlTagsContributor contributor : this.contributors) { + tags = tags.and(contributor.getDataFetchingTags(dataFetcher, parameters, exception)); + } + return tags; + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentation.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentation.java new file mode 100644 index 0000000000..893cc2622b --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentation.java @@ -0,0 +1,160 @@ +/* + * Copyright 2020-2021 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.boot.actuate.metrics.graphql; + +import java.util.concurrent.CompletionStage; +import java.util.concurrent.atomic.AtomicLong; + +import graphql.ExecutionResult; +import graphql.execution.instrumentation.InstrumentationContext; +import graphql.execution.instrumentation.InstrumentationState; +import graphql.execution.instrumentation.SimpleInstrumentation; +import graphql.execution.instrumentation.SimpleInstrumentationContext; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.DataFetcher; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Timer; + +import org.springframework.boot.actuate.metrics.AutoTimer; +import org.springframework.lang.Nullable; + +public class GraphQlMetricsInstrumentation extends SimpleInstrumentation { + + private final MeterRegistry registry; + + private final GraphQlTagsProvider tagsProvider; + + private final AutoTimer autoTimer; + + private final DistributionSummary dataFetchingSummary; + + public GraphQlMetricsInstrumentation(MeterRegistry registry, GraphQlTagsProvider tagsProvider, + AutoTimer autoTimer) { + this.registry = registry; + this.tagsProvider = tagsProvider; + this.autoTimer = autoTimer; + this.dataFetchingSummary = DistributionSummary.builder("graphql.request.datafetch.count").baseUnit("calls") + .description("Count of DataFetcher calls per request.").register(this.registry); + } + + @Override + public InstrumentationState createState() { + return new RequestMetricsInstrumentationState(this.autoTimer, this.registry); + } + + @Override + public InstrumentationContext beginExecution(InstrumentationExecutionParameters parameters) { + if (this.autoTimer.isEnabled()) { + RequestMetricsInstrumentationState state = parameters.getInstrumentationState(); + state.startTimer(); + return new SimpleInstrumentationContext() { + @Override + public void onCompleted(ExecutionResult result, Throwable exc) { + Iterable tags = GraphQlMetricsInstrumentation.this.tagsProvider.getExecutionTags(parameters, + result, exc); + state.tags(tags).stopTimer(); + if (!result.getErrors().isEmpty()) { + result.getErrors() + .forEach((error) -> GraphQlMetricsInstrumentation.this.registry.counter("graphql.error", + GraphQlMetricsInstrumentation.this.tagsProvider.getErrorTags(parameters, error)) + .increment()); + } + GraphQlMetricsInstrumentation.this.dataFetchingSummary.record(state.getDataFetchingCount()); + } + }; + } + return super.beginExecution(parameters); + } + + @Override + public DataFetcher instrumentDataFetcher(DataFetcher dataFetcher, + InstrumentationFieldFetchParameters parameters) { + if (this.autoTimer.isEnabled() && !parameters.isTrivialDataFetcher()) { + return (environment) -> { + Timer.Sample sample = Timer.start(this.registry); + try { + Object value = dataFetcher.get(environment); + if (value instanceof CompletionStage) { + CompletionStage completion = (CompletionStage) value; + return completion.whenComplete( + (result, error) -> recordDataFetcherMetric(sample, dataFetcher, parameters, error)); + } + else { + recordDataFetcherMetric(sample, dataFetcher, parameters, null); + return value; + } + } + catch (Throwable throwable) { + recordDataFetcherMetric(sample, dataFetcher, parameters, throwable); + throw throwable; + } + }; + } + return super.instrumentDataFetcher(dataFetcher, parameters); + } + + private void recordDataFetcherMetric(Timer.Sample sample, DataFetcher dataFetcher, + InstrumentationFieldFetchParameters parameters, @Nullable Throwable throwable) { + Timer.Builder timer = this.autoTimer.builder("graphql.datafetcher"); + timer.tags(this.tagsProvider.getDataFetchingTags(dataFetcher, parameters, throwable)); + sample.stop(timer.register(this.registry)); + RequestMetricsInstrumentationState state = parameters.getInstrumentationState(); + state.incrementDataFetchingCount(); + } + + static class RequestMetricsInstrumentationState implements InstrumentationState { + + private final MeterRegistry registry; + + private final Timer.Builder timer; + + private Timer.Sample sample; + + private AtomicLong dataFetchingCount = new AtomicLong(0L); + + RequestMetricsInstrumentationState(AutoTimer autoTimer, MeterRegistry registry) { + this.timer = autoTimer.builder("graphql.request"); + this.registry = registry; + } + + RequestMetricsInstrumentationState tags(Iterable tags) { + this.timer.tags(tags); + return this; + } + + void startTimer() { + this.sample = Timer.start(this.registry); + } + + void stopTimer() { + this.sample.stop(this.timer.register(this.registry)); + } + + void incrementDataFetchingCount() { + this.dataFetchingCount.incrementAndGet(); + } + + long getDataFetchingCount() { + return this.dataFetchingCount.get(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTags.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTags.java new file mode 100644 index 0000000000..ded7572502 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTags.java @@ -0,0 +1,102 @@ +/* + * Copyright 2020-2021 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.boot.actuate.metrics.graphql; + +import java.util.List; + +import graphql.ErrorClassification; +import graphql.ErrorType; +import graphql.ExecutionResult; +import graphql.GraphQLError; +import graphql.execution.ExecutionStepInfo; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.GraphQLObjectType; +import io.micrometer.core.instrument.Tag; + +import org.springframework.lang.Nullable; +import org.springframework.util.CollectionUtils; + +/** + * Factory methods for Tags associated with a GraphQL request. + * + * @author Brian Clozel + * @since 1.0.0 + */ +public final class GraphQlTags { + + private static final Tag OUTCOME_SUCCESS = Tag.of("outcome", "SUCCESS"); + + private static final Tag OUTCOME_ERROR = Tag.of("outcome", "ERROR"); + + private static final Tag UNKNOWN_ERRORTYPE = Tag.of("errorType", "UNKNOWN"); + + private GraphQlTags() { + + } + + public static Tag executionOutcome(ExecutionResult result, @Nullable Throwable exception) { + if (exception == null && result.getErrors().isEmpty()) { + return OUTCOME_SUCCESS; + } + else { + return OUTCOME_ERROR; + } + } + + public static Tag errorType(GraphQLError error) { + ErrorClassification errorType = error.getErrorType(); + if (errorType instanceof ErrorType) { + return Tag.of("errorType", ((ErrorType) errorType).name()); + } + return UNKNOWN_ERRORTYPE; + } + + public static Tag errorPath(GraphQLError error) { + StringBuilder builder = new StringBuilder(); + List pathSegments = error.getPath(); + if (!CollectionUtils.isEmpty(pathSegments)) { + builder.append('$'); + for (Object segment : pathSegments) { + try { + int index = Integer.parseUnsignedInt(segment.toString()); + builder.append("[*]"); + } + catch (NumberFormatException exc) { + builder.append('.'); + builder.append(segment); + } + } + } + return Tag.of("errorPath", builder.toString()); + } + + public static Tag dataFetchingOutcome(@Nullable Throwable exception) { + return (exception != null) ? OUTCOME_ERROR : OUTCOME_SUCCESS; + } + + public static Tag dataFetchingPath(InstrumentationFieldFetchParameters parameters) { + ExecutionStepInfo executionStepInfo = parameters.getExecutionStepInfo(); + StringBuilder dataFetchingType = new StringBuilder(); + if (executionStepInfo.hasParent() && executionStepInfo.getParent().getType() instanceof GraphQLObjectType) { + dataFetchingType.append(((GraphQLObjectType) executionStepInfo.getParent().getType()).getName()); + dataFetchingType.append('.'); + } + dataFetchingType.append(executionStepInfo.getPath().getSegmentName()); + return Tag.of("path", dataFetchingType.toString()); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsContributor.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsContributor.java new file mode 100644 index 0000000000..99806736d8 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsContributor.java @@ -0,0 +1,45 @@ +/* + * Copyright 2020-2021 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.boot.actuate.metrics.graphql; + +import graphql.ExecutionResult; +import graphql.GraphQLError; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.DataFetcher; +import io.micrometer.core.instrument.Tag; + +import org.springframework.lang.Nullable; + +/** + * A contributor of {@link Tag Tags} for Spring GraphQL-based request handling. Typically, + * used by a {@link GraphQlTagsProvider} to provide tags in addition to its defaults. + * + * @author Brian Clozel + * @since 2.7.0 + */ +public interface GraphQlTagsContributor { + + Iterable getExecutionTags(InstrumentationExecutionParameters parameters, ExecutionResult result, + @Nullable Throwable exception); + + Iterable getErrorTags(InstrumentationExecutionParameters parameters, GraphQLError error); + + Iterable getDataFetchingTags(DataFetcher dataFetcher, InstrumentationFieldFetchParameters parameters, + @Nullable Throwable exception); + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsProvider.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsProvider.java new file mode 100644 index 0000000000..13ba9555b6 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsProvider.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020-2021 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.boot.actuate.metrics.graphql; + +import graphql.ExecutionResult; +import graphql.GraphQLError; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.DataFetcher; +import io.micrometer.core.instrument.Tag; + +import org.springframework.lang.Nullable; + +/** + * Provides {@link Tag Tags} for Spring GraphQL-based request handling. + * + * @author Brian Clozel + * @since 2.7.0 + */ +public interface GraphQlTagsProvider { + + Iterable getExecutionTags(InstrumentationExecutionParameters parameters, ExecutionResult result, + @Nullable Throwable exception); + + Iterable getErrorTags(InstrumentationExecutionParameters parameters, GraphQLError error); + + Iterable getDataFetchingTags(DataFetcher dataFetcher, InstrumentationFieldFetchParameters parameters, + @Nullable Throwable exception); + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/package-info.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/package-info.java new file mode 100644 index 0000000000..03a0361170 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020-2021 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. + */ + +/** + * Provides instrumentation support for Spring GraphQL. + */ +package org.springframework.boot.actuate.metrics.graphql; diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentationTests.java new file mode 100644 index 0000000000..7d5f6e63e3 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentationTests.java @@ -0,0 +1,176 @@ +/* + * Copyright 2012-2021 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.boot.actuate.metrics.graphql; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import graphql.ExecutionResultImpl; +import graphql.execution.instrumentation.InstrumentationContext; +import graphql.execution.instrumentation.InstrumentationState; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.DataFetchingEnvironmentImpl; +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.SchemaGenerator; +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.MockClock; +import io.micrometer.core.instrument.Timer; +import io.micrometer.core.instrument.simple.SimpleConfig; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.metrics.AutoTimer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link GraphQlMetricsInstrumentation}. + * + * @author Brian Clozel + */ +class GraphQlMetricsInstrumentationTests { + + private final ExecutionInput input = ExecutionInput.newExecutionInput("{greeting}").build(); + + private final GraphQLSchema schema = SchemaGenerator.createdMockedSchema("type Query { greeting: String }"); + + private MeterRegistry registry; + + private GraphQlMetricsInstrumentation instrumentation; + + private InstrumentationState state; + + private InstrumentationExecutionParameters parameters; + + @BeforeEach + void setup() { + this.registry = new SimpleMeterRegistry(SimpleConfig.DEFAULT, new MockClock()); + this.instrumentation = new GraphQlMetricsInstrumentation(this.registry, mock(GraphQlTagsProvider.class), + AutoTimer.ENABLED); + this.state = this.instrumentation.createState(); + this.parameters = new InstrumentationExecutionParameters(this.input, this.schema, this.state); + } + + @Test + void shouldRecordTimerWhenResult() { + InstrumentationContext execution = this.instrumentation.beginExecution(this.parameters); + ExecutionResult result = new ExecutionResultImpl("Hello", null); + execution.onCompleted(result, null); + + Timer timer = this.registry.find("graphql.request").timer(); + assertThat(timer).isNotNull(); + assertThat(timer.takeSnapshot().count()).isEqualTo(1); + } + + @Test + void shouldRecordDataFetchingCount() throws Exception { + InstrumentationContext execution = this.instrumentation.beginExecution(this.parameters); + ExecutionResult result = new ExecutionResultImpl("Hello", null); + + DataFetcher dataFetcher = mock(DataFetcher.class); + given(dataFetcher.get(any())).willReturn("Hello"); + InstrumentationFieldFetchParameters fieldFetchParameters = mockFieldFetchParameters(false); + + DataFetcher instrumented = this.instrumentation.instrumentDataFetcher(dataFetcher, fieldFetchParameters); + DataFetchingEnvironment environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment().build(); + instrumented.get(environment); + + execution.onCompleted(result, null); + + DistributionSummary summary = this.registry.find("graphql.request.datafetch.count").summary(); + assertThat(summary).isNotNull(); + assertThat(summary.count()).isEqualTo(1); + } + + @Test + void shouldRecordDataFetchingMetricWhenSuccess() throws Exception { + DataFetcher dataFetcher = mock(DataFetcher.class); + given(dataFetcher.get(any())).willReturn("Hello"); + InstrumentationFieldFetchParameters fieldFetchParameters = mockFieldFetchParameters(false); + + DataFetcher instrumented = this.instrumentation.instrumentDataFetcher(dataFetcher, fieldFetchParameters); + DataFetchingEnvironment environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment().build(); + instrumented.get(environment); + + Timer timer = this.registry.find("graphql.datafetcher").timer(); + assertThat(timer).isNotNull(); + assertThat(timer.takeSnapshot().count()).isEqualTo(1); + } + + @Test + void shouldRecordDataFetchingMetricWhenSuccessCompletionStage() throws Exception { + DataFetcher> dataFetcher = mock(DataFetcher.class); + given(dataFetcher.get(any())).willReturn(CompletableFuture.completedFuture("Hello")); + InstrumentationFieldFetchParameters fieldFetchParameters = mockFieldFetchParameters(false); + + DataFetcher instrumented = this.instrumentation.instrumentDataFetcher(dataFetcher, fieldFetchParameters); + DataFetchingEnvironment environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment().build(); + instrumented.get(environment); + + Timer timer = this.registry.find("graphql.datafetcher").timer(); + assertThat(timer).isNotNull(); + assertThat(timer.takeSnapshot().count()).isEqualTo(1); + } + + @Test + void shouldRecordDataFetchingMetricWhenError() throws Exception { + DataFetcher> dataFetcher = mock(DataFetcher.class); + given(dataFetcher.get(any())).willThrow(new IllegalStateException("test")); + InstrumentationFieldFetchParameters fieldFetchParameters = mockFieldFetchParameters(false); + + DataFetcher instrumented = this.instrumentation.instrumentDataFetcher(dataFetcher, fieldFetchParameters); + DataFetchingEnvironment environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment().build(); + assertThatThrownBy(() -> instrumented.get(environment)).isInstanceOf(IllegalStateException.class); + + Timer timer = this.registry.find("graphql.datafetcher").timer(); + assertThat(timer).isNotNull(); + assertThat(timer.takeSnapshot().count()).isEqualTo(1); + } + + @Test + void shouldNotRecordDataFetchingMetricWhenTrivial() throws Exception { + DataFetcher dataFetcher = mock(DataFetcher.class); + given(dataFetcher.get(any())).willReturn("Hello"); + InstrumentationFieldFetchParameters fieldFetchParameters = mockFieldFetchParameters(true); + + DataFetcher instrumented = this.instrumentation.instrumentDataFetcher(dataFetcher, fieldFetchParameters); + DataFetchingEnvironment environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment().build(); + instrumented.get(environment); + + Timer timer = this.registry.find("graphql.datafetcher").timer(); + assertThat(timer).isNull(); + } + + private InstrumentationFieldFetchParameters mockFieldFetchParameters(boolean isTrivial) { + InstrumentationFieldFetchParameters fieldFetchParameters = mock(InstrumentationFieldFetchParameters.class); + given(fieldFetchParameters.isTrivialDataFetcher()).willReturn(isTrivial); + given(fieldFetchParameters.getInstrumentationState()).willReturn(this.state); + return fieldFetchParameters; + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsTests.java new file mode 100644 index 0000000000..84f1283e38 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsTests.java @@ -0,0 +1,95 @@ +/* + * Copyright 2012-2021 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.boot.actuate.metrics.graphql; + +import java.util.Arrays; + +import graphql.ErrorType; +import graphql.ExecutionResult; +import graphql.ExecutionResultImpl; +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; +import io.micrometer.core.instrument.Tag; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GraphQlTags}. + * + * @author Brian Clozel + */ +class GraphQlTagsTests { + + @Test + void executionOutcomeShouldSucceed() { + ExecutionResult result = ExecutionResultImpl.newExecutionResult().build(); + Tag outcomeTag = GraphQlTags.executionOutcome(result, null); + assertThat(outcomeTag.getValue()).isEqualTo("SUCCESS"); + } + + @Test + void executionOutcomeShouldErrorWhenExceptionThrown() { + ExecutionResult result = ExecutionResultImpl.newExecutionResult().build(); + Tag tag = GraphQlTags.executionOutcome(result, new IllegalArgumentException("test error")); + assertThat(tag.getValue()).isEqualTo("ERROR"); + } + + @Test + void executionOutcomeShouldErrorWhenResponseErrors() { + GraphQLError error = GraphqlErrorBuilder.newError().message("Invalid query").build(); + Tag tag = GraphQlTags.executionOutcome(ExecutionResultImpl.newExecutionResult().addError(error).build(), null); + assertThat(tag.getValue()).isEqualTo("ERROR"); + } + + @Test + void errorTypeShouldBeDefinedIfPresent() { + GraphQLError error = GraphqlErrorBuilder.newError().errorType(ErrorType.DataFetchingException) + .message("test error").build(); + Tag errorTypeTag = GraphQlTags.errorType(error); + assertThat(errorTypeTag.getValue()).isEqualTo("DataFetchingException"); + } + + @Test + void errorPathShouldUseJsonPathFormat() { + GraphQLError error = GraphqlErrorBuilder.newError().path(Arrays.asList("project", "name")).message("test error") + .build(); + Tag errorPathTag = GraphQlTags.errorPath(error); + assertThat(errorPathTag.getValue()).isEqualTo("$.project.name"); + } + + @Test + void errorPathShouldUseJsonPathFormatForIndices() { + GraphQLError error = GraphqlErrorBuilder.newError().path(Arrays.asList("issues", "42", "title")) + .message("test error").build(); + Tag errorPathTag = GraphQlTags.errorPath(error); + assertThat(errorPathTag.getValue()).isEqualTo("$.issues[*].title"); + } + + @Test + void dataFetchingOutcomeShouldBeSuccessfulIfNoException() { + Tag fetchingOutcomeTag = GraphQlTags.dataFetchingOutcome(null); + assertThat(fetchingOutcomeTag.getValue()).isEqualTo("SUCCESS"); + } + + @Test + void dataFetchingOutcomeShouldBeErrorIfException() { + Tag fetchingOutcomeTag = GraphQlTags.dataFetchingOutcome(new IllegalStateException("error state")); + assertThat(fetchingOutcomeTag.getValue()).isEqualTo("ERROR"); + } + +} From 8e29eac8873fbb0ea843ed3bea0b1b852be22ca0 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:34:32 +0100 Subject: [PATCH 16/19] Auto-configure GraphQL metrics This commit auto-configures the metrics infrastructure for Spring GraphQL. See gh-29140 --- .../metrics/MetricsProperties.java | 20 ++++ .../GraphQlMetricsAutoConfiguration.java | 70 +++++++++++++ .../main/resources/META-INF/spring.factories | 1 + .../GraphQlMetricsAutoConfigurationTests.java | 98 +++++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/graphql/GraphQlMetricsAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/graphql/GraphQlMetricsAutoConfigurationTests.java diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsProperties.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsProperties.java index 5ff5ca08b4..5bc5625ee7 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsProperties.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/MetricsProperties.java @@ -62,6 +62,8 @@ public class MetricsProperties { private final Data data = new Data(); + private final Graphql graphql = new Graphql(); + private final System system = new System(); private final Distribution distribution = new Distribution(); @@ -90,6 +92,10 @@ public class MetricsProperties { return this.data; } + public Graphql getGraphql() { + return this.graphql; + } + public System getSystem() { return this.system; } @@ -268,6 +274,20 @@ public class MetricsProperties { } + public static class Graphql { + + /** + * Auto-timed queries settings. + */ + @NestedConfigurationProperty + private final AutoTimeProperties autotime = new AutoTimeProperties(); + + public AutoTimeProperties getAutotime() { + return this.autotime; + } + + } + public static class System { private final Diskspace diskspace = new Diskspace(); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/graphql/GraphQlMetricsAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/graphql/GraphQlMetricsAutoConfiguration.java new file mode 100644 index 0000000000..c9ad464b6f --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/graphql/GraphQlMetricsAutoConfiguration.java @@ -0,0 +1,70 @@ +/* + * Copyright 2020-2021 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.boot.actuate.autoconfigure.metrics.graphql; + +import java.util.stream.Collectors; + +import graphql.GraphQL; +import io.micrometer.core.instrument.MeterRegistry; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties; +import org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration; +import org.springframework.boot.actuate.metrics.graphql.DefaultGraphQlTagsProvider; +import org.springframework.boot.actuate.metrics.graphql.GraphQlMetricsInstrumentation; +import org.springframework.boot.actuate.metrics.graphql.GraphQlTagsContributor; +import org.springframework.boot.actuate.metrics.graphql.GraphQlTagsProvider; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.execution.GraphQlSource; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for instrumentation of Spring + * GraphQL endpoints. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@Configuration(proxyBeanMethods = false) +@AutoConfigureAfter({ MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class, + SimpleMetricsExportAutoConfiguration.class }) +@ConditionalOnBean(MeterRegistry.class) +@ConditionalOnClass({ GraphQL.class, GraphQlSource.class }) +@EnableConfigurationProperties(MetricsProperties.class) +public class GraphQlMetricsAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(GraphQlTagsProvider.class) + public DefaultGraphQlTagsProvider graphQlTagsProvider(ObjectProvider contributors) { + return new DefaultGraphQlTagsProvider(contributors.orderedStream().collect(Collectors.toList())); + } + + @Bean + public GraphQlMetricsInstrumentation graphQlMetricsInstrumentation(MeterRegistry meterRegistry, + GraphQlTagsProvider tagsProvider, MetricsProperties properties) { + return new GraphQlMetricsInstrumentation(meterRegistry, tagsProvider, properties.getGraphql().getAutotime()); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories index dd6eeb9497..a5027fee19 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories @@ -68,6 +68,7 @@ org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetri org.springframework.boot.actuate.autoconfigure.metrics.export.stackdriver.StackdriverMetricsExportAutoConfiguration,\ org.springframework.boot.actuate.autoconfigure.metrics.export.statsd.StatsdMetricsExportAutoConfiguration,\ org.springframework.boot.actuate.autoconfigure.metrics.export.wavefront.WavefrontMetricsExportAutoConfiguration,\ +org.springframework.boot.actuate.autoconfigure.metrics.graphql.GraphQlMetricsAutoConfiguration,\ org.springframework.boot.actuate.autoconfigure.metrics.integration.IntegrationMetricsAutoConfiguration,\ org.springframework.boot.actuate.autoconfigure.metrics.jdbc.DataSourcePoolMetricsAutoConfiguration,\ org.springframework.boot.actuate.autoconfigure.metrics.jersey.JerseyServerMetricsAutoConfiguration,\ diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/graphql/GraphQlMetricsAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/graphql/GraphQlMetricsAutoConfigurationTests.java new file mode 100644 index 0000000000..5c7c502dbd --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/metrics/graphql/GraphQlMetricsAutoConfigurationTests.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-2021 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.boot.actuate.autoconfigure.metrics.graphql; + +import graphql.ExecutionResult; +import graphql.GraphQLError; +import graphql.execution.instrumentation.parameters.InstrumentationExecutionParameters; +import graphql.execution.instrumentation.parameters.InstrumentationFieldFetchParameters; +import graphql.schema.DataFetcher; +import io.micrometer.core.instrument.Tag; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.autoconfigure.metrics.test.MetricsRun; +import org.springframework.boot.actuate.metrics.graphql.DefaultGraphQlTagsProvider; +import org.springframework.boot.actuate.metrics.graphql.GraphQlMetricsInstrumentation; +import org.springframework.boot.actuate.metrics.graphql.GraphQlTagsProvider; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GraphQlMetricsAutoConfiguration}. + * + * @author Brian Clozel + */ +class GraphQlMetricsAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().with(MetricsRun.simple()) + .withConfiguration(AutoConfigurations.of(GraphQlMetricsAutoConfiguration.class)); + + @Test + void backsOffWhenMeterRegistryIsMissing() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(GraphQlMetricsAutoConfiguration.class)) + .run((context) -> assertThat(context).doesNotHaveBean(DefaultGraphQlTagsProvider.class) + .doesNotHaveBean(GraphQlMetricsInstrumentation.class)); + } + + @Test + void definesTagsProviderAndInstrumentationWhenMeterRegistryIsPresent() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(DefaultGraphQlTagsProvider.class) + .hasSingleBean(GraphQlMetricsInstrumentation.class)); + } + + @Test + void tagsProviderBacksOffIfAlreadyPresent() { + this.contextRunner.withUserConfiguration(TagsProviderConfiguration.class).run((context) -> assertThat(context) + .doesNotHaveBean(DefaultGraphQlTagsProvider.class).hasSingleBean(TestGraphQlTagsProvider.class)); + } + + @Configuration(proxyBeanMethods = false) + static class TagsProviderConfiguration { + + @Bean + TestGraphQlTagsProvider tagsProvider() { + return new TestGraphQlTagsProvider(); + } + + } + + static class TestGraphQlTagsProvider implements GraphQlTagsProvider { + + @Override + public Iterable getExecutionTags(InstrumentationExecutionParameters parameters, ExecutionResult result, + Throwable exception) { + return null; + } + + @Override + public Iterable getErrorTags(InstrumentationExecutionParameters parameters, GraphQLError error) { + return null; + } + + @Override + public Iterable getDataFetchingTags(DataFetcher dataFetcher, + InstrumentationFieldFetchParameters parameters, Throwable exception) { + return null; + } + + } + +} From d5603f2c9821f83991cba07b306320a01fcee629 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:34:40 +0100 Subject: [PATCH 17/19] Add GraphQL Spring Boot starter This new starter brings the following dependencies: * the base starter * the JSON starter * the Spring GraphQL dependency See gh-29140 --- .../spring-boot-dependencies/build.gradle | 1 + .../spring-boot-starter-graphql/build.gradle | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 spring-boot-project/spring-boot-starters/spring-boot-starter-graphql/build.gradle diff --git a/spring-boot-project/spring-boot-dependencies/build.gradle b/spring-boot-project/spring-boot-dependencies/build.gradle index 74d24d1b6a..2924769343 100644 --- a/spring-boot-project/spring-boot-dependencies/build.gradle +++ b/spring-boot-project/spring-boot-dependencies/build.gradle @@ -1501,6 +1501,7 @@ bom { "spring-boot-starter-data-neo4j", "spring-boot-starter-data-rest", "spring-boot-starter-freemarker", + "spring-boot-starter-graphql", "spring-boot-starter-groovy-templates", "spring-boot-starter-hateoas", "spring-boot-starter-integration", diff --git a/spring-boot-project/spring-boot-starters/spring-boot-starter-graphql/build.gradle b/spring-boot-project/spring-boot-starters/spring-boot-starter-graphql/build.gradle new file mode 100644 index 0000000000..1099bc51fa --- /dev/null +++ b/spring-boot-project/spring-boot-starters/spring-boot-starter-graphql/build.gradle @@ -0,0 +1,11 @@ +plugins { + id "org.springframework.boot.starter" +} + +description = "Starter for building GraphQL applications with Spring GraphQL" + +dependencies { + api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter")) + api(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-json")) + api("org.springframework.graphql:spring-graphql") +} From c522a8007b3e7b090c384cc7132e9d54504c714e Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:34:49 +0100 Subject: [PATCH 18/19] Add smoke test for Spring GraphQL See gh-29140 --- .../build.gradle | 16 +++++ .../smoketest/graphql/GreetingController.java | 37 +++++++++++ .../smoketest/graphql/GreetingService.java | 30 +++++++++ .../main/java/smoketest/graphql/Project.java | 65 +++++++++++++++++++ .../smoketest/graphql/ProjectsController.java | 42 ++++++++++++ .../graphql/SampleGraphQlApplication.java | 29 +++++++++ .../smoketest/graphql/SecurityConfig.java | 52 +++++++++++++++ .../main/resources/graphql/greeting.graphql | 3 + .../main/resources/graphql/schema.graphqls | 12 ++++ .../graphql/GreetingControllerTests.java | 57 ++++++++++++++++ .../graphql/ProjectControllerTests.java | 43 ++++++++++++ 11 files changed, 386 insertions(+) create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/build.gradle create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/GreetingController.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/GreetingService.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/Project.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/ProjectsController.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/SampleGraphQlApplication.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/SecurityConfig.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/resources/graphql/greeting.graphql create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/resources/graphql/schema.graphqls create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/test/java/smoketest/graphql/GreetingControllerTests.java create mode 100644 spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/test/java/smoketest/graphql/ProjectControllerTests.java diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/build.gradle new file mode 100644 index 0000000000..06affd54ba --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/build.gradle @@ -0,0 +1,16 @@ +plugins { + id "java" + id "org.springframework.boot.conventions" +} + +description = "Spring Boot GraphQL smoke test" + +dependencies { + implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-graphql")) + implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web")) + implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-security")) + + testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test")) + testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-webflux")) + testImplementation('org.springframework.graphql:spring-graphql-test') +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/GreetingController.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/GreetingController.java new file mode 100644 index 0000000000..18c5c62d8d --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/GreetingController.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2021 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 smoketest.graphql; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +@Controller +public class GreetingController { + + private final GreetingService greetingService; + + public GreetingController(GreetingService greetingService) { + this.greetingService = greetingService; + } + + @QueryMapping + public String greeting(@Argument String name) { + return this.greetingService.greet(name); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/GreetingService.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/GreetingService.java new file mode 100644 index 0000000000..aa9c5bee02 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/GreetingService.java @@ -0,0 +1,30 @@ +/* + * Copyright 2012-2021 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 smoketest.graphql; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Component; + +@Component +public class GreetingService { + + @PreAuthorize("hasRole('ADMIN')") + public String greet(String name) { + return "Hello, " + name + "!"; + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/Project.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/Project.java new file mode 100644 index 0000000000..5e8d514de6 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/Project.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-2021 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 smoketest.graphql; + +import java.util.Objects; + +public class Project { + + private String slug; + + private String name; + + public Project(String slug, String name) { + this.slug = slug; + this.name = name; + } + + public String getSlug() { + return this.slug; + } + + public void setSlug(String slug) { + this.slug = slug; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Project project = (Project) o; + return this.slug.equals(project.slug); + } + + @Override + public int hashCode() { + return Objects.hash(this.slug); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/ProjectsController.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/ProjectsController.java new file mode 100644 index 0000000000..e2fd01dae7 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/ProjectsController.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2021 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 smoketest.graphql; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +@Controller +public class ProjectsController { + + private final List projects; + + public ProjectsController() { + this.projects = Arrays.asList(new Project("spring-boot", "Spring Boot"), + new Project("spring-graphql", "Spring GraphQL"), new Project("spring-framework", "Spring Framework")); + } + + @QueryMapping + public Optional project(@Argument String slug) { + return this.projects.stream().filter((project) -> project.getSlug().equals(slug)).findFirst(); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/SampleGraphQlApplication.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/SampleGraphQlApplication.java new file mode 100644 index 0000000000..9674af544a --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/SampleGraphQlApplication.java @@ -0,0 +1,29 @@ +/* + * Copyright 2012-2021 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 smoketest.graphql; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleGraphQlApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleGraphQlApplication.class, args); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/SecurityConfig.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/SecurityConfig.java new file mode 100644 index 0000000000..cfe1e0a203 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/SecurityConfig.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2021 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 smoketest.graphql; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.DefaultSecurityFilterChain; + +import static org.springframework.security.config.Customizer.withDefaults; + +@Configuration +@EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + + @Bean + DefaultSecurityFilterChain springWebFilterChain(HttpSecurity http) throws Exception { + return http.csrf((csrf) -> csrf.disable()) + // Demonstrate that method security works + // Best practice to use both for defense in depth + .authorizeRequests((requests) -> requests.anyRequest().permitAll()).httpBasic(withDefaults()).build(); + } + + @Bean + public static InMemoryUserDetailsManager userDetailsService() { + User.UserBuilder userBuilder = User.withDefaultPasswordEncoder(); + UserDetails rob = userBuilder.username("rob").password("rob").roles("USER").build(); + UserDetails admin = userBuilder.username("admin").password("admin").roles("USER", "ADMIN").build(); + return new InMemoryUserDetailsManager(rob, admin); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/resources/graphql/greeting.graphql b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/resources/graphql/greeting.graphql new file mode 100644 index 0000000000..1521607a7b --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/resources/graphql/greeting.graphql @@ -0,0 +1,3 @@ +query greeting($name: String!) { + greeting(name: $name) +} \ No newline at end of file diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/resources/graphql/schema.graphqls b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/resources/graphql/schema.graphqls new file mode 100644 index 0000000000..b9c9d0e729 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,12 @@ +type Query { + greeting(name: String! = "Spring"): String! + project(slug: ID!): Project +} + +""" A Project in the Spring portfolio """ +type Project { + """ Unique string id used in URLs """ + slug: ID! + """ Project name """ + name: String! +} \ No newline at end of file diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/test/java/smoketest/graphql/GreetingControllerTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/test/java/smoketest/graphql/GreetingControllerTests.java new file mode 100644 index 0000000000..75c9565c68 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/test/java/smoketest/graphql/GreetingControllerTests.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-2021 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 smoketest.graphql; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureWebGraphQlTester; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.graphql.execution.ErrorType; +import org.springframework.graphql.test.tester.WebGraphQlTester; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@AutoConfigureWebGraphQlTester +class GreetingControllerTests { + + @Autowired + private WebGraphQlTester graphQlTester; + + @Test + void shouldUnauthorizeAnonymousUsers() { + this.graphQlTester.queryName("greeting").variable("name", "Brian").execute().errors().satisfy((errors) -> { + assertThat(errors).hasSize(1); + assertThat(errors.get(0).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + }); + } + + @Test + void shouldGreetWithSpecificName() { + this.graphQlTester.queryName("greeting").variable("name", "Brian") + .httpHeaders((headers) -> headers.setBasicAuth("admin", "admin")).execute().path("greeting") + .entity(String.class).isEqualTo("Hello, Brian!"); + } + + @Test + void shouldGreetWithDefaultName() { + this.graphQlTester.query("{ greeting }").httpHeaders((headers) -> headers.setBasicAuth("admin", "admin")) + .execute().path("greeting").entity(String.class).isEqualTo("Hello, Spring!"); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/test/java/smoketest/graphql/ProjectControllerTests.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/test/java/smoketest/graphql/ProjectControllerTests.java new file mode 100644 index 0000000000..89d3e218a1 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/test/java/smoketest/graphql/ProjectControllerTests.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2021 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 smoketest.graphql; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.graphql.GraphQlTest; +import org.springframework.graphql.test.tester.GraphQlTester; + +@GraphQlTest(ProjectsController.class) +class ProjectControllerTests { + + @Autowired + private GraphQlTester graphQlTester; + + @Test + void shouldFindSpringGraphQl() { + this.graphQlTester.query("{ project(slug: \"spring-graphql\") { name } }").execute().path("project.name") + .entity(String.class).isEqualTo("Spring GraphQL"); + } + + @Test + void shouldNotFindUnknownProject() { + this.graphQlTester.query("{ project(slug: \"spring-unknown\") { name } }").execute().path("project.name") + .valueDoesNotExist(); + } + +} From 22706057f06967ff93714c1dc23a78f21d051898 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:34:56 +0100 Subject: [PATCH 19/19] Document Spring GraphQL support This commit documents all the features added in the previous commits: from the main infrastructure support, to testing and metrics. See gh-29140 --- .../spring-boot-docs/build.gradle | 14 +- .../src/docs/asciidoc/actuator/metrics.adoc | 52 +++++++ .../src/docs/asciidoc/attributes.adoc | 4 + .../src/docs/asciidoc/features/testing.adoc | 56 ++++++++ .../src/docs/asciidoc/index.adoc | 2 +- .../src/docs/asciidoc/web.adoc | 2 + .../src/docs/asciidoc/web/spring-graphql.adoc | 135 ++++++++++++++++++ .../GreetingControllerTests.java | 44 ++++++ .../docs/web/graphql/GreetingController.java | 31 ++++ .../main/resources/graphql/schema.graphqls | 29 ++++ 10 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc create mode 100644 spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/springgraphqltests/GreetingControllerTests.java create mode 100644 spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/graphql/GreetingController.java create mode 100644 spring-boot-project/spring-boot-docs/src/main/resources/graphql/schema.graphqls diff --git a/spring-boot-project/spring-boot-docs/build.gradle b/spring-boot-project/spring-boot-docs/build.gradle index 4aa778fc41..2c97dd3bb9 100644 --- a/spring-boot-project/spring-boot-docs/build.gradle +++ b/spring-boot-project/spring-boot-docs/build.gradle @@ -137,6 +137,8 @@ dependencies { implementation("org.springframework.data:spring-data-neo4j") implementation("org.springframework.data:spring-data-redis") implementation("org.springframework.data:spring-data-r2dbc") + implementation("org.springframework.graphql:spring-graphql") + implementation("org.springframework.graphql:spring-graphql-test") implementation("org.springframework.kafka:spring-kafka") implementation("org.springframework.kafka:spring-kafka-test") implementation("org.springframework.restdocs:spring-restdocs-mockmvc") { @@ -277,8 +279,9 @@ tasks.withType(org.asciidoctor.gradle.jvm.AbstractAsciidoctorTask) { "spring-data-r2dbc-version": versionConstraints["org.springframework.data:spring-data-r2dbc"], "spring-data-rest-version": versionConstraints["org.springframework.data:spring-data-rest-core"], "spring-framework-version": versionConstraints["org.springframework:spring-core"], - "spring-kafka-version": versionConstraints["org.springframework.kafka:spring-kafka"], + "spring-graphql-version": versionConstraints["org.springframework.graphql:spring-graphql"], "spring-integration-version": versionConstraints["org.springframework.integration:spring-integration-core"], + "spring-kafka-version": versionConstraints["org.springframework.kafka:spring-kafka"], "spring-security-version": securityVersion, "spring-webservices-version": versionConstraints["org.springframework.ws:spring-ws-core"] } @@ -321,6 +324,9 @@ syncDocumentationSourceForAsciidoctor { from("src/main/groovy") { into "main/groovy" } + from("src/main/resources") { + into "main/resources" + } } syncDocumentationSourceForAsciidoctorMultipage { @@ -342,6 +348,9 @@ syncDocumentationSourceForAsciidoctorMultipage { from("src/main/groovy") { into "main/groovy" } + from("src/main/resources") { + into "main/resources" + } } syncDocumentationSourceForAsciidoctorPdf { @@ -363,6 +372,9 @@ syncDocumentationSourceForAsciidoctorPdf { from("src/main/groovy") { into "main/groovy" } + from("src/main/resources") { + into "main/resources" + } } task zip(type: Zip) { diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/metrics.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/metrics.adoc index da953e32d7..b33232633c 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/metrics.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/actuator/metrics.adoc @@ -873,6 +873,58 @@ A `CacheMetricsRegistrar` bean is made available to make that process easier. +[[actuator.metrics.supported.spring-graphql]] +==== Spring GraphQL Metrics +Auto-configuration enables the instrumentation of GraphQL queries, for any supported transport. + +Spring Boot records a `graphql.request` timer with: + +[cols="1,2,2"] +|=== +|Tag | Description| Sample values + +|outcome +|Request outcome +|"SUCCESS", "ERROR" +|=== + +A single GraphQL query can involve many `DataFetcher` calls, so there is a dedicated `graphql.datafetcher` timer: + +[cols="1,2,2"] +|=== +|Tag | Description| Sample values + +|path +|data fetcher path +|"Query.project" + +|outcome +|data fetching outcome +|"SUCCESS", "ERROR" +|=== + + +The `graphql.request.datafetch.count` https://micrometer.io/docs/concepts#_distribution_summaries[distribution summary] counts the number of non-trivial `DataFetcher` calls made per request. +This metric is useful for detecting "N+1" data fetching issues and consider batch loading; it provides the `"TOTAL"` number of data fetcher calls made over the `"COUNT"` of recorded requests, as well as the `"MAX"` calls made for a single request over the considered period. +More options are available for <>. + +A single response can contain many GraphQL errors, counted by the `graphql.error` counter: + +[cols="1,2,2"] +|=== +|Tag | Description| Sample values + +|errorType +|error type +|"DataFetchingException" + +|errorPath +|error JSON Path +|"$.project" +|=== + + + [[actuator.metrics.supported.jdbc]] ==== DataSource Metrics Auto-configuration enables the instrumentation of all available `DataSource` objects with metrics prefixed with `jdbc.connections`. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc index 3ba8afc70c..27fc4a8811 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/attributes.adoc @@ -21,6 +21,7 @@ :github-wiki: https://github.com/{github-repo}/wiki :docs-java: ../../main/java/org/springframework/boot/docs :docs-groovy: ../../main/groovy/org/springframework/boot/docs +:docs-resources: ../../main/resources :spring-boot-code: https://github.com/{github-repo}/tree/{github-tag} :spring-boot-api: https://docs.spring.io/spring-boot/docs/{spring-boot-version}/api :spring-boot-docs: https://docs.spring.io/spring-boot/docs/{spring-boot-version}/reference @@ -81,6 +82,9 @@ :spring-framework: https://spring.io/projects/spring-framework :spring-framework-api: https://docs.spring.io/spring-framework/docs/{spring-framework-version}/javadoc-api/org/springframework :spring-framework-docs: https://docs.spring.io/spring-framework/docs/{spring-framework-version}/reference/html +:spring-graphql: https://spring.io/projects/spring-graphql +:spring-graphql-api: https://docs.spring.io/spring-graphql/docs/{spring-graphql-version}/api/ +:spring-graphql-docs: https://docs.spring.io/spring-graphql/docs/{spring-graphql-version}/reference/html/ :spring-integration: https://spring.io/projects/spring-integration :spring-integration-docs: https://docs.spring.io/spring-integration/docs/{spring-integration-version}/reference/html/ :spring-kafka-docs: https://docs.spring.io/spring-kafka/docs/{spring-kafka-version}/reference/html/ diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc index 8838f991c7..78dbe81b58 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc @@ -448,6 +448,62 @@ TIP: Sometimes writing Spring WebFlux tests is not enough; Spring Boot can help +[[features.testing.spring-boot-applications.spring-graphql-tests]] +==== Auto-configured Spring GraphQL Tests +Spring GraphQL offers a dedicated testing support module; you'll need to add it to your project: + +.Maven +[source,xml,indent=0,subs="verbatim"] +---- + + + org.springframework.graphql + spring-graphql-test + test + + + + org.springframework + spring-webflux + test + + +---- + +.Gradle +[source,gradle,indent=0,subs="verbatim"] +---- + dependencies { + testImplementation("org.springframework.graphql:spring-graphql-test") + // Unless already present in the implementation configuration + testImplementation("org.springframework:spring-webflux") + } +---- + +This testing module ships the {spring-graphql-docs}/testing.html#testing-webgraphqltester[WebGraphQlTester]. +The tester is heavily used in test, so be sure to become familiar with using it. + +Spring Boot helps you to test your {spring-graphql-docs}#controllers[Spring GraphQL Controllers] with the `@GraphQlTest` annotation. +`@GraphQlTest` auto-configures the Spring GraphQL infrastructure, without any transport nor server being involved. +This limits scanned beans to `@Controller`, `RuntimeWiringConfigurer`, `JsonComponent`, `Converter` and `GenericConverter`. +Regular `@Component` and `@ConfigurationProperties` beans are not scanned when the `@GraphQlTest` annotation is used. +`@EnableConfigurationProperties` can be used to include `@ConfigurationProperties` beans. + +TIP: A list of the auto-configurations that are enabled by `@GraphQlTest` can be <>. + +TIP: If you need to register extra components, such as Jackson `Module`, you can import additional configuration classes using `@Import` on your test. + +Often, `@GraphQlTest` is limited to a set of controllers and used in combination with the `@MockBean` annotation to provide mock implementations for required collaborators. + +[source,java,indent=0,subs="verbatim"] +---- +include::{docs-java}/features/testing/springbootapplications/springgraphqltests/GreetingControllerTests.java[] +---- + +TIP: You can also auto-configure `WebGraphQlTester` in a non-`@GraphQlTest` (such as `@SpringBootTest`) by annotating it with `@AutoConfigureWebGraphQlTester`. + + + [[features.testing.spring-boot-applications.autoconfigured-spring-data-cassandra]] ==== Auto-configured Data Cassandra Tests You can use `@DataCassandraTest` to test Cassandra applications. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc index e9e41e1aca..74af721032 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/index.adoc @@ -16,7 +16,7 @@ The reference documentation consists of the following sections: <> :: Upgrading from 1.x, Upgrading to a new feature release, and Upgrading the Spring Boot CLI. <> :: Build Systems, Structuring Your Code, Configuration, Spring Beans and Dependency Injection, DevTools, and more. <> :: Profiles, Logging, Security, Caching, Spring Integration, Testing, and more. -<> :: Servlet Web, Reactive Web, Embedded Container Support, Graceful Shutdown, and more. +<> :: Servlet Web, Reactive Web, GraphQL, Embedded Container Support, Graceful Shutdown, and more. <> :: SQL and NOSQL data access. <> :: Caching, Quartz Scheduler, REST clients, Sending email, Spring Web Services, and more. <> :: JMS, AMQP, RSocket, WebSocket, and Spring Integration. diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web.adoc index dc98318881..0bf2c5be45 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web.adoc @@ -19,6 +19,8 @@ include::web/spring-security.adoc[] include::web/spring-session.adoc[] +include::web/spring-graphql.adoc[] + include::web/spring-hateoas.adoc[] include::web/whats-next.adoc[] diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc new file mode 100644 index 0000000000..f2a4116629 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc @@ -0,0 +1,135 @@ +[[web.graphql]] +== Spring GraphQL +If you want to build GraphQL applications, you can take advantage of Spring Boot's auto-configuration for {spring-graphql}[Spring GraphQL]. +The Spring GraphQL project is based on https://github.com/graphql-java/graphql-java[GraphQL Java]. +You'll need the `spring-boot-starter-graphql` starter at a minimum. +Because GraphQL is transport-agnostic, you'll also need to have one or more additional starters in your application to expose your GraphQL API over the web: + + +[cols="1,1,1"] +|=== +| Starter | Transport | Implementation + +| `spring-boot-starter-web` +| HTTP +| Spring MVC + +| `spring-boot-starter-websocket` +| WebSocket +| WebSocket for Servlet apps + +| `spring-boot-starter-webflux` +| HTTP, WebSocket +| Spring WebFlux + +|=== + + + +[[web.graphql.schema]] +=== GraphQL Schema + +A Spring GraphQL application requires a defined schema at startup. +By default, you can write ".graphqls" or ".gqls" schema files under `src/main/resources/graphql/**` and Spring Boot will pick them up automatically. +You can customize the locations with configprop:spring.graphql.schema.locations[] and the file extensions with configprop:spring.graphql.schema.file-extensions[]. + +In the following sections, we'll consider this sample GraphQL schema, defining two types and two queries: + +[source,json,indent=0,subs="verbatim,quotes"] +---- +include::{docs-resources}/graphql/schema.graphqls[] +---- + + +[[web.graphql.runtimewiring]] +=== GraphQL RuntimeWiring + +The GraphQL Java `RuntimeWiring.Builder` can be used to register custom scalar types, directives, type resolvers, `DataFetcher`s, and more. +You can declare `RuntimeWiringConfigurer` beans in your Spring config to get access to the `RuntimeWiring.Builder`. +Spring Boot detects such beans and adds them to the {spring-graphql-docs}#execution-graphqlsource[GraphQlSource builder]. + +Typically, however, applications will not implement `DataFetcher` directly and will instead create {spring-graphql-docs}#controllers[annotated controllers]. +Spring Boot will automatically register `@Controller` classes with annotated handler methods and registers those as `DataFetcher`s. +Here's a sample implementation for our greeting query with a `@Controller` class: + +[source,java,indent=0,subs="verbatim"] +---- +include::{docs-java}/web/graphql/GreetingController.java[] +---- + + + +[[web.graphql.data-query]] +=== Querydsl and QueryByExample Repositories support + +Spring Data offers support for both Querydsl and QueryByExample repositories. +Spring GraphQL can {spring-graphql-docs}#data[configure Querydsl and QueryByExample repositories as `DataFetcher`]. + +Spring Data repositories annotated with `@GraphQlRepository` and extending one of: + +* `QuerydslPredicateExecutor` +* `ReactiveQuerydslPredicateExecutor` +* `QueryByExampleExecutor` +* `ReactiveQueryByExampleExecutor` + +are detected by Spring Boot and considered as candidates for `DataFetcher` for matching top-level queries. + + + +[[web.graphql.web-endpoints]] +=== Web Endpoints + +The GraphQL HTTP endpoint is at HTTP POST "/graphql" by default. The path can be customized with configprop:spring.graphql.path[]. + +The GraphQL WebSocket endpoint is off by default. To enable it: + +* For a Servlet application, add the WebSocket starter `spring-boot-starter-websocket` +* For a WebFlux application, no additional dependency is required +* For both, the configprop:spring.graphql.websocket.path[] application property must be set + +Spring GraphQL provides a {spring-graphql-docs}#web-interception[Web Interception] model. +This is quite useful for retrieving information from an HTTP request header and set it in the GraphQL context or fetching information from the same context and writing it to a response header. +With Spring Boot, you can declare a `WebInterceptor` bean to have it registered with the web transport. + + + +[[web.graphql.cors]] +=== CORS + +{spring-framework-docs}/web.html#mvc-cors[Spring MVC] and {spring-framework-docs}/web-reactive.html#webflux-cors[Spring WebFlux] support CORS (Cross-Origin Resource Sharing) requests. +CORS is a critical part of the web config for GraphQL applications that are accessed from browsers using different domains. + +Spring Boot supports many configuration properties under the `spring.graphql.cors.*` namespace; here's a short configuration sample: + +[source,yaml,indent=0,subs="verbatim",configblocks] +---- + spring: + graphql: + cors: + allowed-origins: "https://example.org" + allowed-methods: GET,POST + max-age: 1800s +---- + + + +[[web.graphql.exception-handling]] +=== Exceptions Handling + +Spring GraphQL enables applications to register one or more Spring `DataFetcherExceptionResolver` components that are invoked sequentially. +The Exception must be resolved to a list of `graphql.GraphQLError` objects, see {spring-graphql-docs}#execution-exceptions[Spring GraphQL exception handling documentation]. +Spring Boot will automatically detect `DataFetcherExceptionResolver` beans and register them with the `GraphQlSource.Builder`. + + + +[[web.graphql.graphiql]] +=== GraphiQL and Schema printer + +Spring GraphQL offers infrastructure for helping developers when consuming or developing a GraphQL API. + +Spring GraphQL ships with a default https://github.com/graphql/graphiql[GraphiQL] page that is exposed at "/graphiql" by default. +This page is disabled by default and can be turned on with the configprop:spring.graphql.graphiql.enabled[] property. +Many applications exposing such a page will prefer a custom build. +A default implementation is very useful during development, this is why it is exposed automatically with <> during development. + +You can also choose to expose the GraphQL schema in text format at `/graphql/schema` when the configprop:spring.graphql.schema.printer.enabled[] property is enabled. diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/springgraphqltests/GreetingControllerTests.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/springgraphqltests/GreetingControllerTests.java new file mode 100644 index 0000000000..1812730aca --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/springgraphqltests/GreetingControllerTests.java @@ -0,0 +1,44 @@ +/* + * Copyright 2012-2021 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.boot.docs.features.testing.springbootapplications.springgraphqltests; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.docs.web.graphql.GreetingController; +import org.springframework.boot.test.autoconfigure.graphql.GraphQlTest; +import org.springframework.graphql.test.tester.GraphQlTester; + +@GraphQlTest(GreetingController.class) +class GreetingControllerTests { + + @Autowired + private GraphQlTester graphQlTester; + + @Test + void shouldGreetWithSpecificName() { + this.graphQlTester.query("{ greeting(name: \"Alice\") } ").execute().path("greeting").entity(String.class) + .isEqualTo("Hello, Alice!"); + } + + @Test + void shouldGreetWithDefaultName() { + this.graphQlTester.query("{ greeting } ").execute().path("greeting").entity(String.class) + .isEqualTo("Hello, Spring!"); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/graphql/GreetingController.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/graphql/GreetingController.java new file mode 100644 index 0000000000..99566d9512 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/graphql/GreetingController.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2021 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.boot.docs.web.graphql; + +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.stereotype.Controller; + +@Controller +public class GreetingController { + + @QueryMapping + public String greeting(@Argument String name) { + return "Hello, " + name + "!"; + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/resources/graphql/schema.graphqls b/spring-boot-project/spring-boot-docs/src/main/resources/graphql/schema.graphqls new file mode 100644 index 0000000000..fcbf6b0030 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/resources/graphql/schema.graphqls @@ -0,0 +1,29 @@ +type Query { + greeting(name: String! = "Spring"): String! + project(slug: ID!): Project +} + +""" A Project in the Spring portfolio """ +type Project { + """ Unique string id used in URLs """ + slug: ID! + """ Project name """ + name: String! + """ URL of the git repository """ + repositoryUrl: String! + """ Current support status """ + status: ProjectStatus! +} + +enum ProjectStatus { + """ Actively supported by the Spring team """ + ACTIVE + """ Supported by the community """ + COMMUNITY + """ Prototype, not officially supported yet """ + INCUBATING + """ Project being retired, in maintenance mode """ + ATTIC + """ End-Of-Lifed """ + EOL +} \ No newline at end of file