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;