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 484a3fa336..cb72deb650 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-actuator-autoconfigure/build.gradle b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle index 33e1749c2d..324d8c2ba1 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle @@ -127,6 +127,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/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 2a60a14063..5d257850d2 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 @@ -67,6 +67,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.mongo.MongoMetricsAutoConfiguration,\ 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-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; + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/build.gradle b/spring-boot-project/spring-boot-actuator/build.gradle index a9064cd715..f2b7ff9ed4 100644 --- a/spring-boot-project/spring-boot-actuator/build.gradle +++ b/spring-boot-project/spring-boot-actuator/build.gradle @@ -77,6 +77,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"); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/build.gradle b/spring-boot-project/spring-boot-autoconfigure/build.gradle index 5bc1a394d3..9fc02cd0cf 100644 --- a/spring-boot-project/spring-boot-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-autoconfigure/build.gradle @@ -137,6 +137,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") @@ -173,6 +174,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") @@ -191,6 +193,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/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/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/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..3df070e745 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.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.autoconfigure.graphql; + +import java.time.Duration; +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 { + + /** + * Path at which to expose a GraphQL request HTTP endpoint. + */ + private String path = "/graphql"; + + private final Graphiql graphiql = new Graphiql(); + + private final Schema schema = new Schema(); + + private final Websocket websocket = new Websocket(); + + public Graphiql getGraphiql() { + return this.graphiql; + } + + public String getPath() { + return this.path; + } + + public void setPath(String path) { + this.path = path; + } + + public Schema getSchema() { + return this.schema; + } + + public Websocket getWebsocket() { + return this.websocket; + } + + 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" }; + + private final Printer printer = new Printer(); + + 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); + } + + 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; + } + + } + + } + + 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; + } + + } + + 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/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/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/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/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/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..b7f8136d7f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.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.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.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.context.properties.EnableConfigurationProperties; +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.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; + +/** + * {@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) +@EnableConfigurationProperties(GraphQlCorsProperties.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); + + 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); + } + + 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); + } + } + + } + + @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/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/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/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..fd5a2210fe --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java @@ -0,0 +1,193 @@ +/* + * 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.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; + +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.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; +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.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 + * 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) +@EnableConfigurationProperties(GraphQlCorsProperties.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); + + 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); + } + + 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); + } + } + + } + + @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/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/additional-spring-configuration-metadata.json b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json index c5808c2860..29997c9174 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": "" @@ -2190,6 +2198,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/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories index 9bd2faba0d..f4479e3c06 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 @@ -64,6 +64,15 @@ 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.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.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.hateoas.HypermediaAutoConfiguration,\ @@ -152,6 +161,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.r2dbc.ConnectionFactoryBeanCreationFailureAnalyzer,\ 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..0dbea1be83 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/Book.java @@ -0,0 +1,79 @@ +/* + * 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.data.annotation.Id; + +/** + * Sample class for + * + * @author Brian Clozel + */ +public class Book { + + @Id + 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/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/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/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:"); + } + +} 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/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 { + + } + +} 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..64aadfe805 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java @@ -0,0 +1,182 @@ +/* + * 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.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; + +/** + * Tests for {@link GraphQlWebFluxAutoConfiguration} + * + * @author Brian Clozel + */ +class GraphQlWebFluxAutoConfigurationTests { + + 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.graphiql.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 shouldContributeDefaultBeans() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(GraphQlHttpHandler.class) + .hasSingleBean(WebGraphQlHandler.class).doesNotHaveBean(GraphQlWebSocketHandler.class)); + } + + @Test + void simpleQueryShouldWork() { + testWithWebClient((client) -> { + String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author } }"; + client.post().uri("/graphql").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("/graphql?query={query}", "{ \"query\": \"" + query + "\"}").exchange().expectStatus() + .isEqualTo(HttpStatus.METHOD_NOT_ALLOWED).expectHeader().valueEquals("Allow", "POST"); + }); + } + + @Test + void shouldRejectMissingQuery() { + testWithWebClient( + (client) -> client.post().uri("/graphql").bodyValue("{}").exchange().expectStatus().isBadRequest()); + } + + @Test + void shouldRejectQueryWithInvalidJson() { + testWithWebClient( + (client) -> client.post().uri("/graphql").bodyValue(":)").exchange().expectStatus().isBadRequest()); + } + + @Test + void shouldConfigureWebInterceptors() { + testWithWebClient((client) -> { + String query = "{ bookById(id: \\\"book-1\\\"){ id name pageCount author } }"; + + client.post().uri("/graphql").bodyValue("{ \"query\": \"" + query + "\"}").exchange().expectStatus().isOk() + .expectHeader().valueEquals("X-Custom-Header", "42"); + }); + } + + @Test + void shouldExposeSchemaEndpoint() { + 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); + }); + } + + @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"); + }); + } + + @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() + .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/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); + } + + } + +} 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..3e1f57a16a --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java @@ -0,0 +1,193 @@ +/* + * 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.hamcrest.Matchers; +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.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; +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; + +/** + * 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", "spring.graphql.graphiql.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 shouldContributeDefaultBeans() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(GraphQlHttpHandler.class) + .hasSingleBean(WebGraphQlHandler.class).doesNotHaveBean(GraphQlWebSocketHandler.class)); + } + + @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")); + }); + } + + @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")))); + } + + @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)); + }); + } + + @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")); + }); + } + + @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; + 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"))); + } + + } + +} 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 diff --git a/spring-boot-project/spring-boot-dependencies/build.gradle b/spring-boot-project/spring-boot-dependencies/build.gradle index 69f33ab4b4..08951f3a9e 100644 --- a/spring-boot-project/spring-boot-dependencies/build.gradle +++ b/spring-boot-project/spring-boot-dependencies/build.gradle @@ -273,6 +273,13 @@ bom { ] } } + library("GraphQL Java", "17.3") { + group("com.graphql-java") { + modules = [ + "graphql-java" + ] + } + } library("Groovy", "3.0.9") { group("org.codehaus.groovy") { imports = [ @@ -1157,6 +1164,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", @@ -1313,6 +1321,14 @@ bom { ] } } + library("Spring GraphQL", "1.0.0-SNAPSHOT") { + group("org.springframework.graphql") { + modules = [ + "spring-graphql", + "spring-graphql-test" + ] + } + } library("Spring HATEOAS", "2.0.0-SNAPSHOT") { group("org.springframework.hateoas") { modules = [ 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 88e0487fdc..9e7f760d04 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 @@ -62,6 +62,7 @@ public class DevToolsPropertyDefaultsPostProcessor implements EnvironmentPostPro static { Map properties = new HashMap<>(); 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"); diff --git a/spring-boot-project/spring-boot-docs/build.gradle b/spring-boot-project/spring-boot-docs/build.gradle index c842f1f7b4..2c6dd5445b 100644 --- a/spring-boot-project/spring-boot-docs/build.gradle +++ b/spring-boot-project/spring-boot-docs/build.gradle @@ -133,6 +133,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") { @@ -266,8 +268,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"] } @@ -310,6 +313,9 @@ syncDocumentationSourceForAsciidoctor { from("src/main/groovy") { into "main/groovy" } + from("src/main/resources") { + into "main/resources" + } } syncDocumentationSourceForAsciidoctorMultipage { @@ -331,6 +337,9 @@ syncDocumentationSourceForAsciidoctorMultipage { from("src/main/groovy") { into "main/groovy" } + from("src/main/resources") { + into "main/resources" + } } syncDocumentationSourceForAsciidoctorPdf { @@ -352,6 +361,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 942caee297..25131f3a92 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 @@ -837,6 +837,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 aa7e253680..499b5e7bd0 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 @@ -79,6 +80,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 f7250beafe..032f953008 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 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") +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/build.gradle b/spring-boot-project/spring-boot-test-autoconfigure/build.gradle index 454c62176b..e71df8b3b9 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-test-autoconfigure/build.gradle @@ -44,6 +44,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/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/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/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/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/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 8429c468b5..81647f5d6d 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 @@ -186,6 +186,20 @@ 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 + +# 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/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/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-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 diff --git a/spring-boot-project/spring-boot-test/build.gradle b/spring-boot-project/spring-boot-test/build.gradle index dc37c1cf72..119c511626 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 "{}"; + } + + } + +} 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(); + } + +}