Support auto-generation of Relay Connection type

Closes gh-619
This commit is contained in:
rstoyanchev
2023-02-17 07:12:48 +00:00
parent f3c7303737
commit 20d52a60b6
5 changed files with 292 additions and 3 deletions

View File

@@ -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();
}
}

View File

@@ -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()

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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;