Load multiple GraphQL schema files

Prior to this commit, the `spring.graphql.schema.location` would only
allow a single file as the source for the GraphQL schema.

This commit changes this configuration to
`spring.graphql.schema.locations` so that it accepts actual locations
(folders) and scans for `*.graphqls` files in those locations. All files
are parsed and merged as a single GraphQL `TypeDefinitionRegistry`.

Closes gh-56
This commit is contained in:
Brian Clozel
2021-06-29 21:27:18 +02:00
parent f29c5ba764
commit fdf446f3e6
11 changed files with 66 additions and 37 deletions

View File

@@ -119,8 +119,8 @@ The Spring GraphQL project offers a few configuration properties to customize yo
````properties
# web path to the graphql endpoint
spring.graphql.path=/graphql
# location of the graphql schema file
spring.graphql.schema.location=classpath:graphql/schema.graphqls
# locations of the graphql '*.graphqls' schema files
spring.graphql.schema.locations=classpath:graphql/
# schema printer endpoint configuration
# endpoint path is concatenated with the main path, so "/graphql/schema" by default
spring.graphql.schema.printer.enabled=false

View File

@@ -16,6 +16,10 @@
package org.springframework.graphql.boot;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import graphql.GraphQL;
@@ -27,9 +31,11 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.ResourcePatternResolver;
import org.springframework.graphql.execution.DataFetcherExceptionResolver;
import org.springframework.graphql.execution.GraphQlSource;
@@ -55,6 +61,8 @@ public class GraphQlAutoConfiguration {
@ConditionalOnMissingBean(GraphQlSource.Builder.class)
public static class GraphQlSourceConfiguration {
private static final String SCHEMA_FILES_PATTERN = "*.graphqls";
@Bean
@ConditionalOnMissingBean
public RuntimeWiring runtimeWiring(ObjectProvider<RuntimeWiringCustomizer> customizers) {
@@ -64,17 +72,25 @@ public class GraphQlAutoConfiguration {
}
@Bean
public GraphQlSource.Builder graphQlSourceBuilder(GraphQlProperties properties, RuntimeWiring runtimeWiring,
ObjectProvider<DataFetcherExceptionResolver> exceptionResolversProvider, ResourceLoader resourceLoader,
ObjectProvider<Instrumentation> instrumentationsProvider) {
public GraphQlSource.Builder graphQlSourceBuilder(ApplicationContext applicationContext, GraphQlProperties properties,
RuntimeWiring runtimeWiring, ObjectProvider<DataFetcherExceptionResolver> exceptionResolversProvider,
ObjectProvider<Instrumentation> instrumentationsProvider) throws IOException {
String schemaLocation = properties.getSchema().getLocation();
return GraphQlSource.builder().schemaResource(resourceLoader.getResource(schemaLocation))
List<Resource> schemaResources = resolveSchemaResources(applicationContext, properties.getSchema().getLocations());
return GraphQlSource.builder().schemaResources(schemaResources.toArray(new Resource[0]))
.runtimeWiring(runtimeWiring)
.exceptionResolvers(exceptionResolversProvider.orderedStream().collect(Collectors.toList()))
.instrumentation(instrumentationsProvider.orderedStream().collect(Collectors.toList()));
}
private List<Resource> resolveSchemaResources(ResourcePatternResolver resolver, List<String> schemaLocations) throws IOException {
List<Resource> schemaResources = new ArrayList<>();
for (String location : schemaLocations) {
schemaResources.addAll(Arrays.asList(resolver.getResources(location + SCHEMA_FILES_PATTERN)));
}
return schemaResources;
}
}
}

View File

@@ -17,6 +17,10 @@
package org.springframework.graphql.boot;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -63,18 +67,24 @@ public class GraphQlProperties {
public static class Schema {
/**
* Location of the GraphQL schema file.
* Locations of GraphQL '*.graphqls' schema files.
*/
private String location = "classpath:graphql/schema.graphqls";
private List<String> locations = new ArrayList<>(Collections.singletonList("classpath:graphql/"));
private final Printer printer = new Printer();
public String getLocation() {
return this.location;
public List<String> getLocations() {
return this.locations;
}
public void setLocation(String location) {
this.location = location;
public void setLocations(List<String> locations) {
this.locations = appendSlashIfNecessary(locations);
}
private List<String> appendSlashIfNecessary(List<String> locations) {
return locations.stream()
.map(location -> location.endsWith("/") ? location : location + "/")
.collect(Collectors.toList());
}
public Printer getPrinter() {

View File

@@ -39,19 +39,19 @@ class GraphQlAutoConfigurationTests {
void shouldFailWhenSchemaFileIsMissing() {
this.contextRunner.run((context) -> {
assertThat(context).hasFailed();
assertThat(context).getFailure().getRootCause().hasMessage("'schemaResource' does not exist");
assertThat(context).getFailure().getRootCause().hasMessage("'schemaResources' should not be empty");
});
}
@Test
void shouldCreateBuilderWithSdl() {
this.contextRunner.withPropertyValues("spring.graphql.schema.location:classpath:books/schema.graphqls")
this.contextRunner.withPropertyValues("spring.graphql.schema.locations:classpath:books/")
.run((context) -> assertThat(context).hasSingleBean(GraphQlSource.class));
}
@Test
void shouldUseProgrammaticallyDefinedBuilder() {
this.contextRunner.withPropertyValues("spring.graphql.schema.location:classpath:books/schema.graphqls")
this.contextRunner.withPropertyValues("spring.graphql.schema.locations:classpath:books/")
.withUserConfiguration(CustomGraphQlBuilderConfiguration.class).run((context) -> {
assertThat(context).hasBean("customGraphQlSourceBuilder");
assertThat(context).hasSingleBean(GraphQlSource.Builder.class);
@@ -63,7 +63,7 @@ class GraphQlAutoConfigurationTests {
@Bean
GraphQlSource.Builder customGraphQlSourceBuilder() {
return GraphQlSource.builder().schemaResource(new ClassPathResource("books/schema.graphqls"));
return GraphQlSource.builder().schemaResources(new ClassPathResource("books/schema.graphqls"));
}
}

View File

@@ -144,7 +144,7 @@ class WebFluxApplicationContextTests {
.withPropertyValues(
"spring.main.web-application-type=reactive",
"spring.graphql.schema.printer.enabled=true",
"spring.graphql.schema.location=classpath:books/schema.graphqls")
"spring.graphql.schema.locations=classpath:books/")
.run(consumer);
}

View File

@@ -114,7 +114,7 @@ class WebMvcApplicationContextTests {
.withPropertyValues(
"spring.main.web-application-type=servlet",
"spring.graphql.schema.printer.enabled=true",
"spring.graphql.schema.location=classpath:books/schema.graphqls")
"spring.graphql.schema.locations=classpath:books/")
.run((context) -> {
MediaType mediaType = MediaType.APPLICATION_JSON;
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context)

View File

@@ -19,6 +19,7 @@ package org.springframework.graphql.execution;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.Consumer;
@@ -34,7 +35,6 @@ import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.TypeDefinitionRegistry;
import org.springframework.core.io.Resource;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
@@ -42,11 +42,11 @@ import org.springframework.util.Assert;
* {@link GraphQL} instance and wraps it with a {@link GraphQlSource} that returns it.
*
* @author Rossen Stoyanchev
* @author Brian Clozel
*/
class DefaultGraphQlSourceBuilder implements GraphQlSource.Builder {
@Nullable
private Resource schemaResource;
private List<Resource> schemaResources = new ArrayList<>();
private RuntimeWiring runtimeWiring = RuntimeWiring.newRuntimeWiring().build();
@@ -64,8 +64,8 @@ class DefaultGraphQlSourceBuilder implements GraphQlSource.Builder {
}
@Override
public GraphQlSource.Builder schemaResource(Resource resource) {
this.schemaResource = resource;
public GraphQlSource.Builder schemaResources(Resource... resources) {
this.schemaResources.addAll(Arrays.asList(resources));
return this;
}
@@ -102,7 +102,9 @@ class DefaultGraphQlSourceBuilder implements GraphQlSource.Builder {
@Override
public GraphQlSource build() {
TypeDefinitionRegistry registry = parseSchemaResource();
TypeDefinitionRegistry registry = this.schemaResources.stream()
.map(this::parseSchemaResource).reduce(TypeDefinitionRegistry::merge)
.orElseThrow(() -> new IllegalArgumentException("'schemaResources' should not be empty"));
GraphQLSchema schema = new SchemaGenerator().makeExecutableSchema(registry, this.runtimeWiring);
for (GraphQLTypeVisitor visitor : this.typeVisitors) {
@@ -120,16 +122,16 @@ class DefaultGraphQlSourceBuilder implements GraphQlSource.Builder {
return new CachedGraphQlSource(graphQl, schema);
}
private TypeDefinitionRegistry parseSchemaResource() {
Assert.notNull(this.schemaResource, "'schemaResource' not provided");
Assert.isTrue(this.schemaResource.exists(), "'schemaResource' does not exist");
private TypeDefinitionRegistry parseSchemaResource(Resource schemaResource) {
Assert.notNull(schemaResource, "'schemaResource' not provided");
Assert.isTrue(schemaResource.exists(), "'schemaResource' does not exist");
try {
try (InputStream inputStream = this.schemaResource.getInputStream()) {
try (InputStream inputStream = schemaResource.getInputStream()) {
return new SchemaParser().parse(inputStream);
}
}
catch (IOException ex) {
throw new IllegalArgumentException("Failed to load resourceLocation " + this.schemaResource.toString());
throw new IllegalArgumentException("Failed to load schema resource: " + schemaResource.toString());
}
}

View File

@@ -70,12 +70,13 @@ public interface GraphQlSource {
interface Builder {
/**
* Provide the resource for the GraphQL {@literal ".schema"} file to parse.
* @param resource the resource for the GraphQL schema
* Add {@literal ".graphqls"} schema resources to be
* {@link TypeDefinitionRegistry#merge(TypeDefinitionRegistry) merged} into the type registry.
* @param resources resources for the GraphQL schema
* @return the current builder
* @see graphql.schema.idl.SchemaParser#parse(File)
*/
Builder schemaResource(Resource resource);
Builder schemaResources(Resource... resources);
/**
* Set a {@link RuntimeWiring} to contribute data fetchers and more.

View File

@@ -57,7 +57,7 @@ public abstract class GraphQlTestUtils {
.build();
return GraphQlSource.builder()
.schemaResource(new ByteArrayResource(schemaContent.getBytes(StandardCharsets.UTF_8)))
.schemaResources(new ByteArrayResource(schemaContent.getBytes(StandardCharsets.UTF_8)))
.runtimeWiring(wiring);
}

View File

@@ -202,7 +202,7 @@ class QuerydslDataFetcherTests {
configurer.accept(wiringBuilder);
builder.type(wiringBuilder);
return GraphQlSource.builder()
.schemaResource(new ClassPathResource("books/schema.graphqls"))
.schemaResources(new ClassPathResource("books/schema.graphqls"))
.runtimeWiring(builder.build())
.build();
}

View File

@@ -84,7 +84,7 @@ public abstract class BookTestUtils {
return Flux.fromIterable(booksMap.values()).filter((book) -> book.getAuthor().contains(author));
}));
return GraphQlSource.builder()
.schemaResource(new ClassPathResource("books/schema.graphqls"))
.schemaResources(new ClassPathResource("books/schema.graphqls"))
.runtimeWiring(builder.build())
.build();
}