diff --git a/platform/build.gradle b/platform/build.gradle index e3777385..9f0e134a 100644 --- a/platform/build.gradle +++ b/platform/build.gradle @@ -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") diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index 9bd1157f..6c92b4a5 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -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' diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/federation/EntitiesDataFetcher.java b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/EntitiesDataFetcher.java new file mode 100644 index 00000000..c1fe5298 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/EntitiesDataFetcher.java @@ -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>>> { + + private final Map handlerMethods; + + private final HandlerDataFetcherExceptionResolver exceptionResolver; + + + public EntitiesDataFetcher( + Map handlerMethods, HandlerDataFetcherExceptionResolver resolver) { + + this.handlerMethods = new LinkedHashMap<>(handlerMethods); + this.exceptionResolver = resolver; + } + + + @Override + public Mono>> get(DataFetchingEnvironment environment) { + List> representations = environment.getArgument(_Entity.argumentName); + + List> monoList = new ArrayList<>(); + for (int index = 0; index < representations.size(); index++) { + Map 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 invokeResolver( + DataFetchingEnvironment env, EntityHandlerMethod handlerMethod, Map 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 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> toDataFetcherResult(List entities) { + List 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.>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 errors) { + + ErrorContainer(GraphQLError error) { + this(Collections.singletonList(error)); + } + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/federation/EntityArgumentMethodArgumentResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/EntityArgumentMethodArgumentResolver.java new file mode 100644 index 00000000..38ff9c33 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/EntityArgumentMethodArgumentResolver.java @@ -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 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 representation) { + return new EntityDataFetchingEnvironment(env, representation); + } + + + private static class EntityDataFetchingEnvironment extends DelegatingDataFetchingEnvironment { + + private final Map representation; + + EntityDataFetchingEnvironment(DataFetchingEnvironment env, Map representation) { + super(env); + this.representation = representation; + } + + public Map getRepresentation() { + return this.representation; + } + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/federation/EntityHandlerMethod.java b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/EntityHandlerMethod.java new file mode 100644 index 00000000..1f457273 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/EntityHandlerMethod.java @@ -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 getEntity( + DataFetchingEnvironment environment, Map 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); + } + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/federation/EntityMapping.java b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/EntityMapping.java new file mode 100644 index 00000000..df527940 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/EntityMapping.java @@ -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. + *

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 ""; + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/federation/FederationSchemaFactory.java b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/FederationSchemaFactory.java new file mode 100644 index 00000000..0ecaac79 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/FederationSchemaFactory.java @@ -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}. + * + *

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 { + + @Nullable + private TypeResolver typeResolver; + + private final Map handlerMethods = new LinkedHashMap<>(); + + + /** + * Configure a resolver that helps to map Java to entity schema type names. + *

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. + *

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) { + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/federation/RepresentationException.java b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/RepresentationException.java new file mode 100644 index 00000000..18a17501 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/RepresentationException.java @@ -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: + *

    + *
  • The "__typename" argument is missing. + *
  • The "__typename" could not be mapped to a controller method. + *
+ * + *

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 representation; + + @Nullable + private final HandlerMethod handlerMethod; + + private final ErrorType errorType; + + + public RepresentationException(Map representation, String msg) { + this(representation, null, msg); + } + + public RepresentationException(Map 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 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; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/federation/RepresentationNotResolvedException.java b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/RepresentationNotResolvedException.java new file mode 100644 index 00000000..0cd59c58 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/RepresentationNotResolvedException.java @@ -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 representation, HandlerMethod handlerMethod) { + super(representation, handlerMethod, "Entity fetcher returned null or completed empty"); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/federation/package-info.java b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/package-info.java new file mode 100644 index 00000000..b82ed4fe --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/federation/package-info.java @@ -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; diff --git a/spring-graphql/src/test/java/org/springframework/graphql/BookSource.java b/spring-graphql/src/test/java/org/springframework/graphql/BookSource.java index 21cf53e5..d7705af6 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/BookSource.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/BookSource.java @@ -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. diff --git a/spring-graphql/src/test/java/org/springframework/graphql/ResponseHelper.java b/spring-graphql/src/test/java/org/springframework/graphql/ResponseHelper.java index eed31b01..9eab3093 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/ResponseHelper.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/ResponseHelper.java @@ -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 path = ResponseHelper.this.errors.get(index).getPath(); + return ResultPath.fromList(path).toString(); + } + public Map extensions() { return ResponseHelper.this.errors.get(index).getExtensions(); } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/federation/EntityMappingInvocationTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/federation/EntityMappingInvocationTests.java new file mode 100644 index 00000000..b2266327 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/federation/EntityMappingInvocationTests.java @@ -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 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 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 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 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.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(); + } + } + +} diff --git a/spring-graphql/src/test/resources/books/federation-schema.graphqls b/spring-graphql/src/test/resources/books/federation-schema.graphqls new file mode 100644 index 00000000..251dca33 --- /dev/null +++ b/spring-graphql/src/test/resources/books/federation-schema.graphqls @@ -0,0 +1,10 @@ +type Book @key(fields: "id") @extends { + id: ID! @external + author: Author +} + +type Author { + id: ID + firstName: String + lastName: String +} diff --git a/spring-graphql/src/testFixtures/java/org/springframework/graphql/GraphQlSetup.java b/spring-graphql/src/testFixtures/java/org/springframework/graphql/GraphQlSetup.java index d10cb3cc..679130fd 100644 --- a/spring-graphql/src/testFixtures/java/org/springframework/graphql/GraphQlSetup.java +++ b/spring-graphql/src/testFixtures/java/org/springframework/graphql/GraphQlSetup.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-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 factory) { + this.graphQlSourceBuilder.schemaFactory(factory); + return this; + } + public GraphQL toGraphQl() { return this.graphQlSourceBuilder.build().graphQl(); } diff --git a/spring-graphql/src/testFixtures/java/org/springframework/graphql/TestExecutionRequest.java b/spring-graphql/src/testFixtures/java/org/springframework/graphql/TestExecutionRequest.java index 7eaf455a..ad3a5134 100644 --- a/spring-graphql/src/testFixtures/java/org/springframework/graphql/TestExecutionRequest.java +++ b/spring-graphql/src/testFixtures/java/org/springframework/graphql/TestExecutionRequest.java @@ -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 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 vars) { + return new TestExecutionRequest(document, vars); } }