Refine GraphQL server auto-configuration

Prior to this commit, launching a GraphQL application without any schema
file or customizer bean would result in an exception caught by a
FailureAnalyzer telling the developer about configured locations.

Since then, a new client has been introduced in Spring GraphQL and the
mere presence of the GraphQL starter does not mean anymore that the
intent is to create a GraphQL API in the app: we could instead just
consume an existing, remote API.

This commit refines the GraphQL server auto-configuration so that it is
enabled only if:

* there is at least one schema file in the configured locations
* or a `GraphQlSourceCustomizer` bean has been defined in the app

These changes make the custom FailureAnalyzer useless and is also
removed as part of this commit.

Closes gh-30035
pull/30406/head
Brian Clozel 3 years ago
parent bf79d6baef
commit 087e853c5d

@ -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 {
}

@ -0,0 +1,109 @@
/*
* 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:
* <ul>
* <li>schema files in the {@link GraphQlProperties configured locations}</li>
* <li>or infrastructure beans such as {@link GraphQlSourceBuilderCustomizer}</li>
* </ul>
*
* @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<ConditionMessage> 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<Resource> 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<Resource> resolveSchemaResources(ResourcePatternResolver resolver, String[] locations,
String[] extensions) {
List<Resource> resources = new ArrayList<>();
for (String location : locations) {
for (String extension : extensions) {
resources.addAll(resolveSchemaResources(resolver, location + "*" + extension));
}
}
return resources;
}
private List<Resource> resolveSchemaResources(ResourcePatternResolver resolver, String pattern) {
try {
return Arrays.asList(resolver.getResources(pattern));
}
catch (IOException ex) {
return Collections.emptyList();
}
}
}

@ -48,7 +48,6 @@ import org.springframework.graphql.execution.DataFetcherExceptionResolver;
import org.springframework.graphql.execution.DefaultBatchLoaderRegistry;
import org.springframework.graphql.execution.ExecutionGraphQlService;
import org.springframework.graphql.execution.GraphQlSource;
import org.springframework.graphql.execution.MissingSchemaException;
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
/**
@ -60,6 +59,7 @@ import org.springframework.graphql.execution.RuntimeWiringConfigurer;
*/
@AutoConfiguration
@ConditionalOnClass({ GraphQL.class, GraphQlSource.class })
@ConditionalOnGraphQlSchema
@EnableConfigurationProperties(GraphQlProperties.class)
public class GraphQlAutoConfiguration {
@ -87,12 +87,7 @@ public class GraphQlAutoConfiguration {
}
wiringConfigurers.orderedStream().forEach(builder::configureRuntimeWiring);
sourceCustomizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
try {
return builder.build();
}
catch (MissingSchemaException ex) {
throw new InvalidSchemaLocationsException(schemaLocations, resourcePatternResolver, ex);
}
return builder.build();
}
private Builder enableIntrospection(Builder wiring) {

@ -1,101 +0,0 @@
/*
* Copyright 2020-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.graphql;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import org.springframework.core.NestedRuntimeException;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.util.Assert;
/**
* {@link InvalidSchemaLocationsException} thrown when no schema file could be found in
* the provided locations.
*
* @author Brian Clozel
* @since 2.7.0
*/
public class InvalidSchemaLocationsException extends NestedRuntimeException {
private final List<SchemaLocation> schemaLocations;
public InvalidSchemaLocationsException(String[] locations, ResourcePatternResolver resolver) {
this(locations, resolver, null);
}
public InvalidSchemaLocationsException(String[] locations, ResourcePatternResolver resolver, Throwable cause) {
super("No schema file could be found in the provided locations.", cause);
Assert.notEmpty(locations, "locations should not be empty");
Assert.notNull(resolver, "resolver should not be null");
List<SchemaLocation> providedLocations = new ArrayList<>();
for (String location : locations) {
try {
String uri = resolver.getResource(location).getURI().toASCIIString();
providedLocations.add(new SchemaLocation(location, uri));
}
catch (IOException ex) {
providedLocations.add(new SchemaLocation(location, ""));
}
}
this.schemaLocations = Collections.unmodifiableList(providedLocations);
}
/**
* Return the list of provided locations where to look for schemas.
* @return the list of locations
*/
public List<SchemaLocation> getSchemaLocations() {
return this.schemaLocations;
}
/**
* The location where to look for schemas.
*/
public static class SchemaLocation {
private final String location;
private final String uri;
SchemaLocation(String location, String uri) {
this.location = location;
this.uri = uri;
}
/**
* Return the location String to be resolved by a {@link ResourcePatternResolver}.
* @return the location
*/
public String getLocation() {
return this.location;
}
/**
* Return the resolved URI String for this location, an empty String if resolution
* failed.
* @return the resolved location or an empty String
*/
public String getUri() {
return this.uri;
}
}
}

@ -1,42 +0,0 @@
/*
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.graphql;
import org.springframework.boot.diagnostics.AbstractFailureAnalyzer;
import org.springframework.boot.diagnostics.FailureAnalysis;
/**
* An implementation of {@link AbstractFailureAnalyzer} to analyze failures caused by
* {@link InvalidSchemaLocationsException}.
*
* @author Brian Clozel
*/
class InvalidSchemaLocationsExceptionFailureAnalyzer extends AbstractFailureAnalyzer<InvalidSchemaLocationsException> {
@Override
protected FailureAnalysis analyze(Throwable rootFailure, InvalidSchemaLocationsException cause) {
String message = "Could not find any GraphQL schema file under configured locations.";
StringBuilder action = new StringBuilder(
"Check that the following locations contain schema files: " + System.lineSeparator());
for (InvalidSchemaLocationsException.SchemaLocation schemaLocation : cause.getSchemaLocations()) {
action.append(String.format("- '%s' (%s)" + System.lineSeparator(), schemaLocation.getUri(),
schemaLocation.getLocation()));
}
return new FailureAnalysis(message, action.toString(), cause);
}
}

@ -26,7 +26,6 @@ org.springframework.boot.diagnostics.FailureAnalyzer=\
org.springframework.boot.autoconfigure.data.redis.RedisUrlSyntaxFailureAnalyzer,\
org.springframework.boot.autoconfigure.diagnostics.analyzer.NoSuchBeanDefinitionFailureAnalyzer,\
org.springframework.boot.autoconfigure.flyway.FlywayMigrationScriptMissingFailureAnalyzer,\
org.springframework.boot.autoconfigure.graphql.InvalidSchemaLocationsExceptionFailureAnalyzer,\
org.springframework.boot.autoconfigure.jdbc.DataSourceBeanCreationFailureAnalyzer,\
org.springframework.boot.autoconfigure.jdbc.HikariDriverConfigurationFailureAnalyzer,\
org.springframework.boot.autoconfigure.jooq.NoDslContextBeanFailureAnalyzer,\

@ -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<ConditionEvaluationReport.ConditionAndOutcomes> 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) -> {
};
}
}
}

@ -40,7 +40,6 @@ 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.MissingSchemaException;
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
import static org.assertj.core.api.Assertions.assertThat;
@ -75,11 +74,9 @@ class GraphQlAutoConfigurationTests {
}
@Test
void shouldFailWhenSchemaFileIsMissing() {
this.contextRunner.withPropertyValues("spring.graphql.schema.locations:classpath:missing/").run((context) -> {
assertThat(context).hasFailed();
assertThat(context).getFailure().getRootCause().isInstanceOf(MissingSchemaException.class);
});
void shouldBackoffWhenSchemaFileIsMissing() {
this.contextRunner.withPropertyValues("spring.graphql.schema.locations:classpath:missing/")
.run((context) -> assertThat(context).hasNotFailed().doesNotHaveBean(GraphQlSource.class));
}
@Test

@ -1,69 +0,0 @@
/*
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.graphql;
import org.junit.jupiter.api.Test;
import org.springframework.boot.diagnostics.FailureAnalysis;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.io.support.ResourcePatternResolver;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link InvalidSchemaLocationsExceptionFailureAnalyzer}
*
* @author Brian Clozel
*/
class InvalidSchemaLocationsExceptionFailureAnalyzerTests {
private final InvalidSchemaLocationsExceptionFailureAnalyzer analyzer = new InvalidSchemaLocationsExceptionFailureAnalyzer();
private final ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
private final String[] missingLocation = new String[] {
"classpath:org/springframework/boot/autoconfigure/graphql/missing/" };
private final String[] existingLocation = new String[] {
"classpath:org/springframework/boot/autoconfigure/graphql/" };
@Test
void shouldReportCause() {
InvalidSchemaLocationsException exception = new InvalidSchemaLocationsException(this.existingLocation,
this.resolver);
FailureAnalysis analysis = this.analyzer.analyze(exception);
assertThat(analysis.getCause()).isInstanceOf(InvalidSchemaLocationsException.class);
assertThat(analysis.getAction()).contains("Check that the following locations contain schema files:");
}
@Test
void shouldListUnresolvableLocation() {
InvalidSchemaLocationsException exception = new InvalidSchemaLocationsException(this.missingLocation,
this.resolver);
FailureAnalysis analysis = this.analyzer.analyze(exception);
assertThat(analysis.getAction()).contains(this.existingLocation[0]).doesNotContain("file:");
}
@Test
void shouldListExistingLocation() {
InvalidSchemaLocationsException exception = new InvalidSchemaLocationsException(this.existingLocation,
this.resolver);
FailureAnalysis analysis = this.analyzer.analyze(exception);
assertThat(analysis.getAction()).contains(this.existingLocation[0]).contains("file:");
}
}

@ -1,71 +0,0 @@
/*
* Copyright 2012-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.graphql;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
* Tests for {@link InvalidSchemaLocationsException}.
*
* @author Brian Clozel
*/
class InvalidSchemaLocationsExceptionTests {
private final String schemaFolder = "graphql/";
private final String[] locations = new String[] { "classpath:" + this.schemaFolder };
@Test
void shouldRejectEmptyLocations() {
assertThatIllegalArgumentException().isThrownBy(
() -> new InvalidSchemaLocationsException(new String[] {}, new PathMatchingResourcePatternResolver()))
.isInstanceOf(IllegalArgumentException.class).withMessage("locations should not be empty");
}
@Test
void shouldRejectNullResolver() {
assertThatIllegalArgumentException().isThrownBy(() -> new InvalidSchemaLocationsException(this.locations, null))
.isInstanceOf(IllegalArgumentException.class).withMessage("resolver should not be null");
}
@Test
void shouldExposeConfiguredLocations() {
InvalidSchemaLocationsException exception = new InvalidSchemaLocationsException(this.locations,
new PathMatchingResourcePatternResolver());
assertThat(exception.getSchemaLocations()).hasSize(1);
InvalidSchemaLocationsException.SchemaLocation schemaLocation = exception.getSchemaLocations().get(0);
assertThat(schemaLocation.getLocation()).isEqualTo(this.locations[0]);
assertThat(schemaLocation.getUri()).endsWith(this.schemaFolder);
}
@Test
void shouldNotFailWithUnresolvableLocations() {
String unresolved = "classpath:unresolved/";
InvalidSchemaLocationsException exception = new InvalidSchemaLocationsException(new String[] { unresolved },
new PathMatchingResourcePatternResolver());
assertThat(exception.getSchemaLocations()).hasSize(1);
InvalidSchemaLocationsException.SchemaLocation schemaLocation = exception.getSchemaLocations().get(0);
assertThat(schemaLocation.getLocation()).isEqualTo(unresolved);
assertThat(schemaLocation.getUri()).isEmpty();
}
}
Loading…
Cancel
Save