Skip to content

Commit 159ebaa

Browse files
committed
Add federation support
See gh-864
1 parent 254d0c8 commit 159ebaa

16 files changed

+914
-6
lines changed

Diff for: platform/build.gradle

+2
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ dependencies {
3232
api("jakarta.validation:jakarta.validation-api:3.0.2")
3333
api("jakarta.persistence:jakarta.persistence-api:3.1.0")
3434

35+
api("com.apollographql.federation:federation-graphql-java-support:4.3.0")
36+
3537
api("com.google.code.findbugs:jsr305:3.0.2")
3638

3739
api("org.assertj:assertj-core:3.24.2")

Diff for: spring-graphql/build.gradle

+3
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ dependencies {
3232

3333
compileOnly 'com.fasterxml.jackson.core:jackson-databind'
3434

35+
compileOnly 'com.apollographql.federation:federation-graphql-java-support'
36+
3537
testImplementation 'org.junit.jupiter:junit-jupiter'
3638
testImplementation 'org.assertj:assertj-core'
3739
testImplementation 'org.mockito:mockito-core'
@@ -69,6 +71,7 @@ dependencies {
6971
testImplementation 'com.fasterxml.jackson.core:jackson-databind'
7072
testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
7173
testImplementation 'org.apache.tomcat.embed:tomcat-embed-el:10.0.21'
74+
testImplementation 'com.apollographql.federation:federation-graphql-java-support'
7275

7376
testRuntimeOnly 'org.apache.logging.log4j:log4j-core'
7477
testRuntimeOnly 'org.apache.logging.log4j:log4j-slf4j-impl'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Copyright 2002-2024 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+
17+
package org.springframework.graphql.data.federation;
18+
19+
20+
import java.util.ArrayList;
21+
import java.util.Arrays;
22+
import java.util.Collections;
23+
import java.util.LinkedHashMap;
24+
import java.util.List;
25+
import java.util.Map;
26+
import java.util.concurrent.CompletionException;
27+
28+
import com.apollographql.federation.graphqljava._Entity;
29+
import graphql.GraphQLError;
30+
import graphql.GraphqlErrorBuilder;
31+
import graphql.execution.DataFetcherResult;
32+
import graphql.execution.ExecutionStepInfo;
33+
import graphql.schema.DataFetcher;
34+
import graphql.schema.DataFetchingEnvironment;
35+
import graphql.schema.DelegatingDataFetchingEnvironment;
36+
import reactor.core.publisher.Mono;
37+
38+
import org.springframework.graphql.data.method.annotation.support.HandlerDataFetcherExceptionResolver;
39+
import org.springframework.graphql.execution.ErrorType;
40+
import org.springframework.lang.Nullable;
41+
42+
/**
43+
* DataFetcher that handles the "_entities" query by invoking
44+
* {@link EntityHandlerMethod}s.
45+
*
46+
* @author Rossen Stoyanchev
47+
* @since 1.3
48+
* @see com.apollographql.federation.graphqljava.SchemaTransformer#fetchEntities(DataFetcher)
49+
*/
50+
final class EntitiesDataFetcher implements DataFetcher<Mono<DataFetcherResult<List<Object>>>> {
51+
52+
private final Map<String, EntityHandlerMethod> handlerMethods;
53+
54+
private final HandlerDataFetcherExceptionResolver exceptionResolver;
55+
56+
57+
public EntitiesDataFetcher(
58+
Map<String, EntityHandlerMethod> handlerMethods, HandlerDataFetcherExceptionResolver resolver) {
59+
60+
this.handlerMethods = new LinkedHashMap<>(handlerMethods);
61+
this.exceptionResolver = resolver;
62+
}
63+
64+
65+
@Override
66+
public Mono<DataFetcherResult<List<Object>>> get(DataFetchingEnvironment environment) {
67+
List<Map<String, Object>> representations = environment.getArgument(_Entity.argumentName);
68+
69+
List<Mono<Object>> monoList = new ArrayList<>();
70+
for (int index = 0; index < representations.size(); index++) {
71+
Map<String, Object> map = representations.get(index);
72+
if (!(map.get("__typename") instanceof String typename)) {
73+
Exception ex = new RepresentationException(map, "Missing \"__typename\" argument");
74+
monoList.add(resolveException(ex, environment, null, index));
75+
continue;
76+
}
77+
EntityHandlerMethod handlerMethod = this.handlerMethods.get(typename);
78+
if (handlerMethod == null) {
79+
Exception ex = new RepresentationException(map, "No entity fetcher");
80+
monoList.add(resolveException(ex, environment, null, index));
81+
continue;
82+
}
83+
monoList.add(invokeResolver(environment, handlerMethod, map, index));
84+
}
85+
return Mono.zip(monoList, Arrays::asList).map(EntitiesDataFetcher::toDataFetcherResult);
86+
}
87+
88+
private Mono<Object> invokeResolver(
89+
DataFetchingEnvironment env, EntityHandlerMethod handlerMethod, Map<String, Object> map, int index) {
90+
91+
return handlerMethod.getEntity(env, map, index)
92+
.switchIfEmpty(Mono.error(new RepresentationNotResolvedException(map, handlerMethod)))
93+
.onErrorResume(ex -> resolveException(ex, env, handlerMethod, index));
94+
}
95+
96+
private Mono<Object> resolveException(
97+
Throwable ex, DataFetchingEnvironment env, @Nullable EntityHandlerMethod handlerMethod, int index) {
98+
99+
Throwable theEx = (ex instanceof CompletionException ? ex.getCause() : ex);
100+
DataFetchingEnvironment theEnv = new EntityDataFetchingEnvironment(env, index);
101+
Object handler = (handlerMethod != null ? handlerMethod.getBean() : null);
102+
103+
return this.exceptionResolver.resolveException(theEx, theEnv, handler)
104+
.map(ErrorContainer::new)
105+
.switchIfEmpty(Mono.fromCallable(() -> createDefaultError(theEx, theEnv)))
106+
.cast(Object.class);
107+
}
108+
109+
private ErrorContainer createDefaultError(Throwable ex, DataFetchingEnvironment env) {
110+
111+
ErrorType errorType = (ex instanceof RepresentationException representationEx ?
112+
representationEx.getErrorType() : ErrorType.INTERNAL_ERROR);
113+
114+
return new ErrorContainer(GraphqlErrorBuilder.newError(env)
115+
.errorType(errorType)
116+
.message(ex.getMessage())
117+
.build());
118+
}
119+
120+
private static DataFetcherResult<List<Object>> toDataFetcherResult(List<Object> entities) {
121+
List<GraphQLError> errors = new ArrayList<>();
122+
for (int i = 0; i < entities.size(); i++) {
123+
Object entity = entities.get(i);
124+
if (entity instanceof ErrorContainer errorContainer) {
125+
errors.addAll(errorContainer.errors());
126+
entities.set(i, null);
127+
}
128+
}
129+
return DataFetcherResult.<List<Object>>newResult().data(entities).errors(errors).build();
130+
}
131+
132+
133+
private static class EntityDataFetchingEnvironment extends DelegatingDataFetchingEnvironment {
134+
135+
private final ExecutionStepInfo executionStepInfo;
136+
137+
public EntityDataFetchingEnvironment(DataFetchingEnvironment env, int index) {
138+
super(env);
139+
this.executionStepInfo = ExecutionStepInfo.newExecutionStepInfo(env.getExecutionStepInfo())
140+
.path(env.getExecutionStepInfo().getPath().segment(index))
141+
.build();
142+
}
143+
144+
@Override
145+
public ExecutionStepInfo getExecutionStepInfo() {
146+
return this.executionStepInfo;
147+
}
148+
}
149+
150+
151+
private record ErrorContainer(List<GraphQLError> errors) {
152+
153+
ErrorContainer(GraphQLError error) {
154+
this(Collections.singletonList(error));
155+
}
156+
}
157+
158+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/*
2+
* Copyright 2002-2024 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+
17+
package org.springframework.graphql.data.federation;
18+
19+
import java.util.Map;
20+
21+
import graphql.schema.DataFetchingEnvironment;
22+
import graphql.schema.DelegatingDataFetchingEnvironment;
23+
24+
import org.springframework.core.ResolvableType;
25+
import org.springframework.graphql.data.GraphQlArgumentBinder;
26+
import org.springframework.graphql.data.method.annotation.Argument;
27+
import org.springframework.graphql.data.method.annotation.support.ArgumentMethodArgumentResolver;
28+
import org.springframework.validation.BindException;
29+
30+
/**
31+
* Resolver for a method parameter annotated with {@link Argument @Argument}.
32+
* On {@code @EntityMapping} methods, the raw argument value is obtained from
33+
* the "representation" input map for the entity with entries that identify
34+
* the entity uniquely.
35+
*
36+
* @author Rossen Stoyanchev
37+
* @since 1.3
38+
*/
39+
final class EntityArgumentMethodArgumentResolver extends ArgumentMethodArgumentResolver {
40+
41+
42+
EntityArgumentMethodArgumentResolver(GraphQlArgumentBinder argumentBinder) {
43+
super(argumentBinder);
44+
}
45+
46+
47+
@Override
48+
protected Object doBind(
49+
DataFetchingEnvironment environment, String name, ResolvableType targetType) throws BindException {
50+
51+
if (environment instanceof EntityDataFetchingEnvironment entityEnv) {
52+
Map<String, Object> entityMap = entityEnv.getRepresentation();
53+
Object rawValue = entityMap.get(name);
54+
boolean isOmitted = !entityMap.containsKey(name);
55+
return getArgumentBinder().bind(name, rawValue, isOmitted, targetType);
56+
}
57+
58+
throw new IllegalStateException("Expected decorated DataFetchingEnvironment");
59+
}
60+
61+
/**
62+
* Wrap the environment in order to also expose the entity representation map.
63+
*/
64+
public static DataFetchingEnvironment wrap(DataFetchingEnvironment env, Map<String, Object> representation) {
65+
return new EntityDataFetchingEnvironment(env, representation);
66+
}
67+
68+
69+
private static class EntityDataFetchingEnvironment extends DelegatingDataFetchingEnvironment {
70+
71+
private final Map<String, Object> representation;
72+
73+
EntityDataFetchingEnvironment(DataFetchingEnvironment env, Map<String, Object> representation) {
74+
super(env);
75+
this.representation = representation;
76+
}
77+
78+
public Map<String, Object> getRepresentation() {
79+
return this.representation;
80+
}
81+
}
82+
83+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright 2002-2024 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+
17+
package org.springframework.graphql.data.federation;
18+
19+
import java.util.Map;
20+
import java.util.concurrent.CompletableFuture;
21+
import java.util.concurrent.Executor;
22+
23+
import graphql.schema.DataFetchingEnvironment;
24+
import reactor.core.publisher.Mono;
25+
26+
import org.springframework.graphql.data.method.HandlerMethod;
27+
import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite;
28+
import org.springframework.graphql.data.method.annotation.support.DataFetcherHandlerMethodSupport;
29+
import org.springframework.lang.Nullable;
30+
31+
/**
32+
* Invokable controller method to fetch a federated entity.
33+
*
34+
* @author Rossen Stoyanchev
35+
* @since 1.3
36+
*/
37+
final class EntityHandlerMethod extends DataFetcherHandlerMethodSupport {
38+
39+
public EntityHandlerMethod(
40+
HandlerMethod handlerMethod, HandlerMethodArgumentResolverComposite resolvers,
41+
@Nullable Executor executor) {
42+
43+
super(handlerMethod, resolvers, executor);
44+
}
45+
46+
47+
public Mono<Object> getEntity(
48+
DataFetchingEnvironment environment, Map<String, Object> representation, int index) {
49+
50+
Object[] args;
51+
try {
52+
environment = EntityArgumentMethodArgumentResolver.wrap(environment, representation);
53+
args = getMethodArgumentValues(environment, representation);
54+
}
55+
catch (Throwable ex) {
56+
return Mono.error(ex);
57+
}
58+
59+
Object result = doInvoke(environment.getGraphQlContext(), args);
60+
61+
if (result instanceof Mono<?> mono) {
62+
return mono.cast(Object.class);
63+
}
64+
else if (result instanceof CompletableFuture<?> future) {
65+
return Mono.fromFuture(future);
66+
}
67+
else {
68+
return Mono.justOrEmpty(result);
69+
}
70+
}
71+
72+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2002-2024 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+
17+
package org.springframework.graphql.data.federation;
18+
19+
import java.lang.annotation.Documented;
20+
import java.lang.annotation.ElementType;
21+
import java.lang.annotation.Retention;
22+
import java.lang.annotation.RetentionPolicy;
23+
import java.lang.annotation.Target;
24+
25+
import org.springframework.core.annotation.AliasFor;
26+
27+
/**
28+
* Annotation for mapping a handler method to a federated schema type.
29+
*
30+
* @author Rossen Stoyanchev
31+
* @since 1.3
32+
*/
33+
@Target({ElementType.TYPE, ElementType.METHOD})
34+
@Retention(RetentionPolicy.RUNTIME)
35+
@Documented
36+
public @interface EntityMapping {
37+
38+
/**
39+
* Customize the name of the entity to map to.
40+
* <p>By default, if not specified, this is initialized from the method name,
41+
* with the first letter changed to upper case via {@link Character#toUpperCase}.
42+
*/
43+
@AliasFor("value")
44+
String name() default "";
45+
46+
/**
47+
* Effectively an alias for {@link #name()}.
48+
*/
49+
@AliasFor("name")
50+
String value() default "";
51+
52+
}

0 commit comments

Comments
 (0)