Skip to content

Commit 20d52a6

Browse files
committed
Support auto-generation of Relay Connection type
Closes gh-619
1 parent f3c7303 commit 20d52a6

File tree

5 files changed

+292
-3
lines changed

5 files changed

+292
-3
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.graphql.execution;
17+
18+
import java.util.LinkedHashSet;
19+
import java.util.Set;
20+
import java.util.function.Function;
21+
import java.util.stream.Collectors;
22+
23+
import graphql.language.FieldDefinition;
24+
import graphql.language.ImplementingTypeDefinition;
25+
import graphql.language.ListType;
26+
import graphql.language.NonNullType;
27+
import graphql.language.ObjectTypeDefinition;
28+
import graphql.language.Type;
29+
import graphql.language.TypeName;
30+
import graphql.schema.idl.TypeDefinitionRegistry;
31+
32+
/**
33+
* Exposes the {@link #generateConnectionTypes(TypeDefinitionRegistry)
34+
* generateConnectionTypes method} for adding boilerplate type definitions to a
35+
* {@link TypeDefinitionRegistry}, for pagination based on the Relay
36+
* <a href="https://relay.dev/graphql/connections.htm">GraphQL Cursor Connections Specification</a>.
37+
*
38+
* <p>Use {@link GraphQlSource.SchemaResourceBuilder#configureTypeDefinitionRegistry(Function)}
39+
* to enable connection type generation.
40+
*
41+
* @author Rossen Stoyanchev
42+
* @since 1.2
43+
*/
44+
public class ConnectionTypeGenerator {
45+
46+
private static final TypeName STRING_TYPE = new TypeName("String");
47+
48+
private static final TypeName BOOLEAN_TYPE = new TypeName("Boolean");
49+
50+
private static final TypeName PAGE_INFO_TYPE = new TypeName("PageInfo");
51+
52+
53+
/**
54+
* Find fields whose type definition name ends in "Connection", considered
55+
* by the spec to be a {@literal Connection Type}, and add type definitions
56+
* for all such types, if they don't exist already.
57+
* @param registry the registry to check and add types to
58+
* @return the same registry instance with additional types added
59+
*/
60+
public TypeDefinitionRegistry generateConnectionTypes(TypeDefinitionRegistry registry) {
61+
62+
Set<String> typeNames = findConnectionTypeNames(registry);
63+
64+
if (!typeNames.isEmpty()) {
65+
registry.add(ObjectTypeDefinition.newObjectTypeDefinition()
66+
.name(PAGE_INFO_TYPE.getName())
67+
.fieldDefinition(initFieldDefinition("hasPreviousPage", new NonNullType(BOOLEAN_TYPE)))
68+
.fieldDefinition(initFieldDefinition("hasNextPage", new NonNullType(BOOLEAN_TYPE)))
69+
.fieldDefinition(initFieldDefinition("startCursor", STRING_TYPE))
70+
.fieldDefinition(initFieldDefinition("endCursor", STRING_TYPE))
71+
.build());
72+
73+
typeNames.forEach(typeName -> {
74+
75+
System.out.println("Generating pagination types for '" + typeName + "'");
76+
String connectionTypeName = typeName + "Connection";
77+
String edgeTypeName = typeName + "Edge";
78+
79+
registry.add(ObjectTypeDefinition.newObjectTypeDefinition()
80+
.name(connectionTypeName)
81+
.fieldDefinition(initFieldDefinition("edges", new NonNullType(new ListType(new TypeName(edgeTypeName)))))
82+
.fieldDefinition(initFieldDefinition("pageInfo", new NonNullType(PAGE_INFO_TYPE)))
83+
.build());
84+
85+
registry.add(ObjectTypeDefinition.newObjectTypeDefinition()
86+
.name(edgeTypeName)
87+
.fieldDefinition(initFieldDefinition("cursor", new NonNullType(STRING_TYPE)))
88+
.fieldDefinition(initFieldDefinition("node", new NonNullType(new TypeName(typeName))))
89+
.build());
90+
});
91+
}
92+
93+
return registry;
94+
}
95+
96+
private static Set<String> findConnectionTypeNames(TypeDefinitionRegistry registry) {
97+
return registry.types().values().stream()
98+
.filter(definition -> definition instanceof ImplementingTypeDefinition)
99+
.flatMap(definition -> {
100+
ImplementingTypeDefinition<?> typeDefinition = (ImplementingTypeDefinition<?>) definition;
101+
return typeDefinition.getFieldDefinitions().stream()
102+
.map(fieldDefinition -> {
103+
Type<?> type = fieldDefinition.getType();
104+
return (type instanceof NonNullType ? ((NonNullType) type).getType() : type);
105+
})
106+
.filter(type -> type instanceof TypeName)
107+
.map(type -> ((TypeName) type).getName())
108+
.filter(name -> name.endsWith("Connection"))
109+
.filter(name -> registry.getType(name).isEmpty())
110+
.map(name -> name.substring(0, name.length() - "Connection".length()));
111+
})
112+
.collect(Collectors.toCollection(LinkedHashSet::new));
113+
}
114+
115+
private FieldDefinition initFieldDefinition(String name, Type<?> returnType) {
116+
return FieldDefinition.newFieldDefinition().name(name).type(returnType).build();
117+
}
118+
119+
}

spring-graphql/src/main/java/org/springframework/graphql/execution/DefaultSchemaResourceGraphQlSourceBuilder.java

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@
2424
import java.util.List;
2525
import java.util.Set;
2626
import java.util.function.BiFunction;
27+
import java.util.function.Function;
2728
import java.util.stream.Collectors;
2829

2930
import graphql.language.InterfaceTypeDefinition;
@@ -60,8 +61,12 @@ final class DefaultSchemaResourceGraphQlSourceBuilder
6061

6162
private final Set<Resource> schemaResources = new LinkedHashSet<>();
6263

64+
@Nullable
65+
private Function<TypeDefinitionRegistry, TypeDefinitionRegistry> typeDefinitionRegistryConfigurer;
66+
6367
private final List<RuntimeWiringConfigurer> runtimeWiringConfigurers = new ArrayList<>();
6468

69+
6570
@Nullable
6671
private TypeResolver typeResolver;
6772

@@ -75,6 +80,16 @@ public DefaultSchemaResourceGraphQlSourceBuilder schemaResources(Resource... res
7580
return this;
7681
}
7782

83+
@Override
84+
public GraphQlSource.SchemaResourceBuilder configureTypeDefinitionRegistry(
85+
Function<TypeDefinitionRegistry, TypeDefinitionRegistry> configurer) {
86+
87+
this.typeDefinitionRegistryConfigurer = (this.typeDefinitionRegistryConfigurer != null ?
88+
this.typeDefinitionRegistryConfigurer.andThen(configurer) : configurer);
89+
90+
return this;
91+
}
92+
7893
@Override
7994
public DefaultSchemaResourceGraphQlSourceBuilder configureRuntimeWiring(RuntimeWiringConfigurer configurer) {
8095
this.runtimeWiringConfigurers.add(configurer);
@@ -103,6 +118,10 @@ protected GraphQLSchema initGraphQlSchema() {
103118
.reduce(TypeDefinitionRegistry::merge)
104119
.orElseThrow(MissingSchemaException::new);
105120

121+
if (this.typeDefinitionRegistryConfigurer != null) {
122+
registry = this.typeDefinitionRegistryConfigurer.apply(registry);
123+
}
124+
106125
logger.info("Loaded " + this.schemaResources.size() + " resource(s) in the GraphQL schema.");
107126
if (logger.isDebugEnabled()) {
108127
String resources = this.schemaResources.stream()

spring-graphql/src/main/java/org/springframework/graphql/execution/GraphQlSource.java

+15-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@
1919
import java.util.List;
2020
import java.util.function.BiFunction;
2121
import java.util.function.Consumer;
22+
import java.util.function.Function;
2223

2324
import graphql.GraphQL;
2425
import graphql.execution.instrumentation.Instrumentation;
@@ -171,6 +172,19 @@ interface SchemaResourceBuilder extends Builder<SchemaResourceBuilder> {
171172
*/
172173
SchemaResourceBuilder schemaResources(Resource... resources);
173174

175+
/**
176+
* Provide a function to customize the {@link TypeDefinitionRegistry}
177+
* created by parsing schema files. This allows adding or changing schema
178+
* type definitions before {@link GraphQLSchema} is created and validated.
179+
* @param configurer the function to apply accepting the current
180+
* {@link TypeDefinitionRegistry} and returning the one to use, likely
181+
* the same instance since {@link TypeDefinitionRegistry} is mutable.
182+
* @return the current builder
183+
* @sine 1.2
184+
*/
185+
SchemaResourceBuilder configureTypeDefinitionRegistry(
186+
Function<TypeDefinitionRegistry, TypeDefinitionRegistry> configurer);
187+
174188
/**
175189
* Configure the underlying {@link RuntimeWiring.Builder} to register
176190
* data fetchers, custom scalar types, type resolvers, and more.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright 2002-2023 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.graphql.execution;
17+
18+
import java.util.List;
19+
import java.util.function.Function;
20+
21+
import graphql.relay.Connection;
22+
import graphql.relay.ConnectionCursor;
23+
import graphql.relay.DefaultConnection;
24+
import graphql.relay.DefaultConnectionCursor;
25+
import graphql.relay.DefaultEdge;
26+
import graphql.relay.DefaultPageInfo;
27+
import graphql.relay.Edge;
28+
import graphql.schema.DataFetcher;
29+
import org.junit.jupiter.api.Test;
30+
import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper;
31+
32+
import org.springframework.graphql.Book;
33+
import org.springframework.graphql.BookSource;
34+
import org.springframework.graphql.ExecutionGraphQlResponse;
35+
import org.springframework.graphql.GraphQlSetup;
36+
import org.springframework.graphql.TestExecutionRequest;
37+
38+
import static org.assertj.core.api.Assertions.assertThat;
39+
40+
/**
41+
* Unit tests for {@link ConnectionTypeGenerator}.
42+
*
43+
* @author Rossen Stoyanchev
44+
* @since 1.2
45+
*/
46+
public class ConnectionTypeGeneratorTests {
47+
48+
@Test
49+
void connectionTypeGeneration() throws Exception {
50+
51+
String schema = """
52+
type Query {
53+
books: BookConnection
54+
}
55+
type Book {
56+
id: ID
57+
name: String
58+
}
59+
""";
60+
61+
List<Book> books = BookSource.books();
62+
63+
DataFetcher<?> dataFetcher = environment ->
64+
createConnection(books, book -> new DefaultConnectionCursor("book:" + book.getId()));
65+
66+
String document = "{ " +
67+
" books { " +
68+
" edges {" +
69+
" cursor," +
70+
" node {" +
71+
" id" +
72+
" name" +
73+
" }" +
74+
" }" +
75+
" pageInfo {" +
76+
" startCursor," +
77+
" endCursor," +
78+
" hasPreviousPage," +
79+
" hasNextPage" +
80+
" }" +
81+
" }" +
82+
"}";
83+
84+
ExecutionGraphQlResponse response = initGraphQlSetup(schema)
85+
.dataFetcher("Query", "books", dataFetcher)
86+
.toGraphQlService()
87+
.execute(TestExecutionRequest.forDocument(document))
88+
.block();
89+
90+
assertThat(new ObjectMapper().writeValueAsString(response.getData())).isEqualTo(
91+
"{\"books\":{" +
92+
"\"edges\":[" +
93+
"{\"cursor\":\"book:1\",\"node\":{\"id\":\"1\",\"name\":\"Nineteen Eighty-Four\"}}," +
94+
"{\"cursor\":\"book:2\",\"node\":{\"id\":\"2\",\"name\":\"The Great Gatsby\"}}," +
95+
"{\"cursor\":\"book:3\",\"node\":{\"id\":\"3\",\"name\":\"Catch-22\"}}," +
96+
"{\"cursor\":\"book:4\",\"node\":{\"id\":\"4\",\"name\":\"To The Lighthouse\"}}," +
97+
"{\"cursor\":\"book:5\",\"node\":{\"id\":\"5\",\"name\":\"Animal Farm\"}}," +
98+
"{\"cursor\":\"book:53\",\"node\":{\"id\":\"53\",\"name\":\"Breaking Bad\"}}," +
99+
"{\"cursor\":\"book:42\",\"node\":{\"id\":\"42\",\"name\":\"Hitchhiker's Guide to the Galaxy\"}}" +
100+
"]," +
101+
"\"pageInfo\":{" +
102+
"\"startCursor\":\"book:1\"," +
103+
"\"endCursor\":\"book:42\"," +
104+
"\"hasPreviousPage\":false," +
105+
"\"hasNextPage\":false}" +
106+
"}}"
107+
);
108+
}
109+
110+
private GraphQlSetup initGraphQlSetup(String schema) {
111+
ConnectionTypeGenerator generator = new ConnectionTypeGenerator();
112+
return GraphQlSetup.schemaContent(schema).typeDefinitionRegistryConfigurer(generator::generateConnectionTypes);
113+
}
114+
115+
private static <N> Connection<N> createConnection(
116+
List<N> nodes, Function<N, ConnectionCursor> cursorFunction) {
117+
118+
List<Edge<N>> edges = nodes.stream()
119+
.map(node -> (Edge<N>) new DefaultEdge<>(node, cursorFunction.apply(node)))
120+
.toList();
121+
122+
DefaultPageInfo pageInfo = new DefaultPageInfo(
123+
edges.get(0).getCursor(), edges.get(edges.size() - 1).getCursor(), false, false);
124+
125+
return new DefaultConnection<>(edges, pageInfo);
126+
}
127+
128+
}

spring-graphql/src/testFixtures/java/org/springframework/graphql/GraphQlSetup.java

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2023 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -19,12 +19,14 @@
1919
import java.util.ArrayList;
2020
import java.util.Arrays;
2121
import java.util.List;
22+
import java.util.function.Function;
2223

2324
import graphql.GraphQL;
2425
import graphql.execution.instrumentation.Instrumentation;
2526
import graphql.schema.DataFetcher;
2627
import graphql.schema.GraphQLTypeVisitor;
2728
import graphql.schema.TypeResolver;
29+
import graphql.schema.idl.TypeDefinitionRegistry;
2830

2931
import org.springframework.context.ApplicationContext;
3032
import org.springframework.core.io.ByteArrayResource;
@@ -80,6 +82,13 @@ public GraphQlSetup dataFetcher(String type, String field, DataFetcher<?> dataFe
8082
wiringBuilder.type(type, typeBuilder -> typeBuilder.dataFetcher(field, dataFetcher)));
8183
}
8284

85+
public GraphQlSetup typeDefinitionRegistryConfigurer(
86+
Function<TypeDefinitionRegistry, TypeDefinitionRegistry> configurer) {
87+
88+
this.graphQlSourceBuilder.configureTypeDefinitionRegistry(configurer);
89+
return this;
90+
}
91+
8392
public GraphQlSetup runtimeWiring(RuntimeWiringConfigurer configurer) {
8493
this.graphQlSourceBuilder.configureRuntimeWiring(configurer);
8594
return this;

0 commit comments

Comments
 (0)