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 36a723838d..0a9dfcd51a 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 552e51be62..b304ebf8e3 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/build.gradle @@ -135,6 +135,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..93b8d4fbb7 --- /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,68 @@ +/* + * Copyright 2012-2022 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.AutoConfiguration; +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.graphql.execution.GraphQlSource; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for instrumentation of Spring + * GraphQL endpoints. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@AutoConfiguration(after = { 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/java/org/springframework/boot/actuate/autoconfigure/metrics/graphql/package-info.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/graphql/package-info.java new file mode 100644 index 0000000000..471595d95f --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/metrics/graphql/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2022 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 metrics. + */ +package org.springframework.boot.actuate.autoconfigure.metrics.graphql; diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index c6f4f89c4f..d1df923b28 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -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/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 050afcf1e4..a675a6ebba 100644 --- a/spring-boot-project/spring-boot-actuator/build.gradle +++ b/spring-boot-project/spring-boot-actuator/build.gradle @@ -61,6 +61,7 @@ dependencies { optional("org.springframework:spring-webflux") optional("org.springframework:spring-web") optional("org.springframework:spring-webmvc") + optional("org.springframework.graphql:spring-graphql") optional("org.springframework.amqp:spring-rabbit") optional("org.springframework.data:spring-data-cassandra") { exclude group: "org.slf4j", module: "jcl-over-slf4j" 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..f03e3d33d8 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentation.java @@ -0,0 +1,166 @@ +/* + * Copyright 2020-2022 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.InstrumentationCreateStateParameters; +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; + +/** + * Micrometer-based {@link SimpleInstrumentation}. + * + * @author Brian Clozel + * @since 2.7.0 + */ +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(InstrumentationCreateStateParameters parameters) { + return new RequestMetricsInstrumentationState(this.autoTimer, this.registry); + } + + @Override + public InstrumentationContext beginExecution(InstrumentationExecutionParameters parameters, + InstrumentationState state) { + if (this.autoTimer.isEnabled() && state instanceof RequestMetricsInstrumentationState instrumentationState) { + instrumentationState.startTimer(); + return new SimpleInstrumentationContext() { + @Override + public void onCompleted(ExecutionResult result, Throwable exc) { + Iterable tags = GraphQlMetricsInstrumentation.this.tagsProvider.getExecutionTags(parameters, + result, exc); + instrumentationState.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(instrumentationState.getDataFetchingCount()); + } + }; + } + return super.beginExecution(parameters); + } + + @Override + public DataFetcher instrumentDataFetcher(DataFetcher dataFetcher, + InstrumentationFieldFetchParameters parameters, InstrumentationState state) { + if (this.autoTimer.isEnabled() && !parameters.isTrivialDataFetcher() + && state instanceof RequestMetricsInstrumentationState instrumentationState) { + return (environment) -> { + Timer.Sample sample = Timer.start(this.registry); + try { + Object value = dataFetcher.get(environment); + if (value instanceof CompletionStage completion) { + return completion.whenComplete((result, error) -> recordDataFetcherMetric(sample, + instrumentationState, dataFetcher, parameters, error)); + } + else { + recordDataFetcherMetric(sample, instrumentationState, dataFetcher, parameters, null); + return value; + } + } + catch (Throwable throwable) { + recordDataFetcherMetric(sample, instrumentationState, dataFetcher, parameters, throwable); + throw throwable; + } + }; + } + return super.instrumentDataFetcher(dataFetcher, parameters, state); + } + + private void recordDataFetcherMetric(Timer.Sample sample, RequestMetricsInstrumentationState instrumentationState, + DataFetcher dataFetcher, InstrumentationFieldFetchParameters parameters, Throwable throwable) { + Timer.Builder timer = this.autoTimer.builder("graphql.datafetcher"); + timer.tags(this.tagsProvider.getDataFetchingTags(dataFetcher, parameters, throwable)); + sample.stop(timer.register(this.registry)); + instrumentationState.incrementDataFetchingCount(); + } + + static class RequestMetricsInstrumentationState implements InstrumentationState { + + private final MeterRegistry registry; + + private final Timer.Builder timer; + + private Timer.Sample sample; + + private final AtomicLong dataFetchingCount = new AtomicLong(); + + 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..eb8cd10a88 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTags.java @@ -0,0 +1,101 @@ +/* + * Copyright 2020-2022 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.util.CollectionUtils; + +/** + * Factory methods for Tags associated with a GraphQL request. + * + * @author Brian Clozel + * @since 2.7.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_ERROR_TYPE = Tag.of("error.type", "UNKNOWN"); + + private GraphQlTags() { + + } + + public static Tag executionOutcome(ExecutionResult result, 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("error.type", ((ErrorType) errorType).name()); + } + return UNKNOWN_ERROR_TYPE; + } + + 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 { + Integer.parseUnsignedInt(segment.toString()); + builder.append("[*]"); + } + catch (NumberFormatException exc) { + builder.append('.'); + builder.append(segment); + } + } + } + return Tag.of("error.path", builder.toString()); + } + + public static Tag dataFetchingOutcome(Throwable exception) { + return (exception != null) ? OUTCOME_ERROR : OUTCOME_SUCCESS; + } + + public static Tag dataFetchingPath(InstrumentationFieldFetchParameters parameters) { + ExecutionStepInfo executionStepInfo = parameters.getExecutionStepInfo(); + StringBuilder dataFetchingPath = new StringBuilder(); + if (executionStepInfo.hasParent() && executionStepInfo.getParent().getType() instanceof GraphQLObjectType) { + dataFetchingPath.append(((GraphQLObjectType) executionStepInfo.getParent().getType()).getName()); + dataFetchingPath.append('.'); + } + dataFetchingPath.append(executionStepInfo.getPath().getSegmentName()); + return Tag.of("path", dataFetchingPath.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..55b97055ab --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsContributor.java @@ -0,0 +1,43 @@ +/* + * Copyright 2020-2022 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; + +/** + * 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, + Throwable exception); + + Iterable getErrorTags(InstrumentationExecutionParameters parameters, GraphQLError error); + + Iterable getDataFetchingTags(DataFetcher dataFetcher, InstrumentationFieldFetchParameters parameters, + 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..a60ee6df07 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/graphql/GraphQlTagsProvider.java @@ -0,0 +1,42 @@ +/* + * Copyright 2020-2022 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; + +/** + * 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, + Throwable exception); + + Iterable getErrorTags(InstrumentationExecutionParameters parameters, GraphQLError error); + + Iterable getDataFetchingTags(DataFetcher dataFetcher, InstrumentationFieldFetchParameters parameters, + 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..efe30504e1 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/graphql/GraphQlMetricsInstrumentationTests.java @@ -0,0 +1,185 @@ +/* + * Copyright 2012-2022 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.InstrumentationCreateStateParameters; +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 + */ +@SuppressWarnings("unchecked") +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(new InstrumentationCreateStateParameters(this.schema, this.input)); + this.parameters = new InstrumentationExecutionParameters(this.input, this.schema, this.state); + } + + @Test + void shouldRecordTimerWhenResult() { + InstrumentationContext execution = this.instrumentation.beginExecution(this.parameters, + this.state); + ExecutionResult result = new ExecutionResultImpl("Hello", null); + execution.onCompleted(result, null); + + Timer timer = this.registry.find("graphql.request").timer(); + assertThat(timer).isNotNull(); + assertThat(timer.count()).isEqualTo(1); + } + + @Test + void shouldRecordDataFetchingCount() throws Exception { + InstrumentationContext execution = this.instrumentation.beginExecution(this.parameters, + this.state); + 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, + this.state); + 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, + this.state); + DataFetchingEnvironment environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment().build(); + instrumented.get(environment); + + Timer timer = this.registry.find("graphql.datafetcher").timer(); + assertThat(timer).isNotNull(); + assertThat(timer.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, + this.state); + DataFetchingEnvironment environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment().build(); + instrumented.get(environment); + + Timer timer = this.registry.find("graphql.datafetcher").timer(); + assertThat(timer).isNotNull(); + assertThat(timer.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, + this.state); + 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.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, + this.state); + 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); + 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..c9d04d2732 --- /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-2022 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).isEqualTo(Tag.of("outcome", "SUCCESS")); + } + + @Test + void executionOutcomeShouldErrorWhenExceptionThrown() { + ExecutionResult result = ExecutionResultImpl.newExecutionResult().build(); + Tag tag = GraphQlTags.executionOutcome(result, new IllegalArgumentException("test error")); + assertThat(tag).isEqualTo(Tag.of("outcome", "ERROR")); + } + + @Test + void executionOutcomeShouldErrorWhenResponseErrors() { + GraphQLError error = GraphqlErrorBuilder.newError().message("Invalid query").build(); + Tag tag = GraphQlTags.executionOutcome(ExecutionResultImpl.newExecutionResult().addError(error).build(), null); + assertThat(tag).isEqualTo(Tag.of("outcome", "ERROR")); + } + + @Test + void errorTypeShouldBeDefinedIfPresent() { + GraphQLError error = GraphqlErrorBuilder.newError().errorType(ErrorType.DataFetchingException) + .message("test error").build(); + Tag errorTypeTag = GraphQlTags.errorType(error); + assertThat(errorTypeTag).isEqualTo(Tag.of("error.type", "DataFetchingException")); + } + + @Test + void errorPathShouldUseJsonPathFormat() { + GraphQLError error = GraphqlErrorBuilder.newError().path(Arrays.asList("project", "name")).message("test error") + .build(); + Tag errorPathTag = GraphQlTags.errorPath(error); + assertThat(errorPathTag).isEqualTo(Tag.of("error.path", "$.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).isEqualTo(Tag.of("error.path", "$.issues[*].title")); + } + + @Test + void dataFetchingOutcomeShouldBeSuccessfulIfNoException() { + Tag fetchingOutcomeTag = GraphQlTags.dataFetchingOutcome(null); + assertThat(fetchingOutcomeTag).isEqualTo(Tag.of("outcome", "SUCCESS")); + } + + @Test + void dataFetchingOutcomeShouldBeErrorIfException() { + Tag fetchingOutcomeTag = GraphQlTags.dataFetchingOutcome(new IllegalStateException("error state")); + assertThat(fetchingOutcomeTag).isEqualTo(Tag.of("outcome", "ERROR")); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/build.gradle b/spring-boot-project/spring-boot-autoconfigure/build.gradle index 1bcee1cd16..20be4e8bbb 100644 --- a/spring-boot-project/spring-boot-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-autoconfigure/build.gradle @@ -47,6 +47,7 @@ dependencies { optional("jakarta.persistence:jakarta.persistence-api") optional("jakarta.transaction:jakarta.transaction-api") optional("jakarta.validation:jakarta.validation-api") + optional("jakarta.websocket:jakarta.websocket-api") optional("jakarta.ws.rs:jakarta.ws.rs-api") optional("javax.cache:cache-api") optional("javax.money:money-api") @@ -155,6 +156,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") @@ -196,6 +198,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") @@ -215,6 +218,7 @@ dependencies { testImplementation("org.mockito:mockito-junit-jupiter") testImplementation("org.skyscreamer:jsonassert") 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/ConditionalOnGraphQlSchema.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/ConditionalOnGraphQlSchema.java new file mode 100644 index 0000000000..205a81ac93 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/ConditionalOnGraphQlSchema.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2022 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.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Conditional; + +/** + * {@link Conditional @Conditional} that only matches when a GraphQL schema is defined for + * the application, via schema files or infrastructure beans. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Conditional(DefaultGraphQlSchemaCondition.class) +public @interface ConditionalOnGraphQlSchema { + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/DefaultGraphQlSchemaCondition.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/DefaultGraphQlSchemaCondition.java new file mode 100644 index 0000000000..53d3f1f0da --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/DefaultGraphQlSchemaCondition.java @@ -0,0 +1,108 @@ +/* + * Copyright 2012-2022 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.Collections; +import java.util.List; + +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.boot.autoconfigure.condition.ConditionMessage; +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.ConfigurationCondition; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternUtils; +import org.springframework.core.type.AnnotatedTypeMetadata; + +/** + * {@link Condition} that checks whether a GraphQL schema has been defined in the + * application. This is looking for: + *
    + *
  • schema files in the {@link GraphQlProperties configured locations}
  • + *
  • or infrastructure beans such as {@link GraphQlSourceBuilderCustomizer}
  • + *
+ * + * @author Brian Clozel + * @see ConditionalOnGraphQlSchema + */ +class DefaultGraphQlSchemaCondition extends SpringBootCondition implements ConfigurationCondition { + + @Override + public ConfigurationCondition.ConfigurationPhase getConfigurationPhase() { + return ConfigurationCondition.ConfigurationPhase.REGISTER_BEAN; + } + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { + boolean match = false; + List messages = new ArrayList<>(2); + ConditionMessage.Builder message = ConditionMessage.forCondition(ConditionalOnGraphQlSchema.class); + Binder binder = Binder.get(context.getEnvironment()); + GraphQlProperties.Schema schema = binder.bind("spring.graphql.schema", GraphQlProperties.Schema.class) + .orElse(new GraphQlProperties.Schema()); + ResourcePatternResolver resourcePatternResolver = ResourcePatternUtils + .getResourcePatternResolver(context.getResourceLoader()); + List schemaResources = resolveSchemaResources(resourcePatternResolver, schema.getLocations(), + schema.getFileExtensions()); + if (!schemaResources.isEmpty()) { + match = true; + messages.add(message.found("schema", "schemas").items(ConditionMessage.Style.QUOTE, schemaResources)); + } + else { + messages.add(message.didNotFind("schema files in locations").items(ConditionMessage.Style.QUOTE, + Arrays.asList(schema.getLocations()))); + } + ConfigurableListableBeanFactory beanFactory = context.getBeanFactory(); + String[] customizerBeans = beanFactory.getBeanNamesForType(GraphQlSourceBuilderCustomizer.class, false, false); + if (customizerBeans.length != 0) { + match = true; + messages.add(message.found("customizer", "customizers").items(Arrays.asList(customizerBeans))); + } + else { + messages.add((message.didNotFind("GraphQlSourceBuilderCustomizer").atAll())); + } + return new ConditionOutcome(match, ConditionMessage.of(messages)); + } + + private List resolveSchemaResources(ResourcePatternResolver resolver, String[] locations, + String[] extensions) { + List resources = new ArrayList<>(); + for (String location : locations) { + for (String extension : extensions) { + resources.addAll(resolveSchemaResources(resolver, location + "*" + extension)); + } + } + return resources; + } + + private List resolveSchemaResources(ResourcePatternResolver resolver, String pattern) { + try { + return Arrays.asList(resolver.getResources(pattern)); + } + catch (IOException ex) { + return Collections.emptyList(); + } + } + +} 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..bc90f07e5c --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfiguration.java @@ -0,0 +1,151 @@ +/* + * Copyright 2012-2022 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.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import graphql.GraphQL; +import graphql.execution.instrumentation.Instrumentation; +import graphql.schema.idl.RuntimeWiring.Builder; +import graphql.schema.visibility.NoIntrospectionGraphqlFieldVisibility; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.ListableBeanFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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.boot.convert.ApplicationConversionService; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.core.log.LogMessage; +import org.springframework.graphql.ExecutionGraphQlService; +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.DefaultExecutionGraphQlService; +import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.execution.SubscriptionExceptionResolver; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for creating a Spring GraphQL base + * infrastructure. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@AutoConfiguration +@ConditionalOnClass({ GraphQL.class, GraphQlSource.class }) +@ConditionalOnGraphQlSchema +@EnableConfigurationProperties(GraphQlProperties.class) +public class GraphQlAutoConfiguration { + + private static final Log logger = LogFactory.getLog(GraphQlAutoConfiguration.class); + + private final ListableBeanFactory beanFactory; + + public GraphQlAutoConfiguration(ListableBeanFactory beanFactory) { + this.beanFactory = beanFactory; + } + + @Bean + @ConditionalOnMissingBean + public GraphQlSource graphQlSource(ResourcePatternResolver resourcePatternResolver, GraphQlProperties properties, + ObjectProvider exceptionResolvers, + ObjectProvider subscriptionExceptionResolvers, + ObjectProvider instrumentations, ObjectProvider wiringConfigurers, + ObjectProvider sourceCustomizers) { + String[] schemaLocations = properties.getSchema().getLocations(); + Resource[] schemaResources = resolveSchemaResources(resourcePatternResolver, schemaLocations, + properties.getSchema().getFileExtensions()); + GraphQlSource.SchemaResourceBuilder builder = GraphQlSource.schemaResourceBuilder() + .schemaResources(schemaResources).exceptionResolvers(toList(exceptionResolvers)) + .subscriptionExceptionResolvers(toList(subscriptionExceptionResolvers)) + .instrumentation(toList(instrumentations)); + if (!properties.getSchema().getIntrospection().isEnabled()) { + builder.configureRuntimeWiring(this::enableIntrospection); + } + wiringConfigurers.orderedStream().forEach(builder::configureRuntimeWiring); + sourceCustomizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); + return builder.build(); + } + + private Builder enableIntrospection(Builder wiring) { + return wiring.fieldVisibility(NoIntrospectionGraphqlFieldVisibility.NO_INTROSPECTION_FIELD_VISIBILITY); + } + + private Resource[] resolveSchemaResources(ResourcePatternResolver resolver, String[] locations, + String[] extensions) { + List resources = new ArrayList<>(); + for (String location : locations) { + for (String extension : extensions) { + resources.addAll(resolveSchemaResources(resolver, location + "*" + extension)); + } + } + return resources.toArray(new Resource[0]); + } + + private List resolveSchemaResources(ResourcePatternResolver resolver, String pattern) { + try { + return Arrays.asList(resolver.getResources(pattern)); + } + catch (IOException ex) { + logger.debug(LogMessage.format("Could not resolve schema location: '%s'", pattern), ex); + return Collections.emptyList(); + } + } + + @Bean + @ConditionalOnMissingBean + public BatchLoaderRegistry batchLoaderRegistry() { + return new DefaultBatchLoaderRegistry(); + } + + @Bean + @ConditionalOnMissingBean + public ExecutionGraphQlService executionGraphQlService(GraphQlSource graphQlSource, + BatchLoaderRegistry batchLoaderRegistry) { + DefaultExecutionGraphQlService service = new DefaultExecutionGraphQlService(graphQlSource); + service.addDataLoaderRegistrar(batchLoaderRegistry); + return service; + } + + @Bean + @ConditionalOnMissingBean + public AnnotatedControllerConfigurer annotatedControllerConfigurer() { + AnnotatedControllerConfigurer controllerConfigurer = new AnnotatedControllerConfigurer(); + controllerConfigurer + .addFormatterRegistrar((registry) -> ApplicationConversionService.addBeans(registry, this.beanFactory)); + return controllerConfigurer; + } + + private List toList(ObjectProvider provider) { + return provider.orderedStream().collect(Collectors.toList()); + } + +} 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..7611f4e3ee --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlCorsProperties.java @@ -0,0 +1,156 @@ +/* + * Copyright 2012-2022 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.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. + */ + 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; + } + + 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; + } + + 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..504ab540b2 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java @@ -0,0 +1,230 @@ +/* + * Copyright 2012-2022 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(); + + private final Rsocket rsocket = new Rsocket(); + + 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 Rsocket getRsocket() { + return this.rsocket; + } + + 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 Introspection introspection = new Introspection(); + + 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 Introspection getIntrospection() { + return this.introspection; + } + + public Printer getPrinter() { + return this.printer; + } + + public static class Introspection { + + /** + * Whether field introspection should be enabled at the schema level. + */ + private boolean enabled = true; + + public boolean isEnabled() { + return this.enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + } + + 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; + } + + } + + public static class Rsocket { + + /** + * Mapping of the RSocket message handler. + */ + private String mapping; + + public String getMapping() { + return this.mapping; + } + + public void setMapping(String mapping) { + this.mapping = mapping; + } + + } + +} 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..e8f1802dc2 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlSourceBuilderCustomizer.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2022 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.SchemaResourceBuilder + * Builder} whilst retaining default auto-configuration. + * + * @author Rossen Stoyanchev + * @since 2.7.0 + */ +@FunctionalInterface +public interface GraphQlSourceBuilderCustomizer { + + /** + * Customize the + * {@link org.springframework.graphql.execution.GraphQlSource.SchemaResourceBuilder + * Builder} instance. + * @param builder builder the builder to customize + */ + void customize(GraphQlSource.SchemaResourceBuilder builder); + +} 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..52df8e11cb --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQueryByExampleAutoConfiguration.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-2022 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.List; + +import graphql.GraphQL; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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.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#autoRegistrationConfigurer(List, List) + */ +@AutoConfiguration(after = GraphQlAutoConfiguration.class) +@ConditionalOnClass({ GraphQL.class, QueryByExampleDataFetcher.class, QueryByExampleExecutor.class }) +@ConditionalOnBean(GraphQlSource.class) +public class GraphQlQueryByExampleAutoConfiguration { + + @Bean + public GraphQlSourceBuilderCustomizer queryByExampleRegistrar(ObjectProvider> executors, + ObjectProvider> reactiveExecutors) { + return new GraphQlQuerydslSourceBuilderCustomizer<>(QueryByExampleDataFetcher::autoRegistrationConfigurer, + 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..c32b21b781 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfiguration.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2022 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.List; + +import graphql.GraphQL; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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.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 + * @author Brian Clozel + * @since 2.7.0 + * @see QuerydslDataFetcher#autoRegistrationConfigurer(List, List) + */ +@AutoConfiguration(after = GraphQlAutoConfiguration.class) +@ConditionalOnClass({ GraphQL.class, QuerydslDataFetcher.class, QuerydslPredicateExecutor.class }) +@ConditionalOnBean(GraphQlSource.class) +public class GraphQlQuerydslAutoConfiguration { + + @Bean + public GraphQlSourceBuilderCustomizer querydslRegistrar(ObjectProvider> executors, + ObjectProvider> reactiveExecutors) { + return new GraphQlQuerydslSourceBuilderCustomizer<>(QuerydslDataFetcher::autoRegistrationConfigurer, executors, + reactiveExecutors); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslSourceBuilderCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslSourceBuilderCustomizer.java new file mode 100644 index 0000000000..080d57a7e7 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslSourceBuilderCustomizer.java @@ -0,0 +1,72 @@ +/* + * Copyright 2012-2022 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.function.BiFunction; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; +import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; + +/** + * {@link GraphQlSourceBuilderCustomizer} to apply auto-configured QueryDSL + * {@link RuntimeWiringConfigurer RuntimeWiringConfigurers}. + * + * @param the executor type + * @param the reactive executor type + * @author Phillip Webb + * @author Rossen Stoyanchev + * @author Brian Clozel + */ +class GraphQlQuerydslSourceBuilderCustomizer implements GraphQlSourceBuilderCustomizer { + + private final BiFunction, List, RuntimeWiringConfigurer> wiringConfigurerFactory; + + private final List executors; + + private final List reactiveExecutors; + + GraphQlQuerydslSourceBuilderCustomizer( + BiFunction, List, RuntimeWiringConfigurer> wiringConfigurerFactory, ObjectProvider executors, + ObjectProvider reactiveExecutors) { + this(wiringConfigurerFactory, toList(executors), toList(reactiveExecutors)); + } + + GraphQlQuerydslSourceBuilderCustomizer( + BiFunction, List, RuntimeWiringConfigurer> wiringConfigurerFactory, List executors, + List reactiveExecutors) { + this.wiringConfigurerFactory = wiringConfigurerFactory; + this.executors = executors; + this.reactiveExecutors = reactiveExecutors; + } + + @Override + public void customize(GraphQlSource.SchemaResourceBuilder builder) { + if (!this.executors.isEmpty() || !this.reactiveExecutors.isEmpty()) { + builder.configureRuntimeWiring(this.wiringConfigurerFactory.apply(this.executors, this.reactiveExecutors)); + } + } + + private static List toList(ObjectProvider provider) { + return (provider != null) ? provider.orderedStream().collect(Collectors.toList()) : Collections.emptyList(); + } + +} 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..f28b17801e --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfiguration.java @@ -0,0 +1,58 @@ +/* + * Copyright 2012-2022 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.List; + +import graphql.GraphQL; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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.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#autoRegistrationConfigurer(List, List) + */ +@AutoConfiguration(after = GraphQlAutoConfiguration.class) +@ConditionalOnClass({ GraphQL.class, QueryByExampleDataFetcher.class, ReactiveQueryByExampleExecutor.class }) +@ConditionalOnBean(GraphQlSource.class) +public class GraphQlReactiveQueryByExampleAutoConfiguration { + + @Bean + public GraphQlSourceBuilderCustomizer reactiveQueryByExampleRegistrar( + ObjectProvider> reactiveExecutors) { + return new GraphQlQuerydslSourceBuilderCustomizer<>(QueryByExampleDataFetcher::autoRegistrationConfigurer, + (ObjectProvider>) null, reactiveExecutors); + } + +} 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..f12be0563a --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfiguration.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2022 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.List; + +import graphql.GraphQL; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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.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 + * @author Brian Clozel + * @since 2.7.0 + * @see QuerydslDataFetcher#autoRegistrationConfigurer(List, List) + */ +@AutoConfiguration(after = GraphQlAutoConfiguration.class) +@ConditionalOnClass({ GraphQL.class, QuerydslDataFetcher.class, ReactiveQuerydslPredicateExecutor.class }) +@ConditionalOnBean(GraphQlSource.class) +public class GraphQlReactiveQuerydslAutoConfiguration { + + @Bean + public GraphQlSourceBuilderCustomizer reactiveQuerydslRegistrar( + ObjectProvider> reactiveExecutors) { + return new GraphQlQuerydslSourceBuilderCustomizer<>(QuerydslDataFetcher::autoRegistrationConfigurer, + (ObjectProvider>) null, reactiveExecutors); + } + +} 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..70178c8016 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java @@ -0,0 +1,180 @@ +/* + * Copyright 2012-2022 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 reactor.core.publisher.Mono; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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.annotation.Order; +import org.springframework.core.log.LogMessage; +import org.springframework.graphql.ExecutionGraphQlService; +import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.server.WebGraphQlHandler; +import org.springframework.graphql.server.WebGraphQlInterceptor; +import org.springframework.graphql.server.webflux.GraphQlHttpHandler; +import org.springframework.graphql.server.webflux.GraphQlWebSocketHandler; +import org.springframework.graphql.server.webflux.GraphiQlHandler; +import org.springframework.graphql.server.webflux.SchemaHandler; +import org.springframework.http.HttpHeaders; +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.RequestPredicate; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerRequest; +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 + */ +@AutoConfiguration(after = GraphQlAutoConfiguration.class) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +@ConditionalOnClass({ GraphQL.class, GraphQlHttpHandler.class }) +@ConditionalOnBean(ExecutionGraphQlService.class) +@EnableConfigurationProperties(GraphQlCorsProperties.class) +public class GraphQlWebFluxAutoConfiguration { + + private static final RequestPredicate SUPPORTS_MEDIATYPES = accept(MediaType.APPLICATION_GRAPHQL, + MediaType.APPLICATION_JSON).and(contentType(MediaType.APPLICATION_GRAPHQL, MediaType.APPLICATION_JSON)); + + 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(ExecutionGraphQlService service, + ObjectProvider interceptorsProvider) { + return WebGraphQlHandler.builder(service) + .interceptors(interceptorsProvider.orderedStream().collect(Collectors.toList())).build(); + } + + @Bean + @Order(0) + public RouterFunction graphQlRouterFunction(GraphQlHttpHandler httpHandler, + GraphQlSource graphQlSource, GraphQlProperties properties) { + String path = properties.getPath(); + logger.info(LogMessage.format("GraphQL endpoint HTTP POST %s", path)); + RouterFunctions.Builder builder = RouterFunctions.route(); + builder = builder.GET(path, this::onlyAllowPost); + builder = builder.POST(path, SUPPORTS_MEDIATYPES, httpHandler::handleRequest); + if (properties.getGraphiql().isEnabled()) { + GraphiQlHandler graphQlHandler = new GraphiQlHandler(path, properties.getWebsocket().getPath()); + builder = builder.GET(properties.getGraphiql().getPath(), graphQlHandler::handleRequest); + } + if (properties.getSchema().getPrinter().isEnabled()) { + SchemaHandler schemaHandler = new SchemaHandler(graphQlSource); + builder = builder.GET(path + "/schema", schemaHandler::handleRequest); + } + return builder.build(); + } + + private Mono onlyAllowPost(ServerRequest request) { + return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).headers(this::onlyAllowPost).build(); + } + + private void onlyAllowPost(HttpHeaders headers) { + headers.setAllow(Collections.singleton(HttpMethod.POST)); + } + + @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(); + logger.info(LogMessage.format("GraphQL endpoint WebSocket %s", 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/rsocket/GraphQlRSocketAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/rsocket/GraphQlRSocketAutoConfiguration.java new file mode 100644 index 0000000000..fc44994770 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/rsocket/GraphQlRSocketAutoConfiguration.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-2022 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.rsocket; + +import java.util.List; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.databind.ObjectMapper; +import graphql.GraphQL; +import io.rsocket.core.RSocketServer; +import reactor.netty.http.server.HttpServer; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.rsocket.RSocketMessagingAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.graphql.ExecutionGraphQlService; +import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer; +import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.server.GraphQlRSocketHandler; +import org.springframework.graphql.server.RSocketGraphQlInterceptor; +import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.messaging.rsocket.annotation.support.RSocketMessageHandler; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for enabling Spring GraphQL over + * RSocket. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@AutoConfiguration(after = { GraphQlAutoConfiguration.class, RSocketMessagingAutoConfiguration.class }) +@ConditionalOnClass({ GraphQL.class, GraphQlSource.class, RSocketServer.class, HttpServer.class }) +@ConditionalOnBean({ RSocketMessageHandler.class, AnnotatedControllerConfigurer.class }) +@ConditionalOnProperty(prefix = "spring.graphql.rsocket", name = "mapping") +public class GraphQlRSocketAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public GraphQlRSocketHandler graphQlRSocketHandler(ExecutionGraphQlService graphQlService, + ObjectProvider interceptorsProvider, ObjectMapper objectMapper) { + List interceptors = interceptorsProvider.orderedStream() + .collect(Collectors.toList()); + return new GraphQlRSocketHandler(graphQlService, interceptors, new Jackson2JsonEncoder(objectMapper)); + } + + @Bean + @ConditionalOnMissingBean + public GraphQlRSocketController graphQlRSocketController(GraphQlRSocketHandler handler) { + return new GraphQlRSocketController(handler); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/rsocket/GraphQlRSocketController.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/rsocket/GraphQlRSocketController.java new file mode 100644 index 0000000000..6a416901d2 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/rsocket/GraphQlRSocketController.java @@ -0,0 +1,47 @@ +/* + * Copyright 2012-2022 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.rsocket; + +import java.util.Map; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.graphql.server.GraphQlRSocketHandler; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.stereotype.Controller; + +@Controller +class GraphQlRSocketController { + + private final GraphQlRSocketHandler handler; + + GraphQlRSocketController(GraphQlRSocketHandler handler) { + this.handler = handler; + } + + @MessageMapping("${spring.graphql.rsocket.mapping}") + Mono> handle(Map payload) { + return this.handler.handle(payload); + } + + @MessageMapping("${spring.graphql.rsocket.mapping}") + Flux> handleSubscription(Map payload) { + return this.handler.handleSubscription(payload); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/rsocket/RSocketGraphQlClientAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/rsocket/RSocketGraphQlClientAutoConfiguration.java new file mode 100644 index 0000000000..1876e0958c --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/rsocket/RSocketGraphQlClientAutoConfiguration.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-2022 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.rsocket; + +import graphql.GraphQL; +import io.rsocket.RSocket; +import io.rsocket.transport.netty.client.TcpClientTransport; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.rsocket.RSocketRequesterAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Scope; +import org.springframework.graphql.client.RSocketGraphQlClient; +import org.springframework.messaging.rsocket.RSocketRequester; +import org.springframework.util.MimeTypeUtils; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for {@link RSocketGraphQlClient}. + * This auto-configuration creates + * {@link org.springframework.graphql.client.RSocketGraphQlClient.Builder + * RSocketGraphQlClient.Builder} prototype beans, as the builders are stateful and should + * not be reused to build client instances with different configurations. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@AutoConfiguration(after = RSocketRequesterAutoConfiguration.class) +@ConditionalOnClass({ GraphQL.class, RSocketGraphQlClient.class, RSocketRequester.class, RSocket.class, + TcpClientTransport.class }) +public class RSocketGraphQlClientAutoConfiguration { + + @Bean + @Scope("prototype") + @ConditionalOnMissingBean + public RSocketGraphQlClient.Builder rsocketGraphQlClientBuilder( + RSocketRequester.Builder rsocketRequesterBuilder) { + return RSocketGraphQlClient.builder(rsocketRequesterBuilder.dataMimeType(MimeTypeUtils.APPLICATION_JSON)); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/rsocket/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/rsocket/package-info.java new file mode 100644 index 0000000000..0beb325711 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/rsocket/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2020-2022 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 RSocket integration with GraphQL. + */ +package org.springframework.boot.autoconfigure.graphql.rsocket; 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..776c293184 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebFluxSecurityAutoConfiguration.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2022 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.AutoConfiguration; +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.graphql.execution.ReactiveSecurityDataFetcherExceptionResolver; +import org.springframework.graphql.server.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 + */ +@AutoConfiguration(after = GraphQlWebFluxAutoConfiguration.class) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +@ConditionalOnClass({ GraphQL.class, GraphQlHttpHandler.class, EnableWebFluxSecurity.class }) +@ConditionalOnBean(GraphQlHttpHandler.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..a5f3bc7481 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebMvcSecurityAutoConfiguration.java @@ -0,0 +1,59 @@ +/* + * Copyright 2012-2022 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.AutoConfiguration; +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.graphql.execution.SecurityContextThreadLocalAccessor; +import org.springframework.graphql.execution.SecurityDataFetcherExceptionResolver; +import org.springframework.graphql.server.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 + */ +@AutoConfiguration(after = GraphQlWebMvcAutoConfiguration.class) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnClass({ GraphQL.class, GraphQlHttpHandler.class, EnableWebSecurity.class }) +@ConditionalOnBean(GraphQlHttpHandler.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..015d616e19 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java @@ -0,0 +1,201 @@ +/* + * Copyright 2012-2022 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 graphql.GraphQL; +import jakarta.websocket.server.ServerContainer; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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.annotation.Order; +import org.springframework.core.log.LogMessage; +import org.springframework.graphql.ExecutionGraphQlService; +import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.execution.ThreadLocalAccessor; +import org.springframework.graphql.server.WebGraphQlHandler; +import org.springframework.graphql.server.WebGraphQlInterceptor; +import org.springframework.graphql.server.webmvc.GraphQlHttpHandler; +import org.springframework.graphql.server.webmvc.GraphQlWebSocketHandler; +import org.springframework.graphql.server.webmvc.GraphiQlHandler; +import org.springframework.graphql.server.webmvc.SchemaHandler; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.GenericHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +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.ServerRequest; +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; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for enabling Spring GraphQL over + * Spring MVC. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@AutoConfiguration(after = GraphQlAutoConfiguration.class) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnClass({ GraphQL.class, GraphQlHttpHandler.class }) +@ConditionalOnBean(ExecutionGraphQlService.class) +@EnableConfigurationProperties(GraphQlCorsProperties.class) +public class GraphQlWebMvcAutoConfiguration { + + private static final Log logger = LogFactory.getLog(GraphQlWebMvcAutoConfiguration.class); + + private static MediaType[] SUPPORTED_MEDIA_TYPES = new MediaType[] { MediaType.APPLICATION_GRAPHQL, + MediaType.APPLICATION_JSON }; + + @Bean + @ConditionalOnMissingBean + public GraphQlHttpHandler graphQlHttpHandler(WebGraphQlHandler webGraphQlHandler) { + return new GraphQlHttpHandler(webGraphQlHandler); + } + + @Bean + @ConditionalOnMissingBean + public WebGraphQlHandler webGraphQlHandler(ExecutionGraphQlService service, + ObjectProvider interceptorsProvider, + ObjectProvider accessorsProvider) { + return WebGraphQlHandler.builder(service) + .interceptors(interceptorsProvider.orderedStream().collect(Collectors.toList())) + .threadLocalAccessors(accessorsProvider.orderedStream().collect(Collectors.toList())).build(); + } + + @Bean + @Order(0) + public RouterFunction graphQlRouterFunction(GraphQlHttpHandler httpHandler, + GraphQlSource graphQlSource, GraphQlProperties properties) { + String path = properties.getPath(); + logger.info(LogMessage.format("GraphQL endpoint HTTP POST %s", path)); + RouterFunctions.Builder builder = RouterFunctions.route(); + builder = builder.GET(path, this::onlyAllowPost); + builder = builder.POST(path, RequestPredicates.contentType(SUPPORTED_MEDIA_TYPES) + .and(RequestPredicates.accept(SUPPORTED_MEDIA_TYPES)), httpHandler::handleRequest); + if (properties.getGraphiql().isEnabled()) { + GraphiQlHandler graphiQLHandler = new GraphiQlHandler(path, properties.getWebsocket().getPath()); + builder = builder.GET(properties.getGraphiql().getPath(), graphiQLHandler::handleRequest); + } + if (properties.getSchema().getPrinter().isEnabled()) { + SchemaHandler schemaHandler = new SchemaHandler(graphQlSource); + builder = builder.GET(path + "/schema", schemaHandler::handleRequest); + } + return builder.build(); + } + + private ServerResponse onlyAllowPost(ServerRequest request) { + return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).headers(this::onlyAllowPost).build(); + } + + private void onlyAllowPost(HttpHeaders headers) { + headers.setAllow(Collections.singleton(HttpMethod.POST)); + } + + @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()); + } + + private GenericHttpMessageConverter getJsonConverter(HttpMessageConverters converters) { + return converters.getConverters().stream().filter(this::canReadJsonMap).findFirst() + .map(this::asGenericHttpMessageConverter) + .orElseThrow(() -> new IllegalStateException("No JSON converter")); + } + + private boolean canReadJsonMap(HttpMessageConverter candidate) { + return candidate.canRead(Map.class, MediaType.APPLICATION_JSON); + } + + @SuppressWarnings("unchecked") + private GenericHttpMessageConverter asGenericHttpMessageConverter(HttpMessageConverter converter) { + return (GenericHttpMessageConverter) converter; + } + + @Bean + public HandlerMapping graphQlWebSocketMapping(GraphQlWebSocketHandler handler, GraphQlProperties properties) { + String path = properties.getWebsocket().getPath(); + logger.info(LogMessage.format("GraphQL endpoint WebSocket %s", path)); + WebSocketHandlerMapping mapping = new WebSocketHandlerMapping(); + mapping.setWebSocketUpgradeMatch(true); + mapping.setUrlMap(Collections.singletonMap(path, + handler.asWebSocketHttpRequestHandler(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 1344bf6b46..e608e7924c 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 @@ -1162,6 +1162,14 @@ "level": "error" } }, + { + "name": "spring.graphql.schema.file-extensions", + "defaultValue": ".graphqls,.gqls" + }, + { + "name": "spring.graphql.schema.locations", + "defaultValue": "classpath:graphql/**/" + }, { "name": "spring.groovy.template.prefix", "defaultValue": "" @@ -2422,6 +2430,56 @@ } ] }, + { + "name": "spring.datasource.xa.data-source-class-name", + "providers": [ + { + "name": "class-reference", + "parameters": { + "target": "javax.sql.XADataSource" + } + } + ] + }, + { + "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/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 139e872652..4323300b78 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -44,6 +44,17 @@ org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchRestClientAuto org.springframework.boot.autoconfigure.elasticsearch.ReactiveElasticsearchClientAutoConfiguration 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.rsocket.GraphQlRSocketAutoConfiguration +org.springframework.boot.autoconfigure.graphql.rsocket.RSocketGraphQlClientAutoConfiguration +org.springframework.boot.autoconfigure.graphql.security.GraphQlWebFluxSecurityAutoConfiguration +org.springframework.boot.autoconfigure.graphql.security.GraphQlWebMvcSecurityAutoConfiguration +org.springframework.boot.autoconfigure.graphql.servlet.GraphQlWebMvcAutoConfiguration org.springframework.boot.autoconfigure.groovy.template.GroovyTemplateAutoConfiguration org.springframework.boot.autoconfigure.gson.GsonAutoConfiguration org.springframework.boot.autoconfigure.h2.H2ConsoleAutoConfiguration diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/Book.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/Book.java new file mode 100644 index 0000000000..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/DefaultGraphQlSchemaConditionTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/DefaultGraphQlSchemaConditionTests.java new file mode 100644 index 0000000000..cd8391f723 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/DefaultGraphQlSchemaConditionTests.java @@ -0,0 +1,105 @@ +/* + * Copyright 2012-2022 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.Collection; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport; +import org.springframework.boot.test.context.assertj.AssertableApplicationContext; +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 ConditionalOnGraphQlSchema}. + * + * @author Brian Clozel + */ +class DefaultGraphQlSchemaConditionTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner(); + + @Test + void matchesWhenSchemaFilesAreDetected() { + this.contextRunner.withUserConfiguration(TestingConfiguration.class).run((context) -> { + didMatch(context); + assertThat(conditionReportMessage(context)).contains("@ConditionalOnGraphQlSchema found schemas") + .contains("@ConditionalOnGraphQlSchema did not find GraphQlSourceBuilderCustomizer"); + }); + } + + @Test + void matchesWhenCustomizerIsDetected() { + this.contextRunner.withUserConfiguration(CustomCustomizerConfiguration.class, TestingConfiguration.class) + .withPropertyValues("spring.graphql.schema.locations=classpath:graphql/missing").run((context) -> { + didMatch(context); + assertThat(conditionReportMessage(context)).contains( + "@ConditionalOnGraphQlSchema did not find schema files in locations 'classpath:graphql/missing/'") + .contains("@ConditionalOnGraphQlSchema found customizer myBuilderCuystomizer"); + }); + } + + @Test + void doesNotMatchWhenBothAreMissing() { + this.contextRunner.withUserConfiguration(TestingConfiguration.class) + .withPropertyValues("spring.graphql.schema.locations=classpath:graphql/missing").run((context) -> { + assertThat(context).doesNotHaveBean("success"); + assertThat(conditionReportMessage(context)).contains( + "@ConditionalOnGraphQlSchema did not find schema files in locations 'classpath:graphql/missing/'") + .contains("@ConditionalOnGraphQlSchema did not find GraphQlSourceBuilderCustomizer"); + }); + } + + private void didMatch(AssertableApplicationContext context) { + assertThat(context).hasBean("success"); + assertThat(context.getBean("success")).isEqualTo("success"); + } + + private String conditionReportMessage(AssertableApplicationContext context) { + Collection conditionAndOutcomes = ConditionEvaluationReport + .get(context.getSourceApplicationContext().getBeanFactory()).getConditionAndOutcomesBySource().values(); + return conditionAndOutcomes.iterator().next().iterator().next().getOutcome().getMessage(); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnGraphQlSchema + static class TestingConfiguration { + + @Bean + String success() { + return "success"; + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomCustomizerConfiguration { + + @Bean + GraphQlSourceBuilderCustomizer myBuilderCuystomizer() { + return (builder) -> { + + }; + } + + } + +} 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..662d442934 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/GraphQlAutoConfigurationTests.java @@ -0,0 +1,270 @@ +/* + * Copyright 2012-2022 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.nio.charset.StandardCharsets; + +import graphql.GraphQL; +import graphql.execution.instrumentation.ChainedInstrumentation; +import graphql.execution.instrumentation.Instrumentation; +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.RuntimeWiring; +import graphql.schema.visibility.DefaultGraphqlFieldVisibility; +import graphql.schema.visibility.NoIntrospectionGraphqlFieldVisibility; +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.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.graphql.ExecutionGraphQlService; +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.DataLoaderRegistrar; +import org.springframework.graphql.execution.GraphQlSource; +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(ExecutionGraphQlService.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 shouldBackoffWhenSchemaFileIsMissing() { + this.contextRunner.withPropertyValues("spring.graphql.schema.locations:classpath:missing/") + .run((context) -> assertThat(context).hasNotFailed().doesNotHaveBean(GraphQlSource.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(); + }); + } + + @Test + void fieldIntrospectionShouldBeEnabledByDefault() { + this.contextRunner.run((context) -> { + GraphQlSource graphQlSource = context.getBean(GraphQlSource.class); + GraphQLSchema schema = graphQlSource.schema(); + assertThat(schema.getCodeRegistry().getFieldVisibility()).isInstanceOf(DefaultGraphqlFieldVisibility.class); + }); + } + + @Test + void shouldDisableFieldIntrospection() { + this.contextRunner.withPropertyValues("spring.graphql.schema.introspection.enabled:false").run((context) -> { + GraphQlSource graphQlSource = context.getBean(GraphQlSource.class); + GraphQLSchema schema = graphQlSource.schema(); + assertThat(schema.getCodeRegistry().getFieldVisibility()) + .isInstanceOf(NoIntrospectionGraphqlFieldVisibility.class); + }); + } + + @Test + void shouldConfigureCustomBatchLoaderRegistry() { + this.contextRunner + .withBean("customBatchLoaderRegistry", BatchLoaderRegistry.class, () -> mock(BatchLoaderRegistry.class)) + .run((context) -> { + assertThat(context).hasSingleBean(BatchLoaderRegistry.class); + assertThat(context.getBean("customBatchLoaderRegistry")) + .isSameAs(context.getBean(BatchLoaderRegistry.class)); + assertThat(context.getBean(ExecutionGraphQlService.class)) + .extracting("dataLoaderRegistrars", + InstanceOfAssertFactories.list(DataLoaderRegistrar.class)) + .containsOnly(context.getBean(BatchLoaderRegistry.class)); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomGraphQlBuilderConfiguration { + + @Bean + GraphQlSource.SchemaResourceBuilder customGraphQlSourceBuilder() { + return GraphQlSource.schemaResourceBuilder().schemaResources( + new ClassPathResource("graphql/schema.graphqls"), + new ClassPathResource("graphql/types/book.graphqls")); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomGraphQlSourceConfiguration { + + @Bean + GraphQlSource customGraphQlSource() { + ByteArrayResource schemaResource = new ByteArrayResource( + "type Query { greeting: String }".getBytes(StandardCharsets.UTF_8)); + return GraphQlSource.schemaResourceBuilder().schemaResources(schemaResource).build(); + } + + } + + @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.SchemaResourceBuilder 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..036c759f0a --- /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/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..a8c4c1d6f8 --- /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-2022 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.ExecutionGraphQlService; +import org.springframework.graphql.data.GraphQlRepository; +import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester; + +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) -> { + ExecutionGraphQlService graphQlService = context.getBean(ExecutionGraphQlService.class); + ExecutionGraphQlServiceTester graphQlTester = ExecutionGraphQlServiceTester.create(graphQlService); + graphQlTester.document("{ 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..27e4b5cb0a --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlQuerydslAutoConfigurationTests.java @@ -0,0 +1,82 @@ +/* + * Copyright 2012-2022 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.ExecutionGraphQlService; +import org.springframework.graphql.data.GraphQlRepository; +import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester; +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) -> { + ExecutionGraphQlService graphQlService = context.getBean(ExecutionGraphQlService.class); + GraphQlTester graphQlTester = ExecutionGraphQlServiceTester.create(graphQlService); + graphQlTester.document("{ 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..48c9be1cab --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQueryByExampleAutoConfigurationTests.java @@ -0,0 +1,81 @@ +/* + * Copyright 2012-2022 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.ExecutionGraphQlService; +import org.springframework.graphql.data.GraphQlRepository; +import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester; +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) -> { + ExecutionGraphQlService graphQlService = context.getBean(ExecutionGraphQlService.class); + GraphQlTester graphQlTester = ExecutionGraphQlServiceTester.create(graphQlService); + graphQlTester.document("{ 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..d2a33df2fc --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/data/GraphQlReactiveQuerydslAutoConfigurationTests.java @@ -0,0 +1,81 @@ +/* + * Copyright 2012-2022 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.ExecutionGraphQlService; +import org.springframework.graphql.data.GraphQlRepository; +import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester; +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) -> { + ExecutionGraphQlService graphQlService = context.getBean(ExecutionGraphQlService.class); + GraphQlTester graphQlTester = ExecutionGraphQlServiceTester.create(graphQlService); + graphQlTester.document("{ 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..2c498b56f9 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java @@ -0,0 +1,214 @@ +/* + * Copyright 2012-2022 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.Map; +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.core.annotation.Order; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.server.WebGraphQlHandler; +import org.springframework.graphql.server.WebGraphQlInterceptor; +import org.springframework.graphql.server.webflux.GraphQlHttpHandler; +import org.springframework.graphql.server.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 org.springframework.web.reactive.function.server.RouterFunction; + +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() + .expectHeader().contentType("application/json").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)); + } + + @Test + void routerFunctionShouldHaveOrderZero() throws Exception { + this.contextRunner.withUserConfiguration(CustomRouterFunctions.class).run((context) -> { + Map beans = context.getBeansOfType(RouterFunction.class); + Object[] ordered = context.getBeanProvider(RouterFunction.class).orderedStream().toArray(); + assertThat(beans.get("before")).isSameAs(ordered[0]); + assertThat(beans.get("graphQlRouterFunction")).isSameAs(ordered[1]); + assertThat(beans.get("after")).isSameAs(ordered[2]); + }); + } + + 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 + WebGraphQlInterceptor customWebGraphQlInterceptor() { + return (webInput, interceptorChain) -> interceptorChain.next(webInput) + .doOnNext((output) -> output.getResponseHeaders().add("X-Custom-Header", "42")); + } + + } + + @Configuration + static class CustomRouterFunctions { + + @Bean + @Order(-1) + RouterFunction before() { + return (r) -> null; + } + + @Bean + @Order(1) + RouterFunction after() { + return (r) -> null; + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/rsocket/GraphQlRSocketAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/rsocket/GraphQlRSocketAutoConfigurationTests.java new file mode 100644 index 0000000000..827a47e495 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/rsocket/GraphQlRSocketAutoConfigurationTests.java @@ -0,0 +1,150 @@ +/* + * Copyright 2012-2022 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.rsocket; + +import java.net.URI; +import java.time.Duration; +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.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; +import org.springframework.boot.autoconfigure.graphql.GraphQlTestDataFetchers; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.rsocket.RSocketMessagingAutoConfiguration; +import org.springframework.boot.autoconfigure.rsocket.RSocketServerAutoConfiguration; +import org.springframework.boot.autoconfigure.rsocket.RSocketStrategiesAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.error.ErrorWebFluxAutoConfiguration; +import org.springframework.boot.rsocket.context.RSocketPortInfoApplicationContextInitializer; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.web.context.ServerPortInfoApplicationContextInitializer; +import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory; +import org.springframework.boot.web.embedded.netty.NettyRouteProvider; +import org.springframework.boot.web.reactive.context.AnnotationConfigReactiveWebServerApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.client.RSocketGraphQlClient; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.server.GraphQlRSocketHandler; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GraphQlRSocketAutoConfiguration} + * + * @author Brian Clozel + */ +class GraphQlRSocketAutoConfigurationTests { + + private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(JacksonAutoConfiguration.class, RSocketStrategiesAutoConfiguration.class, + RSocketMessagingAutoConfiguration.class, RSocketServerAutoConfiguration.class, + GraphQlAutoConfiguration.class, GraphQlRSocketAutoConfiguration.class)) + .withUserConfiguration(DataFetchersConfiguration.class) + .withPropertyValues("spring.main.web-application-type=reactive", "spring.graphql.rsocket.mapping=graphql"); + + @Test + void shouldContributeDefaultBeans() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(GraphQlRSocketHandler.class) + .hasSingleBean(GraphQlRSocketController.class)); + } + + @Test + void simpleQueryShouldWorkWithTcpServer() { + testWithRSocketTcp(this::assertThatSimpleQueryWorks); + } + + @Test + void simpleQueryShouldWorkWithWebSocketServer() { + testWithRSocketWebSocket(this::assertThatSimpleQueryWorks); + } + + private void assertThatSimpleQueryWorks(RSocketGraphQlClient client) { + String document = "{ bookById(id: \"book-1\"){ id name pageCount author } }"; + String bookName = client.document(document).retrieve("bookById.name").toEntity(String.class) + .block(Duration.ofSeconds(5)); + assertThat(bookName).isEqualTo("GraphQL for beginners"); + } + + private void testWithRSocketTcp(Consumer consumer) { + ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(JacksonAutoConfiguration.class, RSocketStrategiesAutoConfiguration.class, + RSocketMessagingAutoConfiguration.class, RSocketServerAutoConfiguration.class, + GraphQlAutoConfiguration.class, GraphQlRSocketAutoConfiguration.class)) + .withUserConfiguration(DataFetchersConfiguration.class).withPropertyValues( + "spring.main.web-application-type=reactive", "spring.graphql.rsocket.mapping=graphql"); + contextRunner.withInitializer(new RSocketPortInfoApplicationContextInitializer()) + .withPropertyValues("spring.rsocket.server.port=0").run((context) -> { + String serverPort = context.getEnvironment().getProperty("local.rsocket.server.port"); + RSocketGraphQlClient client = RSocketGraphQlClient.builder() + .tcp("localhost", Integer.parseInt(serverPort)).route("graphql").build(); + consumer.accept(client); + }); + } + + private void testWithRSocketWebSocket(Consumer consumer) { + ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner( + AnnotationConfigReactiveWebServerApplicationContext::new).withConfiguration( + AutoConfigurations.of(HttpHandlerAutoConfiguration.class, WebFluxAutoConfiguration.class, + ErrorWebFluxAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class, + JacksonAutoConfiguration.class, RSocketStrategiesAutoConfiguration.class, + RSocketMessagingAutoConfiguration.class, RSocketServerAutoConfiguration.class, + GraphQlAutoConfiguration.class, GraphQlRSocketAutoConfiguration.class)) + .withInitializer(new ServerPortInfoApplicationContextInitializer()) + .withUserConfiguration(DataFetchersConfiguration.class, NettyServerConfiguration.class) + .withPropertyValues("spring.main.web-application-type=reactive", "server.port=0", + "spring.graphql.rsocket.mapping=graphql", "spring.rsocket.server.transport=websocket", + "spring.rsocket.server.mapping-path=/rsocket"); + contextRunner.run((context) -> { + String serverPort = context.getEnvironment().getProperty("local.server.port"); + RSocketGraphQlClient client = RSocketGraphQlClient.builder() + .webSocket(URI.create("ws://localhost:" + serverPort + "/rsocket")).route("graphql").build(); + consumer.accept(client); + }); + } + + @Configuration(proxyBeanMethods = false) + static class NettyServerConfiguration { + + @Bean + NettyReactiveWebServerFactory serverFactory(NettyRouteProvider routeProvider) { + NettyReactiveWebServerFactory serverFactory = new NettyReactiveWebServerFactory(0); + serverFactory.addRouteProviders(routeProvider); + return serverFactory; + } + + } + + @Configuration(proxyBeanMethods = false) + static class DataFetchersConfiguration { + + @Bean + RuntimeWiringConfigurer bookDataFetcher() { + return (builder) -> builder.type(TypeRuntimeWiring.newTypeWiring("Query").dataFetcher("bookById", + GraphQlTestDataFetchers.getBookByIdDataFetcher())); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/rsocket/RSocketGraphQlClientAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/rsocket/RSocketGraphQlClientAutoConfigurationTests.java new file mode 100644 index 0000000000..f46a2d25d5 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/rsocket/RSocketGraphQlClientAutoConfigurationTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-2022 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.rsocket; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.rsocket.RSocketRequesterAutoConfiguration; +import org.springframework.boot.autoconfigure.rsocket.RSocketStrategiesAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.client.RSocketGraphQlClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link RSocketGraphQlClientAutoConfiguration}. + * + * @author Brian Clozel + */ +class RSocketGraphQlClientAutoConfigurationTests { + + private static final RSocketGraphQlClient.Builder builderInstance = RSocketGraphQlClient.builder(); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(RSocketStrategiesAutoConfiguration.class, + RSocketRequesterAutoConfiguration.class, RSocketGraphQlClientAutoConfiguration.class)); + + @Test + void shouldCreateBuilder() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(RSocketGraphQlClient.Builder.class)); + } + + @Test + void shouldGetPrototypeScopedBean() { + this.contextRunner.run((context) -> { + RSocketGraphQlClient.Builder first = context.getBean(RSocketGraphQlClient.Builder.class); + RSocketGraphQlClient.Builder second = context.getBean(RSocketGraphQlClient.Builder.class); + assertThat(first).isNotEqualTo(second); + }); + } + + @Test + void shouldNotCreateBuilderIfAlreadyPresent() { + this.contextRunner.withUserConfiguration(CustomRSocketGraphQlClientBuilder.class).run((context) -> { + RSocketGraphQlClient.Builder builder = context.getBean(RSocketGraphQlClient.Builder.class); + assertThat(builder).isEqualTo(builderInstance); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomRSocketGraphQlClientBuilder { + + @Bean + RSocketGraphQlClient.Builder myRSocketGraphQlClientBuilder() { + return builderInstance; + } + + } + +} 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..30ded29aed --- /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-2022 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.ReactiveSecurityDataFetcherExceptionResolver; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +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) { + 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..fa33b55439 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/security/GraphQlWebMvcSecurityAutoConfigurationTests.java @@ -0,0 +1,180 @@ +/* + * Copyright 2020-2022 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.execution.SecurityContextThreadLocalAccessor; +import org.springframework.graphql.execution.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) + @SuppressWarnings("deprecation") + 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 + 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..285b5e2fd2 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java @@ -0,0 +1,225 @@ +/* + * Copyright 2012-2022 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.Map; + +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.core.annotation.Order; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.server.WebGraphQlHandler; +import org.springframework.graphql.server.WebGraphQlInterceptor; +import org.springframework.graphql.server.webmvc.GraphQlHttpHandler; +import org.springframework.graphql.server.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 org.springframework.web.servlet.function.RouterFunction; + +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_GRAPHQL)) + .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)); + } + + @Test + void routerFunctionShouldHaveOrderZero() throws Exception { + this.contextRunner.withUserConfiguration(CustomRouterFunctions.class).run((context) -> { + Map beans = context.getBeansOfType(RouterFunction.class); + Object[] ordered = context.getBeanProvider(RouterFunction.class).orderedStream().toArray(); + assertThat(beans.get("before")).isSameAs(ordered[0]); + assertThat(beans.get("graphQlRouterFunction")).isSameAs(ordered[1]); + assertThat(beans.get("after")).isSameAs(ordered[2]); + }); + } + + private void testWith(MockMvcConsumer mockMvcConsumer) { + this.contextRunner.run((context) -> { + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context).defaultRequest( + post("/graphql").contentType(MediaType.APPLICATION_GRAPHQL).accept(MediaType.APPLICATION_GRAPHQL)) + .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 + WebGraphQlInterceptor customWebGraphQlInterceptor() { + return (webInput, interceptorChain) -> interceptorChain.next(webInput) + .doOnNext((output) -> output.getResponseHeaders().add("X-Custom-Header", "42")); + } + + } + + @Configuration + static class CustomRouterFunctions { + + @Bean + @Order(-1) + RouterFunction before() { + return (r) -> null; + } + + @Bean + @Order(1) + RouterFunction after() { + return (r) -> null; + } + + } + +} 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 8585f95df1..0cae206daa 100644 --- a/spring-boot-project/spring-boot-dependencies/build.gradle +++ b/spring-boot-project/spring-boot-dependencies/build.gradle @@ -295,6 +295,13 @@ bom { ] } } + library("GraphQL Java", "19.0") { + group("com.graphql-java") { + modules = [ + "graphql-java" + ] + } + } library("Groovy", "4.0.3") { group("org.apache.groovy") { imports = [ @@ -1281,6 +1288,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", @@ -1415,6 +1423,14 @@ bom { ] } } + library("Spring GraphQL", "1.1.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/resources/org/springframework/boot/devtools/env/devtools-property-defaults.properties b/spring-boot-project/spring-boot-devtools/src/main/resources/org/springframework/boot/devtools/env/devtools-property-defaults.properties index 23639ad0fb..e7d0dafe62 100644 --- a/spring-boot-project/spring-boot-devtools/src/main/resources/org/springframework/boot/devtools/env/devtools-property-defaults.properties +++ b/spring-boot-project/spring-boot-devtools/src/main/resources/org/springframework/boot/devtools/env/devtools-property-defaults.properties @@ -4,6 +4,7 @@ server.error.include-stacktrace=always server.servlet.jsp.init-parameters.development=true server.servlet.session.persistent=true spring.freemarker.cache=false +spring.graphql.graphiql.enabled=true spring.groovy.template.cache=false spring.h2.console.enabled=true spring.mustache.servlet.cache=false diff --git a/spring-boot-project/spring-boot-docs/build.gradle b/spring-boot-project/spring-boot-docs/build.gradle index e0748f1c4b..5c9417a646 100644 --- a/spring-boot-project/spring-boot-docs/build.gradle +++ b/spring-boot-project/spring-boot-docs/build.gradle @@ -137,6 +137,8 @@ dependencies { implementation("org.springframework.data:spring-data-neo4j") implementation("org.springframework.data:spring-data-redis") implementation("org.springframework.data:spring-data-r2dbc") + implementation("org.springframework.graphql:spring-graphql") + implementation("org.springframework.graphql:spring-graphql-test") implementation("org.springframework.kafka:spring-kafka") implementation("org.springframework.kafka:spring-kafka-test") implementation("org.springframework.restdocs:spring-restdocs-mockmvc") { @@ -270,8 +272,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"] } 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 69e8910956..b0bdbe2130 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 @@ -888,6 +888,55 @@ NOTE: Only caches that are configured on startup are bound to the registry. For caches not defined in the cache’s configuration, such as caches created on the fly or programmatically after the startup phase, an explicit registration is required. 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-trivia +This metric is useful for detecting "N+1" data fetching issues and considering batch loading; it provides the `"TOTAL"` number of data fetcher calls ma +More options are available for < + + org.springframework.graphql + spring-graphql-test + test + + + + org.springframework.boot + spring-boot-starter-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.boot:spring-boot-starter-webflux") + } +---- + +This testing module ships the {spring-graphql-docs}/#testing-graphqltester[GraphQlTester]. +spring-boot-project/spring-boot-docs/src/docs/asciidoc/features/testing.adoc + + [[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/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..452df0f4ae --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc @@ -0,0 +1,152 @@ +[[web.graphql]] +== Spring for GraphQL +If you want to build GraphQL applications, you can take advantage of Spring Boot's auto-configuration for {spring-graphql}[Spring for GraphQL]. +The Spring for 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 + +| `spring-boot-starter-rsocket` +| TCP, WebSocket +| Spring WebFlux on Reactor Netty +|=== + + + +[[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[] +---- + +NOTE: By default, https://spec.graphql.org/draft/#sec-Introspection[field introspection] will be allowed on the schema as it is required for tools such as GraphiQL. +If you wish to not expose information about the schema, you can disable introspection by setting configprop:spring.graphql.schema.introspection.enabled[] to `false`. + + + +[[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 detect `@Controller` classes with annotated handler methods and register those as `DataFetcher`s. +Here's a sample implementation for our greeting query with a `@Controller` class: + +include::code:GreetingController[] + + + +[[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.transports]] +=== Transports + + + +[[web.graphql.transports.http-websocket]] +==== HTTP and WebSocket +The GraphQL HTTP endpoint is at HTTP POST "/graphql" by default. +The path can be customized with configprop:spring.graphql.path[]. + +TIP: The HTTP endpoint for both Spring MVC and Spring WebFlux is provided by a `RouterFunction` bean with an `@Order` of `0`. +If you define your own `RouterFunction` beans, you may want to add appropriate `@Order` annotations to ensure that they are sorted correctly. + +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. + + +{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.transports.rsocket]] +==== RSocket +RSocket is also supported as a transport, on top of WebSocket or TCP. +Once the <>, we can configure our GraphQL handler on a particular route using configprop:spring.graphql.rsocket.mapping[]. +For example, configuring that mapping as `"graphql"` means we can use that as a route when sending requests with the `RSocketGraphQlClient`. + +Spring Boot auto-configures a `RSocketGraphQlClient.Builder` bean that you can inject in your components: + +include::code:RSocketGraphQlClientExample[tag=builder] + +And then send a request: +include::code:RSocketGraphQlClientExample[tag=request] + + + +[[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/GraphQlIntegrationTests.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/springgraphqltests/GraphQlIntegrationTests.java new file mode 100644 index 0000000000..2ff2a08ea6 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/features/testing/springbootapplications/springgraphqltests/GraphQlIntegrationTests.java @@ -0,0 +1,40 @@ +/* + * Copyright 2012-2022 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.test.autoconfigure.graphql.tester.AutoConfigureHttpGraphQlTester; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.graphql.test.tester.HttpGraphQlTester; + +@AutoConfigureHttpGraphQlTester +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +class GraphQlIntegrationTests { + + @Test + void shouldGreetWithSpecificName(@Autowired HttpGraphQlTester graphQlTester) { + HttpGraphQlTester authenticatedTester = graphQlTester.mutate() + .webTestClient( + (client) -> client.defaultHeaders((headers) -> headers.setBasicAuth("admin", "ilovespring"))) + .build(); + authenticatedTester.document("{ greeting(name: \"Alice\") } ").execute().path("greeting").entity(String.class) + .isEqualTo("Hello, Alice!"); + } + +} 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..951bd126d0 --- /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-2022 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.runtimewiring.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.document("{ greeting(name: \"Alice\") } ").execute().path("greeting").entity(String.class) + .isEqualTo("Hello, Alice!"); + } + + @Test + void shouldGreetWithDefaultName() { + this.graphQlTester.document("{ 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/runtimewiring/GreetingController.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/graphql/runtimewiring/GreetingController.java new file mode 100644 index 0000000000..b25af8d050 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/graphql/runtimewiring/GreetingController.java @@ -0,0 +1,31 @@ +/* + * Copyright 2002-2022 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.runtimewiring; + +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/java/org/springframework/boot/docs/web/graphql/transports/rsocket/RSocketGraphQlClientExample.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/graphql/transports/rsocket/RSocketGraphQlClientExample.java new file mode 100644 index 0000000000..9df5ff0c53 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/web/graphql/transports/rsocket/RSocketGraphQlClientExample.java @@ -0,0 +1,49 @@ +/* + * Copyright 2012-2022 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.transports.rsocket; + +import java.time.Duration; + +import reactor.core.publisher.Mono; + +import org.springframework.graphql.client.RSocketGraphQlClient; +import org.springframework.stereotype.Component; + +// tag::builder[] +@Component +public class RSocketGraphQlClientExample { + + private final RSocketGraphQlClient graphQlClient; + + public RSocketGraphQlClientExample(RSocketGraphQlClient.Builder builder) { + this.graphQlClient = builder.tcp("example.spring.io", 8181).route("graphql").build(); + } + // end::builder[] + + public void rsocketOverTcp() { + // tag::request[] + Mono book = this.graphQlClient.document("{ bookById(id: \"book-1\"){ id name pageCount author } }") + .retrieve("bookById").toEntity(Book.class); + // end::request[] + book.block(Duration.ofSeconds(5)); + } + + static class Book { + + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/springgraphqltests/GraphQlIntegrationTests.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/springgraphqltests/GraphQlIntegrationTests.kt new file mode 100644 index 0000000000..f8b0f3ea09 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/springgraphqltests/GraphQlIntegrationTests.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2022 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.test.autoconfigure.graphql.tester.AutoConfigureHttpGraphQlTester +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.graphql.test.tester.HttpGraphQlTester +import org.springframework.http.HttpHeaders +import org.springframework.test.web.reactive.server.WebTestClient + +@AutoConfigureHttpGraphQlTester +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +class GraphQlIntegrationTests { + + @Test + fun shouldGreetWithSpecificName(@Autowired graphQlTester: HttpGraphQlTester) { + val authenticatedTester = graphQlTester.mutate() + .webTestClient { client: WebTestClient.Builder -> + client.defaultHeaders { headers: HttpHeaders -> + headers.setBasicAuth("admin", "ilovespring") + } + }.build() + authenticatedTester.document("{ greeting(name: \"Alice\") } ").execute() + .path("greeting").entity(String::class.java).isEqualTo("Hello, Alice!") + } +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/springgraphqltests/GreetingControllerTests.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/springgraphqltests/GreetingControllerTests.kt new file mode 100644 index 0000000000..902d9cec26 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/features/testing/springbootapplications/springgraphqltests/GreetingControllerTests.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2022 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.runtimewiring.GreetingController +import org.springframework.boot.test.autoconfigure.graphql.GraphQlTest +import org.springframework.graphql.test.tester.GraphQlTester + +@GraphQlTest(GreetingController::class) +internal class GreetingControllerTests { + + @Autowired + lateinit var graphQlTester: GraphQlTester + + @Test + fun shouldGreetWithSpecificName() { + graphQlTester.document("{ greeting(name: \"Alice\") } ").execute().path("greeting").entity(String::class.java) + .isEqualTo("Hello, Alice!") + } + + @Test + fun shouldGreetWithDefaultName() { + graphQlTester.document("{ greeting } ").execute().path("greeting").entity(String::class.java) + .isEqualTo("Hello, Spring!") + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/graphql/runtimewiring/GreetingController.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/graphql/runtimewiring/GreetingController.kt new file mode 100644 index 0000000000..0e410ef66c --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/graphql/runtimewiring/GreetingController.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2012-2022 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.runtimewiring; + +import org.springframework.graphql.data.method.annotation.Argument +import org.springframework.graphql.data.method.annotation.QueryMapping +import org.springframework.stereotype.Controller + +@Controller +class GreetingController { + + @QueryMapping + fun greeting(@Argument name: String): String { + return "Hello, $name!" + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/graphql/transports/rsocket/RSocketGraphQlClientExample.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/graphql/transports/rsocket/RSocketGraphQlClientExample.kt new file mode 100644 index 0000000000..79988fe089 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/web/graphql/transports/rsocket/RSocketGraphQlClientExample.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2022 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.transports.rsocket + +import org.springframework.graphql.client.RSocketGraphQlClient +import org.springframework.stereotype.Component +import java.time.Duration + +// tag::builder[] +@Component +class RSocketGraphQlClientExample(private val builder: RSocketGraphQlClient.Builder<*>) { +// end::builder[] + + val graphQlClient = builder.tcp("example.spring.io", 8181) + .route("graphql") + .build() + + fun rsocketOverTcp() { + // tag::request[] + val book = graphQlClient.document( + """ + { + bookById(id: "book-1"){ + id + name + pageCount + author + } + } + """ + ) + .retrieve("bookById").toEntity(Book::class.java) + // end::request[] + book.block(Duration.ofSeconds(5)) + } + + internal class Book +} \ 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 cd58756e58..5f7e5d38bc 100644 --- a/spring-boot-project/spring-boot-test-autoconfigure/build.gradle +++ b/spring-boot-project/spring-boot-test-autoconfigure/build.gradle @@ -46,6 +46,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..1a72744bdc --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTest.java @@ -0,0 +1,157 @@ +/* + * Copyright 2020-2022 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.AutoConfigureHttpGraphQlTester; +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} + *
  • {@code DataFetcherExceptionResolver} + *
  • {@code Instrumentation} + *
  • {@code GraphQlSourceBuilderCustomizer} + *
+ *

+ * 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 HttpGraphQlTester}, consider using + * {@link org.springframework.boot.test.context.SpringBootTest @SpringBootTest} combined + * with {@link AutoConfigureHttpGraphQlTester @AutoConfigureHttpGraphQlTester}. + * + * @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}, {@code GenericConverter}, {@code DataFetcherExceptionResolver}, + * {@code Instrumentation} and {@code GraphQlSourceBuilderCustomizer} 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..d3376eec8e --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTypeExcludeFilter.java @@ -0,0 +1,100 @@ +/* + * Copyright 2020-2022 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 graphql.execution.instrumentation.Instrumentation; + +import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; +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.DataFetcherExceptionResolver; +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); + includes.add(DataFetcherExceptionResolver.class); + includes.add(Instrumentation.class); + includes.add(GraphQlSourceBuilderCustomizer.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/AutoConfigureHttpGraphQlTester.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/AutoConfigureHttpGraphQlTester.java new file mode 100644 index 0000000000..133db41f40 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/AutoConfigureHttpGraphQlTester.java @@ -0,0 +1,52 @@ +/* + * Copyright 2020-2022 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.HttpGraphQlTester; + +/** + * Annotation that can be applied to a test class to enable a {@link HttpGraphQlTester}. + * + *

+ * 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 HttpGraphQlTesterAutoConfiguration + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@AutoConfigureMockMvc +@AutoConfigureWebTestClient +@ImportAutoConfiguration +public @interface AutoConfigureHttpGraphQlTester { + +} 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..1ed2087bcb --- /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,61 @@ +/* + * Copyright 2012-2022 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 com.fasterxml.jackson.databind.ObjectMapper; +import graphql.GraphQL; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +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.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.graphql.ExecutionGraphQlService; +import org.springframework.graphql.test.tester.ExecutionGraphQlServiceTester; +import org.springframework.graphql.test.tester.GraphQlTester; +import org.springframework.http.MediaType; +import org.springframework.http.codec.json.Jackson2JsonDecoder; +import org.springframework.http.codec.json.Jackson2JsonEncoder; + +/** + * Auto-configuration for {@link GraphQlTester}. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@AutoConfiguration(after = { JacksonAutoConfiguration.class, GraphQlAutoConfiguration.class }) +@ConditionalOnClass({ GraphQL.class, GraphQlTester.class }) +public class GraphQlTesterAutoConfiguration { + + @Bean + @ConditionalOnBean(ExecutionGraphQlService.class) + @ConditionalOnMissingBean + public ExecutionGraphQlServiceTester graphQlTester(ExecutionGraphQlService graphQlService, + ObjectProvider objectMapperProvider) { + ExecutionGraphQlServiceTester.Builder builder = ExecutionGraphQlServiceTester.builder(graphQlService); + objectMapperProvider.ifAvailable((objectMapper) -> { + MediaType[] mediaTypes = new MediaType[] { MediaType.APPLICATION_JSON, MediaType.APPLICATION_GRAPHQL }; + builder.encoder(new Jackson2JsonEncoder(objectMapper, mediaTypes)); + builder.decoder(new Jackson2JsonDecoder(objectMapper, mediaTypes)); + }); + return builder.build(); + } + +} diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/HttpGraphQlTesterAutoConfiguration.java b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/HttpGraphQlTesterAutoConfiguration.java new file mode 100644 index 0000000000..953571a1c9 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/java/org/springframework/boot/test/autoconfigure/graphql/tester/HttpGraphQlTesterAutoConfiguration.java @@ -0,0 +1,50 @@ +/* + * Copyright 2012-2022 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.AutoConfiguration; +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.graphql.test.tester.HttpGraphQlTester; +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 HttpGraphQlTester}. + * + * @author Brian Clozel + * @since 2.7.0 + */ +@AutoConfiguration(after = { WebTestClientAutoConfiguration.class, MockMvcAutoConfiguration.class }) +@ConditionalOnClass({ WebClient.class, WebTestClient.class, WebGraphQlTester.class }) +public class HttpGraphQlTesterAutoConfiguration { + + @Bean + @ConditionalOnBean(WebTestClient.class) + @ConditionalOnMissingBean + public HttpGraphQlTester webTestClientGraphQlTester(WebTestClient webTestClient, GraphQlProperties properties) { + WebTestClient mutatedWebTestClient = webTestClient.mutate().baseUrl(properties.getPath()).build(); + return HttpGraphQlTester.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/org.springframework.boot.test.autoconfigure.graphql.AutoConfigureGraphQl.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.graphql.AutoConfigureGraphQl.imports new file mode 100644 index 0000000000..c6f0c3e4e0 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.graphql.AutoConfigureGraphQl.imports @@ -0,0 +1,4 @@ +# AutoConfigureGraphQl auto-configuration imports +org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration +org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration +org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureGraphQlTester.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureGraphQlTester.imports new file mode 100644 index 0000000000..bbc8368cbe --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureGraphQlTester.imports @@ -0,0 +1,2 @@ +# AutoConfigureGraphQlTester auto-configuration imports +org.springframework.boot.test.autoconfigure.graphql.tester.GraphQlTesterAutoConfiguration \ No newline at end of file diff --git a/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureHttpGraphQlTester.imports b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureHttpGraphQlTester.imports new file mode 100644 index 0000000000..78f1ffda4d --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.test.autoconfigure.graphql.tester.AutoConfigureHttpGraphQlTester.imports @@ -0,0 +1,2 @@ +# AutoConfigureHttpGraphQlTester auto-configuration imports +org.springframework.boot.test.autoconfigure.graphql.tester.HttpGraphQlTesterAutoConfiguration \ No newline at end of file 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..fe0cca64a2 --- /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-2022 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.document(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..a84ebd26c7 --- /dev/null +++ b/spring-boot-project/spring-boot-test-autoconfigure/src/test/java/org/springframework/boot/test/autoconfigure/graphql/GraphQlTypeExcludeFilterTests.java @@ -0,0 +1,227 @@ +/* + * Copyright 2012-2022 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 java.util.List; + +import com.fasterxml.jackson.databind.module.SimpleModule; +import graphql.GraphQLError; +import graphql.execution.instrumentation.Instrumentation; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.idl.RuntimeWiring; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.boot.autoconfigure.graphql.GraphQlSourceBuilderCustomizer; +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.DataFetcherExceptionResolver; +import org.springframework.graphql.execution.GraphQlSource; +import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.server.WebGraphQlInterceptor; +import org.springframework.graphql.server.WebGraphQlRequest; +import org.springframework.graphql.server.WebGraphQlResponse; +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(); + assertThat(excludes(filter, ExampleDataFetcherExceptionResolver.class)).isFalse(); + assertThat(excludes(filter, ExampleInstrumentation.class)).isFalse(); + assertThat(excludes(filter, ExampleGraphQlSourceBuilderCustomizer.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(); + assertThat(excludes(filter, ExampleDataFetcherExceptionResolver.class)).isFalse(); + assertThat(excludes(filter, ExampleInstrumentation.class)).isFalse(); + assertThat(excludes(filter, ExampleGraphQlSourceBuilderCustomizer.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(); + assertThat(excludes(filter, ExampleDataFetcherExceptionResolver.class)).isTrue(); + assertThat(excludes(filter, ExampleInstrumentation.class)).isTrue(); + assertThat(excludes(filter, ExampleGraphQlSourceBuilderCustomizer.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(); + assertThat(excludes(filter, ExampleDataFetcherExceptionResolver.class)).isFalse(); + assertThat(excludes(filter, ExampleInstrumentation.class)).isFalse(); + assertThat(excludes(filter, ExampleGraphQlSourceBuilderCustomizer.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(); + assertThat(excludes(filter, ExampleDataFetcherExceptionResolver.class)).isFalse(); + assertThat(excludes(filter, ExampleInstrumentation.class)).isFalse(); + assertThat(excludes(filter, ExampleGraphQlSourceBuilderCustomizer.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 WebGraphQlInterceptor { + + @Override + public Mono intercept(WebGraphQlRequest request, Chain chain) { + return null; + } + + } + + @SuppressWarnings("serial") + static class ExampleModule extends SimpleModule { + + } + + static class ExampleDataFetcherExceptionResolver implements DataFetcherExceptionResolver { + + @Override + public Mono> resolveException(Throwable exception, DataFetchingEnvironment environment) { + return null; + } + + } + + static class ExampleInstrumentation implements Instrumentation { + + } + + static class ExampleGraphQlSourceBuilderCustomizer implements GraphQlSourceBuilderCustomizer { + + @Override + public void customize(GraphQlSource.SchemaResourceBuilder builder) { + + } + + } + +} 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..9708539009 --- /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,63 @@ +/* + * Copyright 2012-2022 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.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.ExecutionGraphQlService; +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(JacksonAutoConfiguration.class, 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 + ExecutionGraphQlService graphQlService() { + return mock(ExecutionGraphQlService.class); + } + + } + +} diff --git a/spring-boot-project/spring-boot-test/build.gradle b/spring-boot-project/spring-boot-test/build.gradle index 542ec2051c..d63ad21262 100644 --- a/spring-boot-project/spring-boot-test/build.gradle +++ b/spring-boot-project/spring-boot-test/build.gradle @@ -1,9 +1,9 @@ plugins { - id "java-library" - id "org.jetbrains.kotlin.jvm" + id "java-library" + id "org.jetbrains.kotlin.jvm" id "org.springframework.boot.conventions" id "org.springframework.boot.deployed" - id "org.springframework.boot.optional-dependencies" + id "org.springframework.boot.optional-dependencies" } description = "Spring Boot Test" @@ -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/HttpGraphQlTesterContextCustomizer.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/HttpGraphQlTesterContextCustomizer.java new file mode 100644 index 0000000000..ed0a836ab3 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/HttpGraphQlTesterContextCustomizer.java @@ -0,0 +1,217 @@ +/* + * Copyright 2020-2022 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.HttpGraphQlTester; +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 HttpGraphQlTester}. + * + * @author Brian Clozel + */ +class HttpGraphQlTesterContextCustomizer implements ContextCustomizer { + + @Override + public void customizeContext(ConfigurableApplicationContext context, MergedContextConfiguration mergedConfig) { + SpringBootTest springBootTest = TestContextAnnotationUtils.findMergedAnnotation(mergedConfig.getTestClass(), + SpringBootTest.class); + if (springBootTest.webEnvironment().isEmbedded()) { + registerHttpGraphQlTester(context); + } + } + + private void registerHttpGraphQlTester(ConfigurableApplicationContext context) { + ConfigurableListableBeanFactory beanFactory = context.getBeanFactory(); + if (beanFactory instanceof BeanDefinitionRegistry) { + registerHttpGraphQlTester((BeanDefinitionRegistry) beanFactory); + } + } + + private void registerHttpGraphQlTester(BeanDefinitionRegistry registry) { + RootBeanDefinition definition = new RootBeanDefinition(HttpGraphQlTesterRegistrar.class); + definition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + registry.registerBeanDefinition(HttpGraphQlTesterRegistrar.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 HttpGraphQlTesterRegistrar + 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, + HttpGraphQlTester.class, false, false).length == 0) { + registry.registerBeanDefinition(HttpGraphQlTester.class.getName(), + new RootBeanDefinition(HttpGraphQlTesterFactory.class)); + } + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE - 1; + } + + } + + public static class HttpGraphQlTesterFactory 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 HttpGraphQlTester object; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @Override + public boolean isSingleton() { + return true; + } + + @Override + public Class getObjectType() { + return HttpGraphQlTester.class; + } + + @Override + public HttpGraphQlTester getObject() throws Exception { + if (this.object == null) { + this.object = createGraphQlTester(); + } + return this.object; + } + + private HttpGraphQlTester 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 HttpGraphQlTester.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/HttpGraphQlTesterContextCustomizerFactory.java b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/HttpGraphQlTesterContextCustomizerFactory.java new file mode 100644 index 0000000000..ac245832b5 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/main/java/org/springframework/boot/test/graphql/tester/HttpGraphQlTesterContextCustomizerFactory.java @@ -0,0 +1,54 @@ +/* + * Copyright 2020-2022 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.HttpGraphQlTester; +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 HttpGraphQlTester}. + * + * @author Brian Clozel + * @see HttpGraphQlTesterContextCustomizer + */ +class HttpGraphQlTesterContextCustomizerFactory implements ContextCustomizerFactory { + + private static final String HTTPGRAPHQLTESTER_CLASS = "org.springframework.graphql.test.tester.HttpGraphQlTester"; + + 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 HttpGraphQlTesterContextCustomizer() : null; + } + + private boolean isGraphQlTesterPresent() { + return ClassUtils.isPresent(WEBTESTCLIENT_CLASS, getClass().getClassLoader()) + && ClassUtils.isPresent(HTTPGRAPHQLTESTER_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..44cbf9b58d 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.HttpGraphQlTesterContextCustomizerFactory,\ 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/HttpGraphQlTesterContextCustomizerIntegrationTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/HttpGraphQlTesterContextCustomizerIntegrationTests.java new file mode 100644 index 0000000000..b66eec19be --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/HttpGraphQlTesterContextCustomizerIntegrationTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-2022 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.HttpGraphQlTester; +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 HttpGraphQlTesterContextCustomizer}. + * + * @author Brian Clozel + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "spring.main.web-application-type=reactive") +@DirtiesContext +class HttpGraphQlTesterContextCustomizerIntegrationTests { + + @Autowired + HttpGraphQlTester graphQlTester; + + @Test + void shouldHandleGraphQlRequests() { + this.graphQlTester.document("{}").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/HttpGraphQlTesterContextCustomizerWithCustomBasePathTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/HttpGraphQlTesterContextCustomizerWithCustomBasePathTests.java new file mode 100644 index 0000000000..f7167d07e1 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/HttpGraphQlTesterContextCustomizerWithCustomBasePathTests.java @@ -0,0 +1,88 @@ +/* + * Copyright 2012-2022 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.HttpGraphQlTester; +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 HttpGraphQlTesterContextCustomizer} 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 HttpGraphQlTesterContextCustomizerWithCustomBasePathTests { + + @Autowired + HttpGraphQlTester graphQlTester; + + @Test + void shouldHandleGraphQlRequests() { + this.graphQlTester.document("{}").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/HttpGraphQlTesterContextCustomizerWithCustomContextPathTests.java b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/HttpGraphQlTesterContextCustomizerWithCustomContextPathTests.java new file mode 100644 index 0000000000..fec60ac442 --- /dev/null +++ b/spring-boot-project/spring-boot-test/src/test/java/org/springframework/boot/test/graphql/tester/HttpGraphQlTesterContextCustomizerWithCustomContextPathTests.java @@ -0,0 +1,80 @@ +/* + * Copyright 2012-2022 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.HttpGraphQlTester; +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 HttpGraphQlTesterContextCustomizer} 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 HttpGraphQlTesterContextCustomizerWithCustomContextPathTests { + + @Autowired + HttpGraphQlTester graphQlTester; + + @Test + void shouldHandleGraphQlRequests() { + this.graphQlTester.document("{}").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/spring-boot-smoke-test-graphql/build.gradle b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/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/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/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/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/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/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/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/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/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/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/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/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/ProjectController.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/ProjectController.java new file mode 100644 index 0000000000..b96379cce4 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/ProjectController.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2022 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 ProjectController { + + private final List projects; + + public ProjectController() { + 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/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/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/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/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/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/SecurityConfig.java new file mode 100644 index 0000000000..ec1a87f030 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/SecurityConfig.java @@ -0,0 +1,53 @@ +/* + * Copyright 2012-2022 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(proxyBeanMethods = false) +@EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + + @Bean + public 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 + @SuppressWarnings("deprecation") + public 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/spring-boot-smoke-test-graphql/src/main/resources/graphql/schema.graphqls b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/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/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/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/spring-boot-smoke-test-graphql/src/test/java/smoketest/graphql/GreetingControllerTests.java new file mode 100644 index 0000000000..27ddea4659 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/spring-boot-smoke-test-graphql/src/test/java/smoketest/graphql/GreetingControllerTests.java @@ -0,0 +1,65 @@ +/* + * Copyright 2012-2022 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.AutoConfigureHttpGraphQlTester; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.graphql.execution.ErrorType; +import org.springframework.graphql.test.tester.HttpGraphQlTester; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@AutoConfigureHttpGraphQlTester +class GreetingControllerTests { + + @Autowired + private HttpGraphQlTester graphQlTester; + + @Test + void shouldUnauthorizeAnonymousUsers() { + this.graphQlTester.documentName("greeting").variable("name", "Brian").execute().errors().satisfy((errors) -> { + assertThat(errors).hasSize(1); + assertThat(errors.get(0).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + }); + } + + @Test + void shouldGreetWithSpecificName() { + HttpGraphQlTester authenticated = withAdminCredentials(this.graphQlTester); + authenticated.documentName("greeting").variable("name", "Brian").execute().path("greeting").entity(String.class) + .isEqualTo("Hello, Brian!"); + } + + @Test + void shouldGreetWithDefaultName() { + HttpGraphQlTester authenticated = withAdminCredentials(this.graphQlTester); + authenticated.document("{ greeting }").execute().path("greeting").entity(String.class) + .isEqualTo("Hello, Spring!"); + } + + private HttpGraphQlTester withAdminCredentials(HttpGraphQlTester graphQlTester) { + return graphQlTester.mutate() + .webTestClient( + (httpClient) -> httpClient.defaultHeaders((headers) -> headers.setBasicAuth("admin", "admin"))) + .build(); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/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/spring-boot-smoke-test-graphql/src/test/java/smoketest/graphql/ProjectControllerTests.java new file mode 100644 index 0000000000..a9a76eee10 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/spring-boot-smoke-test-graphql/src/test/java/smoketest/graphql/ProjectControllerTests.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2022 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(ProjectController.class) +class ProjectControllerTests { + + @Autowired + private GraphQlTester graphQlTester; + + @Test + void shouldFindSpringGraphQl() { + this.graphQlTester.document("{ project(slug: \"spring-graphql\") { name } }").execute().path("project.name") + .entity(String.class).isEqualTo("Spring GraphQL"); + } + + @Test + void shouldNotFindUnknownProject() { + this.graphQlTester.document("{ project(slug: \"spring-unknown\") { name } }").execute().path("project.name") + .pathDoesNotExist(); + } + +} diff --git a/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/spring-boot-smoke-test-graphql/src/test/resources/graphql-test/greeting.graphql b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/spring-boot-smoke-test-graphql/src/test/resources/graphql-test/greeting.graphql new file mode 100644 index 0000000000..1521607a7b --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/spring-boot-smoke-test-graphql/src/test/resources/graphql-test/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/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/ProjectController.java b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/ProjectController.java new file mode 100644 index 0000000000..b96379cce4 --- /dev/null +++ b/spring-boot-tests/spring-boot-smoke-tests/spring-boot-smoke-test-graphql/src/main/java/smoketest/graphql/ProjectController.java @@ -0,0 +1,42 @@ +/* + * Copyright 2012-2022 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 ProjectController { + + private final List projects; + + public ProjectController() { + 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..ec1a87f030 --- /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,53 @@ +/* + * Copyright 2012-2022 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(proxyBeanMethods = false) +@EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + + @Bean + public 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 + @SuppressWarnings("deprecation") + public 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/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..27ddea4659 --- /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,65 @@ +/* + * Copyright 2012-2022 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.AutoConfigureHttpGraphQlTester; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.graphql.execution.ErrorType; +import org.springframework.graphql.test.tester.HttpGraphQlTester; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@AutoConfigureHttpGraphQlTester +class GreetingControllerTests { + + @Autowired + private HttpGraphQlTester graphQlTester; + + @Test + void shouldUnauthorizeAnonymousUsers() { + this.graphQlTester.documentName("greeting").variable("name", "Brian").execute().errors().satisfy((errors) -> { + assertThat(errors).hasSize(1); + assertThat(errors.get(0).getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + }); + } + + @Test + void shouldGreetWithSpecificName() { + HttpGraphQlTester authenticated = withAdminCredentials(this.graphQlTester); + authenticated.documentName("greeting").variable("name", "Brian").execute().path("greeting").entity(String.class) + .isEqualTo("Hello, Brian!"); + } + + @Test + void shouldGreetWithDefaultName() { + HttpGraphQlTester authenticated = withAdminCredentials(this.graphQlTester); + authenticated.document("{ greeting }").execute().path("greeting").entity(String.class) + .isEqualTo("Hello, Spring!"); + } + + private HttpGraphQlTester withAdminCredentials(HttpGraphQlTester graphQlTester) { + return graphQlTester.mutate() + .webTestClient( + (httpClient) -> httpClient.defaultHeaders((headers) -> headers.setBasicAuth("admin", "admin"))) + .build(); + } + +} 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..a9a76eee10 --- /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-2022 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(ProjectController.class) +class ProjectControllerTests { + + @Autowired + private GraphQlTester graphQlTester; + + @Test + void shouldFindSpringGraphQl() { + this.graphQlTester.document("{ project(slug: \"spring-graphql\") { name } }").execute().path("project.name") + .entity(String.class).isEqualTo("Spring GraphQL"); + } + + @Test + void shouldNotFindUnknownProject() { + this.graphQlTester.document("{ project(slug: \"spring-unknown\") { name } }").execute().path("project.name") + .pathDoesNotExist(); + } + +}