Support auto-generation of Relay Connection type
Closes gh-619
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright 2002-2023 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.graphql.execution;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import graphql.language.FieldDefinition;
|
||||
import graphql.language.ImplementingTypeDefinition;
|
||||
import graphql.language.ListType;
|
||||
import graphql.language.NonNullType;
|
||||
import graphql.language.ObjectTypeDefinition;
|
||||
import graphql.language.Type;
|
||||
import graphql.language.TypeName;
|
||||
import graphql.schema.idl.TypeDefinitionRegistry;
|
||||
|
||||
/**
|
||||
* Exposes the {@link #generateConnectionTypes(TypeDefinitionRegistry)
|
||||
* generateConnectionTypes method} for adding boilerplate type definitions to a
|
||||
* {@link TypeDefinitionRegistry}, for pagination based on the Relay
|
||||
* <a href="https://relay.dev/graphql/connections.htm">GraphQL Cursor Connections Specification</a>.
|
||||
*
|
||||
* <p>Use {@link GraphQlSource.SchemaResourceBuilder#configureTypeDefinitionRegistry(Function)}
|
||||
* to enable connection type generation.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 1.2
|
||||
*/
|
||||
public class ConnectionTypeGenerator {
|
||||
|
||||
private static final TypeName STRING_TYPE = new TypeName("String");
|
||||
|
||||
private static final TypeName BOOLEAN_TYPE = new TypeName("Boolean");
|
||||
|
||||
private static final TypeName PAGE_INFO_TYPE = new TypeName("PageInfo");
|
||||
|
||||
|
||||
/**
|
||||
* Find fields whose type definition name ends in "Connection", considered
|
||||
* by the spec to be a {@literal Connection Type}, and add type definitions
|
||||
* for all such types, if they don't exist already.
|
||||
* @param registry the registry to check and add types to
|
||||
* @return the same registry instance with additional types added
|
||||
*/
|
||||
public TypeDefinitionRegistry generateConnectionTypes(TypeDefinitionRegistry registry) {
|
||||
|
||||
Set<String> typeNames = findConnectionTypeNames(registry);
|
||||
|
||||
if (!typeNames.isEmpty()) {
|
||||
registry.add(ObjectTypeDefinition.newObjectTypeDefinition()
|
||||
.name(PAGE_INFO_TYPE.getName())
|
||||
.fieldDefinition(initFieldDefinition("hasPreviousPage", new NonNullType(BOOLEAN_TYPE)))
|
||||
.fieldDefinition(initFieldDefinition("hasNextPage", new NonNullType(BOOLEAN_TYPE)))
|
||||
.fieldDefinition(initFieldDefinition("startCursor", STRING_TYPE))
|
||||
.fieldDefinition(initFieldDefinition("endCursor", STRING_TYPE))
|
||||
.build());
|
||||
|
||||
typeNames.forEach(typeName -> {
|
||||
|
||||
System.out.println("Generating pagination types for '" + typeName + "'");
|
||||
String connectionTypeName = typeName + "Connection";
|
||||
String edgeTypeName = typeName + "Edge";
|
||||
|
||||
registry.add(ObjectTypeDefinition.newObjectTypeDefinition()
|
||||
.name(connectionTypeName)
|
||||
.fieldDefinition(initFieldDefinition("edges", new NonNullType(new ListType(new TypeName(edgeTypeName)))))
|
||||
.fieldDefinition(initFieldDefinition("pageInfo", new NonNullType(PAGE_INFO_TYPE)))
|
||||
.build());
|
||||
|
||||
registry.add(ObjectTypeDefinition.newObjectTypeDefinition()
|
||||
.name(edgeTypeName)
|
||||
.fieldDefinition(initFieldDefinition("cursor", new NonNullType(STRING_TYPE)))
|
||||
.fieldDefinition(initFieldDefinition("node", new NonNullType(new TypeName(typeName))))
|
||||
.build());
|
||||
});
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
private static Set<String> findConnectionTypeNames(TypeDefinitionRegistry registry) {
|
||||
return registry.types().values().stream()
|
||||
.filter(definition -> definition instanceof ImplementingTypeDefinition)
|
||||
.flatMap(definition -> {
|
||||
ImplementingTypeDefinition<?> typeDefinition = (ImplementingTypeDefinition<?>) definition;
|
||||
return typeDefinition.getFieldDefinitions().stream()
|
||||
.map(fieldDefinition -> {
|
||||
Type<?> type = fieldDefinition.getType();
|
||||
return (type instanceof NonNullType ? ((NonNullType) type).getType() : type);
|
||||
})
|
||||
.filter(type -> type instanceof TypeName)
|
||||
.map(type -> ((TypeName) type).getName())
|
||||
.filter(name -> name.endsWith("Connection"))
|
||||
.filter(name -> registry.getType(name).isEmpty())
|
||||
.map(name -> name.substring(0, name.length() - "Connection".length()));
|
||||
})
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
private FieldDefinition initFieldDefinition(String name, Type<?> returnType) {
|
||||
return FieldDefinition.newFieldDefinition().name(name).type(returnType).build();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2002-2022 the original author or authors.
|
||||
* Copyright 2002-2023 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.
|
||||
@@ -24,6 +24,7 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import graphql.language.InterfaceTypeDefinition;
|
||||
@@ -60,8 +61,12 @@ final class DefaultSchemaResourceGraphQlSourceBuilder
|
||||
|
||||
private final Set<Resource> schemaResources = new LinkedHashSet<>();
|
||||
|
||||
@Nullable
|
||||
private Function<TypeDefinitionRegistry, TypeDefinitionRegistry> typeDefinitionRegistryConfigurer;
|
||||
|
||||
private final List<RuntimeWiringConfigurer> runtimeWiringConfigurers = new ArrayList<>();
|
||||
|
||||
|
||||
@Nullable
|
||||
private TypeResolver typeResolver;
|
||||
|
||||
@@ -75,6 +80,16 @@ final class DefaultSchemaResourceGraphQlSourceBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GraphQlSource.SchemaResourceBuilder configureTypeDefinitionRegistry(
|
||||
Function<TypeDefinitionRegistry, TypeDefinitionRegistry> configurer) {
|
||||
|
||||
this.typeDefinitionRegistryConfigurer = (this.typeDefinitionRegistryConfigurer != null ?
|
||||
this.typeDefinitionRegistryConfigurer.andThen(configurer) : configurer);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DefaultSchemaResourceGraphQlSourceBuilder configureRuntimeWiring(RuntimeWiringConfigurer configurer) {
|
||||
this.runtimeWiringConfigurers.add(configurer);
|
||||
@@ -103,6 +118,10 @@ final class DefaultSchemaResourceGraphQlSourceBuilder
|
||||
.reduce(TypeDefinitionRegistry::merge)
|
||||
.orElseThrow(MissingSchemaException::new);
|
||||
|
||||
if (this.typeDefinitionRegistryConfigurer != null) {
|
||||
registry = this.typeDefinitionRegistryConfigurer.apply(registry);
|
||||
}
|
||||
|
||||
logger.info("Loaded " + this.schemaResources.size() + " resource(s) in the GraphQL schema.");
|
||||
if (logger.isDebugEnabled()) {
|
||||
String resources = this.schemaResources.stream()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2002-2022 the original author or authors.
|
||||
* Copyright 2002-2023 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.
|
||||
@@ -19,6 +19,7 @@ package org.springframework.graphql.execution;
|
||||
import java.util.List;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import graphql.GraphQL;
|
||||
import graphql.execution.instrumentation.Instrumentation;
|
||||
@@ -171,6 +172,19 @@ public interface GraphQlSource {
|
||||
*/
|
||||
SchemaResourceBuilder schemaResources(Resource... resources);
|
||||
|
||||
/**
|
||||
* Provide a function to customize the {@link TypeDefinitionRegistry}
|
||||
* created by parsing schema files. This allows adding or changing schema
|
||||
* type definitions before {@link GraphQLSchema} is created and validated.
|
||||
* @param configurer the function to apply accepting the current
|
||||
* {@link TypeDefinitionRegistry} and returning the one to use, likely
|
||||
* the same instance since {@link TypeDefinitionRegistry} is mutable.
|
||||
* @return the current builder
|
||||
* @sine 1.2
|
||||
*/
|
||||
SchemaResourceBuilder configureTypeDefinitionRegistry(
|
||||
Function<TypeDefinitionRegistry, TypeDefinitionRegistry> configurer);
|
||||
|
||||
/**
|
||||
* Configure the underlying {@link RuntimeWiring.Builder} to register
|
||||
* data fetchers, custom scalar types, type resolvers, and more.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright 2002-2023 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.graphql.execution;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
import graphql.relay.Connection;
|
||||
import graphql.relay.ConnectionCursor;
|
||||
import graphql.relay.DefaultConnection;
|
||||
import graphql.relay.DefaultConnectionCursor;
|
||||
import graphql.relay.DefaultEdge;
|
||||
import graphql.relay.DefaultPageInfo;
|
||||
import graphql.relay.Edge;
|
||||
import graphql.schema.DataFetcher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.springframework.graphql.Book;
|
||||
import org.springframework.graphql.BookSource;
|
||||
import org.springframework.graphql.ExecutionGraphQlResponse;
|
||||
import org.springframework.graphql.GraphQlSetup;
|
||||
import org.springframework.graphql.TestExecutionRequest;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link ConnectionTypeGenerator}.
|
||||
*
|
||||
* @author Rossen Stoyanchev
|
||||
* @since 1.2
|
||||
*/
|
||||
public class ConnectionTypeGeneratorTests {
|
||||
|
||||
@Test
|
||||
void connectionTypeGeneration() throws Exception {
|
||||
|
||||
String schema = """
|
||||
type Query {
|
||||
books: BookConnection
|
||||
}
|
||||
type Book {
|
||||
id: ID
|
||||
name: String
|
||||
}
|
||||
""";
|
||||
|
||||
List<Book> books = BookSource.books();
|
||||
|
||||
DataFetcher<?> dataFetcher = environment ->
|
||||
createConnection(books, book -> new DefaultConnectionCursor("book:" + book.getId()));
|
||||
|
||||
String document = "{ " +
|
||||
" books { " +
|
||||
" edges {" +
|
||||
" cursor," +
|
||||
" node {" +
|
||||
" id" +
|
||||
" name" +
|
||||
" }" +
|
||||
" }" +
|
||||
" pageInfo {" +
|
||||
" startCursor," +
|
||||
" endCursor," +
|
||||
" hasPreviousPage," +
|
||||
" hasNextPage" +
|
||||
" }" +
|
||||
" }" +
|
||||
"}";
|
||||
|
||||
ExecutionGraphQlResponse response = initGraphQlSetup(schema)
|
||||
.dataFetcher("Query", "books", dataFetcher)
|
||||
.toGraphQlService()
|
||||
.execute(TestExecutionRequest.forDocument(document))
|
||||
.block();
|
||||
|
||||
assertThat(new ObjectMapper().writeValueAsString(response.getData())).isEqualTo(
|
||||
"{\"books\":{" +
|
||||
"\"edges\":[" +
|
||||
"{\"cursor\":\"book:1\",\"node\":{\"id\":\"1\",\"name\":\"Nineteen Eighty-Four\"}}," +
|
||||
"{\"cursor\":\"book:2\",\"node\":{\"id\":\"2\",\"name\":\"The Great Gatsby\"}}," +
|
||||
"{\"cursor\":\"book:3\",\"node\":{\"id\":\"3\",\"name\":\"Catch-22\"}}," +
|
||||
"{\"cursor\":\"book:4\",\"node\":{\"id\":\"4\",\"name\":\"To The Lighthouse\"}}," +
|
||||
"{\"cursor\":\"book:5\",\"node\":{\"id\":\"5\",\"name\":\"Animal Farm\"}}," +
|
||||
"{\"cursor\":\"book:53\",\"node\":{\"id\":\"53\",\"name\":\"Breaking Bad\"}}," +
|
||||
"{\"cursor\":\"book:42\",\"node\":{\"id\":\"42\",\"name\":\"Hitchhiker's Guide to the Galaxy\"}}" +
|
||||
"]," +
|
||||
"\"pageInfo\":{" +
|
||||
"\"startCursor\":\"book:1\"," +
|
||||
"\"endCursor\":\"book:42\"," +
|
||||
"\"hasPreviousPage\":false," +
|
||||
"\"hasNextPage\":false}" +
|
||||
"}}"
|
||||
);
|
||||
}
|
||||
|
||||
private GraphQlSetup initGraphQlSetup(String schema) {
|
||||
ConnectionTypeGenerator generator = new ConnectionTypeGenerator();
|
||||
return GraphQlSetup.schemaContent(schema).typeDefinitionRegistryConfigurer(generator::generateConnectionTypes);
|
||||
}
|
||||
|
||||
private static <N> Connection<N> createConnection(
|
||||
List<N> nodes, Function<N, ConnectionCursor> cursorFunction) {
|
||||
|
||||
List<Edge<N>> edges = nodes.stream()
|
||||
.map(node -> (Edge<N>) new DefaultEdge<>(node, cursorFunction.apply(node)))
|
||||
.toList();
|
||||
|
||||
DefaultPageInfo pageInfo = new DefaultPageInfo(
|
||||
edges.get(0).getCursor(), edges.get(edges.size() - 1).getCursor(), false, false);
|
||||
|
||||
return new DefaultConnection<>(edges, pageInfo);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2002-2022 the original author or authors.
|
||||
* Copyright 2002-2023 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.
|
||||
@@ -19,12 +19,14 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
|
||||
import graphql.GraphQL;
|
||||
import graphql.execution.instrumentation.Instrumentation;
|
||||
import graphql.schema.DataFetcher;
|
||||
import graphql.schema.GraphQLTypeVisitor;
|
||||
import graphql.schema.TypeResolver;
|
||||
import graphql.schema.idl.TypeDefinitionRegistry;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
@@ -80,6 +82,13 @@ public class GraphQlSetup implements GraphQlServiceSetup {
|
||||
wiringBuilder.type(type, typeBuilder -> typeBuilder.dataFetcher(field, dataFetcher)));
|
||||
}
|
||||
|
||||
public GraphQlSetup typeDefinitionRegistryConfigurer(
|
||||
Function<TypeDefinitionRegistry, TypeDefinitionRegistry> configurer) {
|
||||
|
||||
this.graphQlSourceBuilder.configureTypeDefinitionRegistry(configurer);
|
||||
return this;
|
||||
}
|
||||
|
||||
public GraphQlSetup runtimeWiring(RuntimeWiringConfigurer configurer) {
|
||||
this.graphQlSourceBuilder.configureRuntimeWiring(configurer);
|
||||
return this;
|
||||
|
||||
Reference in New Issue
Block a user