Add federation support
See gh-864
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 "";
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
type Book @key(fields: "id") @extends {
|
||||
id: ID! @external
|
||||
author: Author
|
||||
}
|
||||
|
||||
type Author {
|
||||
id: ID
|
||||
firstName: String
|
||||
lastName: String
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user