From de808834f588eb93647af809bc0e9727522b246c Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:32:37 +0100 Subject: [PATCH] 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