diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/ConnectionTypeGenerator.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/ConnectionTypeGenerator.java
new file mode 100644
index 00000000..6e2e1334
--- /dev/null
+++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/ConnectionTypeGenerator.java
@@ -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
+ * GraphQL Cursor Connections Specification.
+ *
+ *
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 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 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();
+ }
+
+}
diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/DefaultSchemaResourceGraphQlSourceBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/DefaultSchemaResourceGraphQlSourceBuilder.java
index 4aff26ec..67ca0bcd 100644
--- a/spring-graphql/src/main/java/org/springframework/graphql/execution/DefaultSchemaResourceGraphQlSourceBuilder.java
+++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/DefaultSchemaResourceGraphQlSourceBuilder.java
@@ -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 schemaResources = new LinkedHashSet<>();
+ @Nullable
+ private Function typeDefinitionRegistryConfigurer;
+
private final List runtimeWiringConfigurers = new ArrayList<>();
+
@Nullable
private TypeResolver typeResolver;
@@ -75,6 +80,16 @@ final class DefaultSchemaResourceGraphQlSourceBuilder
return this;
}
+ @Override
+ public GraphQlSource.SchemaResourceBuilder configureTypeDefinitionRegistry(
+ Function 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()
diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/GraphQlSource.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/GraphQlSource.java
index 0fad4103..f1db320f 100644
--- a/spring-graphql/src/main/java/org/springframework/graphql/execution/GraphQlSource.java
+++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/GraphQlSource.java
@@ -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 configurer);
+
/**
* Configure the underlying {@link RuntimeWiring.Builder} to register
* data fetchers, custom scalar types, type resolvers, and more.
diff --git a/spring-graphql/src/test/java/org/springframework/graphql/execution/ConnectionTypeGeneratorTests.java b/spring-graphql/src/test/java/org/springframework/graphql/execution/ConnectionTypeGeneratorTests.java
new file mode 100644
index 00000000..d5ab9dcc
--- /dev/null
+++ b/spring-graphql/src/test/java/org/springframework/graphql/execution/ConnectionTypeGeneratorTests.java
@@ -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 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 Connection createConnection(
+ List nodes, Function cursorFunction) {
+
+ List> edges = nodes.stream()
+ .map(node -> (Edge) 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);
+ }
+
+}
diff --git a/spring-graphql/src/testFixtures/java/org/springframework/graphql/GraphQlSetup.java b/spring-graphql/src/testFixtures/java/org/springframework/graphql/GraphQlSetup.java
index 20b7ef50..3e0a461f 100644
--- a/spring-graphql/src/testFixtures/java/org/springframework/graphql/GraphQlSetup.java
+++ b/spring-graphql/src/testFixtures/java/org/springframework/graphql/GraphQlSetup.java
@@ -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 configurer) {
+
+ this.graphQlSourceBuilder.configureTypeDefinitionRegistry(configurer);
+ return this;
+ }
+
public GraphQlSetup runtimeWiring(RuntimeWiringConfigurer configurer) {
this.graphQlSourceBuilder.configureRuntimeWiring(configurer);
return this;