Add federation support

See gh-864
This commit is contained in:
rstoyanchev
2024-02-05 17:06:54 +00:00
parent 254d0c87c5
commit 159ebaa278
16 changed files with 914 additions and 6 deletions

View File

@@ -32,6 +32,8 @@ dependencies {
api("jakarta.validation:jakarta.validation-api:3.0.2")
api("jakarta.persistence:jakarta.persistence-api:3.1.0")
api("com.apollographql.federation:federation-graphql-java-support:4.3.0")
api("com.google.code.findbugs:jsr305:3.0.2")
api("org.assertj:assertj-core:3.24.2")

View File

@@ -32,6 +32,8 @@ dependencies {
compileOnly 'com.fasterxml.jackson.core:jackson-databind'
compileOnly 'com.apollographql.federation:federation-graphql-java-support'
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.assertj:assertj-core'
testImplementation 'org.mockito:mockito-core'
@@ -69,6 +71,7 @@ dependencies {
testImplementation 'com.fasterxml.jackson.core:jackson-databind'
testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
testImplementation 'org.apache.tomcat.embed:tomcat-embed-el:10.0.21'
testImplementation 'com.apollographql.federation:federation-graphql-java-support'
testRuntimeOnly 'org.apache.logging.log4j:log4j-core'
testRuntimeOnly 'org.apache.logging.log4j:log4j-slf4j-impl'

View File

@@ -0,0 +1,158 @@
/*
* Copyright 2002-2024 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.data.federation;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletionException;
import com.apollographql.federation.graphqljava._Entity;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.execution.DataFetcherResult;
import graphql.execution.ExecutionStepInfo;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import graphql.schema.DelegatingDataFetchingEnvironment;
import reactor.core.publisher.Mono;
import org.springframework.graphql.data.method.annotation.support.HandlerDataFetcherExceptionResolver;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.lang.Nullable;
/**
* DataFetcher that handles the "_entities" query by invoking
* {@link EntityHandlerMethod}s.
*
* @author Rossen Stoyanchev
* @since 1.3
* @see com.apollographql.federation.graphqljava.SchemaTransformer#fetchEntities(DataFetcher)
*/
final class EntitiesDataFetcher implements DataFetcher<Mono<DataFetcherResult<List<Object>>>> {
private final Map<String, EntityHandlerMethod> handlerMethods;
private final HandlerDataFetcherExceptionResolver exceptionResolver;
public EntitiesDataFetcher(
Map<String, EntityHandlerMethod> handlerMethods, HandlerDataFetcherExceptionResolver resolver) {
this.handlerMethods = new LinkedHashMap<>(handlerMethods);
this.exceptionResolver = resolver;
}
@Override
public Mono<DataFetcherResult<List<Object>>> get(DataFetchingEnvironment environment) {
List<Map<String, Object>> representations = environment.getArgument(_Entity.argumentName);
List<Mono<Object>> monoList = new ArrayList<>();
for (int index = 0; index < representations.size(); index++) {
Map<String, Object> map = representations.get(index);
if (!(map.get("__typename") instanceof String typename)) {
Exception ex = new RepresentationException(map, "Missing \"__typename\" argument");
monoList.add(resolveException(ex, environment, null, index));
continue;
}
EntityHandlerMethod handlerMethod = this.handlerMethods.get(typename);
if (handlerMethod == null) {
Exception ex = new RepresentationException(map, "No entity fetcher");
monoList.add(resolveException(ex, environment, null, index));
continue;
}
monoList.add(invokeResolver(environment, handlerMethod, map, index));
}
return Mono.zip(monoList, Arrays::asList).map(EntitiesDataFetcher::toDataFetcherResult);
}
private Mono<Object> invokeResolver(
DataFetchingEnvironment env, EntityHandlerMethod handlerMethod, Map<String, Object> map, int index) {
return handlerMethod.getEntity(env, map, index)
.switchIfEmpty(Mono.error(new RepresentationNotResolvedException(map, handlerMethod)))
.onErrorResume(ex -> resolveException(ex, env, handlerMethod, index));
}
private Mono<Object> resolveException(
Throwable ex, DataFetchingEnvironment env, @Nullable EntityHandlerMethod handlerMethod, int index) {
Throwable theEx = (ex instanceof CompletionException ? ex.getCause() : ex);
DataFetchingEnvironment theEnv = new EntityDataFetchingEnvironment(env, index);
Object handler = (handlerMethod != null ? handlerMethod.getBean() : null);
return this.exceptionResolver.resolveException(theEx, theEnv, handler)
.map(ErrorContainer::new)
.switchIfEmpty(Mono.fromCallable(() -> createDefaultError(theEx, theEnv)))
.cast(Object.class);
}
private ErrorContainer createDefaultError(Throwable ex, DataFetchingEnvironment env) {
ErrorType errorType = (ex instanceof RepresentationException representationEx ?
representationEx.getErrorType() : ErrorType.INTERNAL_ERROR);
return new ErrorContainer(GraphqlErrorBuilder.newError(env)
.errorType(errorType)
.message(ex.getMessage())
.build());
}
private static DataFetcherResult<List<Object>> toDataFetcherResult(List<Object> entities) {
List<GraphQLError> errors = new ArrayList<>();
for (int i = 0; i < entities.size(); i++) {
Object entity = entities.get(i);
if (entity instanceof ErrorContainer errorContainer) {
errors.addAll(errorContainer.errors());
entities.set(i, null);
}
}
return DataFetcherResult.<List<Object>>newResult().data(entities).errors(errors).build();
}
private static class EntityDataFetchingEnvironment extends DelegatingDataFetchingEnvironment {
private final ExecutionStepInfo executionStepInfo;
public EntityDataFetchingEnvironment(DataFetchingEnvironment env, int index) {
super(env);
this.executionStepInfo = ExecutionStepInfo.newExecutionStepInfo(env.getExecutionStepInfo())
.path(env.getExecutionStepInfo().getPath().segment(index))
.build();
}
@Override
public ExecutionStepInfo getExecutionStepInfo() {
return this.executionStepInfo;
}
}
private record ErrorContainer(List<GraphQLError> errors) {
ErrorContainer(GraphQLError error) {
this(Collections.singletonList(error));
}
}
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2002-2024 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.data.federation;
import java.util.Map;
import graphql.schema.DataFetchingEnvironment;
import graphql.schema.DelegatingDataFetchingEnvironment;
import org.springframework.core.ResolvableType;
import org.springframework.graphql.data.GraphQlArgumentBinder;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.support.ArgumentMethodArgumentResolver;
import org.springframework.validation.BindException;
/**
* Resolver for a method parameter annotated with {@link Argument @Argument}.
* On {@code @EntityMapping} methods, the raw argument value is obtained from
* the "representation" input map for the entity with entries that identify
* the entity uniquely.
*
* @author Rossen Stoyanchev
* @since 1.3
*/
final class EntityArgumentMethodArgumentResolver extends ArgumentMethodArgumentResolver {
EntityArgumentMethodArgumentResolver(GraphQlArgumentBinder argumentBinder) {
super(argumentBinder);
}
@Override
protected Object doBind(
DataFetchingEnvironment environment, String name, ResolvableType targetType) throws BindException {
if (environment instanceof EntityDataFetchingEnvironment entityEnv) {
Map<String, Object> entityMap = entityEnv.getRepresentation();
Object rawValue = entityMap.get(name);
boolean isOmitted = !entityMap.containsKey(name);
return getArgumentBinder().bind(name, rawValue, isOmitted, targetType);
}
throw new IllegalStateException("Expected decorated DataFetchingEnvironment");
}
/**
* Wrap the environment in order to also expose the entity representation map.
*/
public static DataFetchingEnvironment wrap(DataFetchingEnvironment env, Map<String, Object> representation) {
return new EntityDataFetchingEnvironment(env, representation);
}
private static class EntityDataFetchingEnvironment extends DelegatingDataFetchingEnvironment {
private final Map<String, Object> representation;
EntityDataFetchingEnvironment(DataFetchingEnvironment env, Map<String, Object> representation) {
super(env);
this.representation = representation;
}
public Map<String, Object> getRepresentation() {
return this.representation;
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright 2002-2024 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.data.federation;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import graphql.schema.DataFetchingEnvironment;
import reactor.core.publisher.Mono;
import org.springframework.graphql.data.method.HandlerMethod;
import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite;
import org.springframework.graphql.data.method.annotation.support.DataFetcherHandlerMethodSupport;
import org.springframework.lang.Nullable;
/**
* Invokable controller method to fetch a federated entity.
*
* @author Rossen Stoyanchev
* @since 1.3
*/
final class EntityHandlerMethod extends DataFetcherHandlerMethodSupport {
public EntityHandlerMethod(
HandlerMethod handlerMethod, HandlerMethodArgumentResolverComposite resolvers,
@Nullable Executor executor) {
super(handlerMethod, resolvers, executor);
}
public Mono<Object> getEntity(
DataFetchingEnvironment environment, Map<String, Object> representation, int index) {
Object[] args;
try {
environment = EntityArgumentMethodArgumentResolver.wrap(environment, representation);
args = getMethodArgumentValues(environment, representation);
}
catch (Throwable ex) {
return Mono.error(ex);
}
Object result = doInvoke(environment.getGraphQlContext(), args);
if (result instanceof Mono<?> mono) {
return mono.cast(Object.class);
}
else if (result instanceof CompletableFuture<?> future) {
return Mono.fromFuture(future);
}
else {
return Mono.justOrEmpty(result);
}
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2002-2024 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.data.federation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;
/**
* Annotation for mapping a handler method to a federated schema type.
*
* @author Rossen Stoyanchev
* @since 1.3
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface EntityMapping {
/**
* Customize the name of the entity to map to.
* <p>By default, if not specified, this is initialized from the method name,
* with the first letter changed to upper case via {@link Character#toUpperCase}.
*/
@AliasFor("value")
String name() default "";
/**
* Effectively an alias for {@link #name()}.
*/
@AliasFor("name")
String value() default "";
}

View File

@@ -0,0 +1,172 @@
/*
* Copyright 2002-2024 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.data.federation;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.BiFunction;
import com.apollographql.federation.graphqljava.Federation;
import com.apollographql.federation.graphqljava.SchemaTransformer;
import graphql.schema.DataFetcher;
import graphql.schema.GraphQLSchema;
import graphql.schema.TypeResolver;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.TypeDefinitionRegistry;
import org.springframework.context.ApplicationContext;
import org.springframework.context.expression.BeanFactoryResolver;
import org.springframework.core.KotlinDetector;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.graphql.data.GraphQlArgumentBinder;
import org.springframework.graphql.data.method.HandlerMethod;
import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite;
import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerDetectionSupport;
import org.springframework.graphql.data.method.annotation.support.AuthenticationPrincipalArgumentResolver;
import org.springframework.graphql.data.method.annotation.support.ContextValueMethodArgumentResolver;
import org.springframework.graphql.data.method.annotation.support.ContinuationHandlerMethodArgumentResolver;
import org.springframework.graphql.data.method.annotation.support.DataFetchingEnvironmentMethodArgumentResolver;
import org.springframework.graphql.data.method.annotation.support.LocalContextValueMethodArgumentResolver;
import org.springframework.graphql.data.method.annotation.support.PrincipalMethodArgumentResolver;
import org.springframework.graphql.execution.ClassNameTypeResolver;
import org.springframework.graphql.execution.GraphQlSource.SchemaResourceBuilder;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* Detects {@link EntityMapping @EntityMapping} handler methods on controllers
* declared in Spring configuration, and provides factory methods to create
* {@link GraphQLSchema} or {@link SchemaTransformer}.
*
* <p>This class is intended to be declared as a bean in Spring configuration,
* and plugged in via {@link SchemaResourceBuilder#schemaFactory(BiFunction)}.
*
* @author Rossen Stoyanchev
* @since 1.3
* @see Federation#transform(TypeDefinitionRegistry, RuntimeWiring)
*
*/
public final class FederationSchemaFactory
extends AnnotatedControllerDetectionSupport<FederationSchemaFactory.EntityMappingInfo> {
@Nullable
private TypeResolver typeResolver;
private final Map<String, EntityHandlerMethod> handlerMethods = new LinkedHashMap<>();
/**
* Configure a resolver that helps to map Java to entity schema type names.
* <p>By default this is {@link ClassNameTypeResolver}.
* @see SchemaTransformer#resolveEntityType(TypeResolver)
*/
public void setTypeResolver(@Nullable TypeResolver typeResolver) {
this.typeResolver = typeResolver;
}
@Override
public void afterPropertiesSet() {
super.afterPropertiesSet();
detectHandlerMethods().forEach(info ->
this.handlerMethods.put(info.typeName(),
new EntityHandlerMethod(info.handlerMethod(), getArgumentResolvers(), getExecutor())));
if (this.typeResolver == null) {
this.typeResolver = new ClassNameTypeResolver();
}
}
@Override
protected HandlerMethodArgumentResolverComposite initArgumentResolvers() {
HandlerMethodArgumentResolverComposite resolvers = new HandlerMethodArgumentResolverComposite();
GraphQlArgumentBinder argumentBinder =
new GraphQlArgumentBinder(getConversionService(), isFallBackOnDirectFieldAccess());
// Annotation based
resolvers.addResolver(new ContextValueMethodArgumentResolver());
resolvers.addResolver(new LocalContextValueMethodArgumentResolver());
resolvers.addResolver(new EntityArgumentMethodArgumentResolver(argumentBinder));
// Type based
resolvers.addResolver(new DataFetchingEnvironmentMethodArgumentResolver());
if (springSecurityPresent) {
ApplicationContext context = obtainApplicationContext();
resolvers.addResolver(new PrincipalMethodArgumentResolver());
resolvers.addResolver(new AuthenticationPrincipalArgumentResolver(new BeanFactoryResolver(context)));
}
if (KotlinDetector.isKotlinPresent()) {
resolvers.addResolver(new ContinuationHandlerMethodArgumentResolver());
}
return resolvers;
}
@Override
@Nullable
protected EntityMappingInfo getMappingInfo(Method method, Object handler, Class<?> handlerType) {
EntityMapping mapping = AnnotatedElementUtils.findMergedAnnotation(method, EntityMapping.class);
if (mapping == null) {
return null;
}
String typeName = mapping.name();
if (!StringUtils.hasText(typeName)) {
typeName = StringUtils.capitalize(method.getName());
}
HandlerMethod handlerMethod = createHandlerMethod(method, handler, handlerType);
return new EntityMappingInfo(typeName, handlerMethod);
}
@Override
protected HandlerMethod getHandlerMethod(EntityMappingInfo mappingInfo) {
return mappingInfo.handlerMethod();
}
/**
* Create {@link GraphQLSchema} via {@link SchemaTransformer}, setting up
* the "_entities" {@link DataFetcher} and {@link TypeResolver} for federated types.
* <p>Use this to supply a {@link SchemaResourceBuilder#schemaFactory(BiFunction) schemaFactory}.
*/
public GraphQLSchema createGraphQLSchema(TypeDefinitionRegistry registry, RuntimeWiring wiring) {
return createSchemaTransformer(registry, wiring).build();
}
/**
* Alternative to {@link #createGraphQLSchema(TypeDefinitionRegistry, RuntimeWiring)}
* that allows calling additional methods on {@link SchemaTransformer}.
*/
public SchemaTransformer createSchemaTransformer(TypeDefinitionRegistry registry, RuntimeWiring wiring) {
Assert.state(this.typeResolver != null, "afterPropertiesSet not called");
return Federation.transform(registry, wiring)
.fetchEntities(new EntitiesDataFetcher(this.handlerMethods, getExceptionResolver()))
.resolveEntityType(this.typeResolver);
}
public record EntityMappingInfo(String typeName, HandlerMethod handlerMethod) {
}
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2002-2024 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.data.federation;
import java.util.Map;
import org.springframework.graphql.data.method.HandlerMethod;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.lang.Nullable;
/**
* Raised when a representation could not be resolved because:
* <ul>
* <li>The "__typename" argument is missing.
* <li>The "__typename" could not be mapped to a controller method.
* </ul>
*
* <p>The {@link RepresentationNotResolvedException} subtype is raised when a
* resolver returned {@code null} or completed empty.
*
* @author Rossen Stoyanchev
* @since 1.3
*/
@SuppressWarnings("serial")
public class RepresentationException extends RuntimeException {
private final Map<String, Object> representation;
@Nullable
private final HandlerMethod handlerMethod;
private final ErrorType errorType;
public RepresentationException(Map<String, Object> representation, String msg) {
this(representation, null, msg);
}
public RepresentationException(Map<String, Object> representation, @Nullable HandlerMethod hm, String msg) {
super(msg);
this.representation = representation;
this.handlerMethod = hm;
this.errorType = (representation.get("__typename") == null ? ErrorType.BAD_REQUEST : ErrorType.INTERNAL_ERROR);
}
/**
* Return the entity "representation" input map.
*/
@Nullable
public Map<String, Object> getRepresentation() {
return this.representation;
}
/**
* Return the mapped controller method, or {@code null} if it could not be mapped.
*/
@Nullable
public HandlerMethod getHandlerMethod() {
return this.handlerMethod;
}
/**
* The classification for the error, {@link ErrorType#BAD_REQUEST} if the
* "__typename" argument was missing, or {@link ErrorType#INTERNAL_ERROR} otherwise.
*/
public ErrorType getErrorType() {
return this.errorType;
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2002-2024 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.data.federation;
import java.util.Map;
import org.springframework.graphql.data.method.HandlerMethod;
/**
* Specialization of {@link RepresentationException} that indicates a resolver
* returned {@code null} or completed empty.
*
* @author Rossen Stoyanchev
* @since 1.3
*/
@SuppressWarnings("serial")
public class RepresentationNotResolvedException extends RepresentationException {
public RepresentationNotResolvedException(Map<String, Object> representation, HandlerMethod handlerMethod) {
super(representation, handlerMethod, "Entity fetcher returned null or completed empty");
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2020-2021 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.
*/
/**
* Integration for Apollo federation that provides a
* {@link org.springframework.graphql.data.federation.FederationSchemaFactory} to
* set up the schema with, and supports the fetching of federated types
* via {@link org.springframework.graphql.data.federation.EntityMapping @EntityMapping}
* controller methods.
*/
@NonNullApi
@NonNullFields
package org.springframework.graphql.data.federation;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2024 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.

View File

@@ -28,6 +28,7 @@ import com.jayway.jsonpath.spi.json.JacksonJsonProvider;
import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider;
import graphql.ExecutionResult;
import graphql.GraphQLError;
import graphql.execution.ResultPath;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.reactivestreams.Publisher;
@@ -186,6 +187,11 @@ public class ResponseHelper {
return ResponseHelper.this.errors.get(index).getErrorType().toString();
}
public String path() {
List<Object> path = ResponseHelper.this.errors.get(index).getPath();
return ResultPath.fromList(path).toString();
}
public Map<String, Object> extensions() {
return ResponseHelper.this.errors.get(index).getExtensions();
}

View File

@@ -0,0 +1,182 @@
/*
* Copyright 2002-2024 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.data.federation;
import java.util.List;
import java.util.Map;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.graphql.Author;
import org.springframework.graphql.Book;
import org.springframework.graphql.BookSource;
import org.springframework.graphql.ExecutionGraphQlRequest;
import org.springframework.graphql.ExecutionGraphQlResponse;
import org.springframework.graphql.GraphQlSetup;
import org.springframework.graphql.ResponseHelper;
import org.springframework.graphql.TestExecutionGraphQlService;
import org.springframework.graphql.TestExecutionRequest;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.GraphQlExceptionHandler;
import org.springframework.graphql.data.method.annotation.SchemaMapping;
import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Controller;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for requests handled through {@code @EntityMapping} methods.
*
* @author Rossen Stoyanchev
*/
public class EntityMappingInvocationTests {
private static final Resource federationSchema = new ClassPathResource("books/federation-schema.graphqls");
private static final String document = """
query Entities($representations: [_Any!]!) {
_entities(representations: $representations) {
...on Book {
id
author {
id
firstName
lastName
}
}}
}
""";
@Test
void fetchEntities() {
Map<String, Object> variables =
Map.of("representations", List.of(
Map.of("__typename", "Book", "id", "3"),
Map.of("__typename", "Book", "id", "5")));
ExecutionGraphQlRequest request = TestExecutionRequest.forDocumentAndVars(document, variables);
Mono<ExecutionGraphQlResponse> responseMono = graphQlService().execute(request);
ResponseHelper helper = ResponseHelper.forResponse(responseMono);
Author author = helper.toEntity("_entities[0].author", Author.class);
assertThat(author.getFirstName()).isEqualTo("Joseph");
assertThat(author.getLastName()).isEqualTo("Heller");
author = helper.toEntity("_entities[1].author", Author.class);
assertThat(author.getFirstName()).isEqualTo("George");
assertThat(author.getLastName()).isEqualTo("Orwell");
}
@Test
void fetchEntitiesWithExceptions() {
Map<String, Object> variables =
Map.of("representations", List.of(
Map.of("id", "-95"), // RepresentationException, no "__typename"
Map.of("__typename", "Unknown"), // RepresentationException, no fetcher
Map.of("__typename", "Book", "id", "-97"), // IllegalArgumentException
Map.of("__typename", "Book", "id", "-98"), // IllegalStateException
Map.of("__typename", "Book", "id", "-99"), // null
Map.of("__typename", "Book", "id", "3"),
Map.of("__typename", "Book", "id", "5")));
ExecutionGraphQlRequest request = TestExecutionRequest.forDocumentAndVars(document, variables);
Mono<ExecutionGraphQlResponse> responseMono = graphQlService().execute(request);
ResponseHelper helper = ResponseHelper.forResponse(responseMono);
int i = 0;
assertError(helper, i++, "BAD_REQUEST", "Missing \"__typename\" argument");
assertError(helper, i++, "INTERNAL_ERROR", "No entity fetcher");
assertError(helper, i++, "BAD_REQUEST", "handled");
assertError(helper, i++, "INTERNAL_ERROR", "not handled");
assertError(helper, i++, "INTERNAL_ERROR", "Entity fetcher returned null or completed empty");
assertThat(helper.toEntity("_entities[" + i++ + "].author", Author.class).getLastName()).isEqualTo("Heller");
assertThat(helper.toEntity("_entities[" + i++ + "].author", Author.class).getLastName()).isEqualTo("Orwell");
}
private static void assertError(ResponseHelper helper, int i, String errorType, String msg) {
String path = "_entities[" + i + "]";
assertThat(helper.error(i).message()).isEqualTo(msg);
assertThat(helper.error(i).errorType()).isEqualTo(errorType);
assertThat(helper.error(i).path()).isEqualTo("/" + path);
assertThat(helper.<Object>rawValue(path)).isNull();
}
private TestExecutionGraphQlService graphQlService() {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
context.register(AuthorController.class);
context.refresh();
AnnotatedControllerConfigurer configurer = new AnnotatedControllerConfigurer();
configurer.setApplicationContext(context);
configurer.afterPropertiesSet();
FederationSchemaFactory schemaFactory = new FederationSchemaFactory();
schemaFactory.setApplicationContext(context);
schemaFactory.afterPropertiesSet();
return GraphQlSetup.schemaResource(federationSchema)
.runtimeWiring(configurer)
.schemaFactory(schemaFactory::createGraphQLSchema)
.toGraphQlService();
}
@SuppressWarnings("unused")
@Controller
private static class AuthorController {
@Nullable
@EntityMapping
public Book book(@Argument int id) {
return switch (id) {
case -97 -> throw new IllegalArgumentException("handled");
case -98 -> throw new IllegalStateException("not handled");
case -99 -> null;
default -> new Book((long) id, null, (Long) null);
};
}
@SchemaMapping
public Author author(Book book) {
long id = book.getId();
return BookSource.getBook(id).getAuthor();
}
@GraphQlExceptionHandler
public GraphQLError handle(IllegalArgumentException ex, DataFetchingEnvironment env) {
return GraphqlErrorBuilder.newError(env)
.errorType(ErrorType.BAD_REQUEST)
.message(ex.getMessage())
.build();
}
}
}

View File

@@ -0,0 +1,10 @@
type Book @key(fields: "id") @extends {
id: ID! @external
author: Author
}
type Author {
id: ID
firstName: String
lastName: String
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* Copyright 2002-2024 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,16 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.function.BiFunction;
import graphql.GraphQL;
import graphql.execution.instrumentation.Instrumentation;
import graphql.schema.DataFetcher;
import graphql.schema.GraphQLSchema;
import graphql.schema.GraphQLTypeVisitor;
import graphql.schema.TypeResolver;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.TypeDefinitionRegistry;
import org.springframework.context.ApplicationContext;
import org.springframework.core.io.ByteArrayResource;
@@ -137,6 +141,11 @@ public class GraphQlSetup implements GraphQlServiceSetup {
return this;
}
public GraphQlSetup schemaFactory(BiFunction<TypeDefinitionRegistry, RuntimeWiring, GraphQLSchema> factory) {
this.graphQlSourceBuilder.schemaFactory(factory);
return this;
}
public GraphQL toGraphQl() {
return this.graphQlSourceBuilder.build().graphQl();
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2022 the original author or authors.
* Copyright 2002-2024 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.
@@ -16,7 +16,9 @@
package org.springframework.graphql;
import java.util.Collections;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.graphql.support.DefaultExecutionGraphQlRequest;
@@ -32,13 +34,17 @@ public class TestExecutionRequest extends DefaultExecutionGraphQlRequest {
private static final AtomicLong idIndex = new AtomicLong();
private TestExecutionRequest(String document) {
super(document, null, null, null, String.valueOf(idIndex.incrementAndGet()), Locale.ENGLISH);
private TestExecutionRequest(String document, Map<String, Object> vars) {
super(document, null, vars, null, String.valueOf(idIndex.incrementAndGet()), Locale.ENGLISH);
}
public static ExecutionGraphQlRequest forDocument(String document) {
return new TestExecutionRequest(document);
return new TestExecutionRequest(document, Collections.emptyMap());
}
public static ExecutionGraphQlRequest forDocumentAndVars(String document, Map<String, Object> vars) {
return new TestExecutionRequest(document, vars);
}
}