diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/GraphQlExceptionHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/GraphQlExceptionHandler.java new file mode 100644 index 00000000..383f5773 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/GraphQlExceptionHandler.java @@ -0,0 +1,55 @@ +/* + * Copyright 2002-2023 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.method.annotation; + +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 java.util.List; + +/** + * Declares a method as a handler of exceptions raised while fetching data + * for a field. When declared in an + * {@link org.springframework.stereotype.Controller @Controller}, it applies to + * {@code @SchemaMapping} methods of that controller only. When declared in an + * {@link org.springframework.web.bind.annotation.ControllerAdvice @ControllerAdvice} + * it applies across controllers. + * + *

You can also use annotated exception handler methods in + * {@code @ControllerAdvice} beans to handle exceptions from non-controller + * {@link graphql.schema.DataFetcher}s by obtaining + * {@link org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer#getExceptionResolver()} + * and registering it with + * {@link org.springframework.graphql.execution.GraphQlSource.Builder#exceptionResolvers(List) + * GraphQlSource.Builder}. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface GraphQlExceptionHandler { + + /** + * Exceptions handled by the annotated method. If empty, defaults to + * exception types declared in the method signature. + */ + Class[] value() default {}; + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java index 81142a41..4c3ad62c 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java @@ -23,6 +23,7 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.Callable; @@ -30,6 +31,7 @@ import java.util.concurrent.Executor; import java.util.function.Consumer; import java.util.stream.Collectors; +import graphql.execution.DataFetcherResult; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import graphql.schema.FieldCoordinates; @@ -38,6 +40,7 @@ import graphql.schema.idl.RuntimeWiring; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.dataloader.DataLoader; +import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -62,7 +65,9 @@ import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComp import org.springframework.graphql.data.method.annotation.BatchMapping; import org.springframework.graphql.data.method.annotation.SchemaMapping; import org.springframework.graphql.execution.BatchLoaderRegistry; +import org.springframework.graphql.execution.DataFetcherExceptionResolver; import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.execution.SubscriptionPublisherException; import org.springframework.lang.Nullable; import org.springframework.stereotype.Controller; import org.springframework.util.Assert; @@ -127,6 +132,9 @@ public class AnnotatedControllerConfigurer @Nullable private ValidationHelper validationHelper; + @Nullable + private AnnotatedControllerExceptionResolver exceptionResolver; + /** * Add a {@code FormatterRegistrar} to customize the {@link ConversionService} @@ -165,6 +173,25 @@ public class AnnotatedControllerConfigurer this.applicationContext = applicationContext; } + /** + * Return a {@link DataFetcherExceptionResolver} that resolves exceptions with + * {@code @GraphQlExceptionHandler} methods in {@code @ControllerAdvice} + * classes declared in Spring configuration. This is useful primarily for + * exceptions from non-controller {@link DataFetcher}s since exceptions from + * {@code @SchemaMapping} controller methods are handled automatically at + * the point of invocation. + * + * @return a resolver instance that can be plugged into + * {@link org.springframework.graphql.execution.GraphQlSource.Builder#exceptionResolvers(List) + * GraphQlSource.Builder} + * + * @since 1.2 + */ + public DataFetcherExceptionResolver getExceptionResolver() { + Assert.notNull(this.exceptionResolver, "ExceptionResolver is not initialized, was afterPropertiesSet called?"); + return (ex, env) -> this.exceptionResolver.resolveException(ex, env, null); + } + @Nullable HandlerMethodArgumentResolverComposite getArgumentResolvers() { return this.argumentResolvers; @@ -175,6 +202,11 @@ public class AnnotatedControllerConfigurer this.argumentResolvers = initArgumentResolvers(); + this.exceptionResolver = new AnnotatedControllerExceptionResolver(this.argumentResolvers); + if (this.applicationContext != null) { + this.exceptionResolver.registerControllerAdvice(this.applicationContext); + } + if (beanValidationPresent) { this.validationHelper = ValidationHelper.createIfValidatorPresent(obtainApplicationContext()); } @@ -222,12 +254,13 @@ public class AnnotatedControllerConfigurer @Override public void configure(RuntimeWiring.Builder runtimeWiringBuilder) { Assert.state(this.argumentResolvers != null, "`argumentResolvers` is not initialized"); + Assert.state(this.exceptionResolver != null, "`exceptionResolver` is not initialized"); findHandlerMethods().forEach((info) -> { DataFetcher dataFetcher; if (!info.isBatchMapping()) { dataFetcher = new SchemaMappingDataFetcher( - info, this.argumentResolvers, this.validationHelper, this.executor); + info, this.argumentResolvers, this.validationHelper, this.exceptionResolver, this.executor); } else { String dataLoaderKey = registerBatchLoader(info); @@ -493,19 +526,30 @@ public class AnnotatedControllerConfigurer @Nullable private final Consumer methodValidationHelper; + private final AnnotatedControllerExceptionResolver exceptionResolver; + @Nullable private final Executor executor; private final boolean subscription; SchemaMappingDataFetcher( - MappingInfo info, HandlerMethodArgumentResolverComposite resolvers, - @Nullable ValidationHelper validationHelper, @Nullable Executor executor) { + MappingInfo info, HandlerMethodArgumentResolverComposite argumentResolvers, + @Nullable ValidationHelper helper, AnnotatedControllerExceptionResolver exceptionResolver, + @Nullable Executor executor) { this.info = info; - this.argumentResolvers = resolvers; - this.methodValidationHelper = (validationHelper != null ? - validationHelper.getValidationHelperFor(info.getHandlerMethod()) : null); + this.argumentResolvers = argumentResolvers; + + this.methodValidationHelper = + (helper != null ? helper.getValidationHelperFor(info.getHandlerMethod()) : null); + + // Register controllers early to validate exception handler return types + Class controllerType = info.getHandlerMethod().getBeanType(); + exceptionResolver.registerController(controllerType); + + this.exceptionResolver = exceptionResolver; + this.executor = executor; this.subscription = this.info.getCoordinates().getTypeName().equalsIgnoreCase("Subscription"); } @@ -517,17 +561,53 @@ public class AnnotatedControllerConfigurer return this.info.getHandlerMethod(); } - @Override - @SuppressWarnings("ConstantConditions") + @SuppressWarnings({"ConstantConditions", "ReactiveStreamsUnusedPublisher"}) public Object get(DataFetchingEnvironment environment) throws Exception { DataFetcherHandlerMethod handlerMethod = new DataFetcherHandlerMethod( getHandlerMethod(), this.argumentResolvers, this.methodValidationHelper, this.executor, this.subscription); - return handlerMethod.invoke(environment); + try { + Object result = handlerMethod.invoke(environment); + return applyExceptionHandling(environment, handlerMethod, result); + } + catch (Throwable ex) { + return handleException(ex, environment, handlerMethod); + } } + + @SuppressWarnings({"unchecked", "ReactiveStreamsUnusedPublisher"}) + private Object applyExceptionHandling( + DataFetchingEnvironment env, DataFetcherHandlerMethod handlerMethod, Object result) { + + if (this.subscription && result instanceof Publisher publisher) { + result = Flux.from(publisher).onErrorResume(ex -> handleSubscriptionError(ex, env, handlerMethod)); + } + else if (result instanceof Mono) { + result = ((Mono) result).onErrorResume(ex -> (Mono) handleException(ex, env, handlerMethod)); + } + else if (result instanceof Flux) { + result = ((Flux) result).onErrorResume(ex -> (Mono) handleException(ex, env, handlerMethod)); + } + return result; + } + + private Mono> handleException( + Throwable ex, DataFetchingEnvironment env, DataFetcherHandlerMethod handlerMethod) { + + return this.exceptionResolver.resolveException(ex, env, handlerMethod.getBean()) + .map(errors -> DataFetcherResult.newResult().errors(errors).build()); + } + + private Publisher handleSubscriptionError( + Throwable ex, DataFetchingEnvironment env, DataFetcherHandlerMethod handlerMethod) { + + return this.exceptionResolver.resolveException(ex, env, handlerMethod.getBean()) + .flatMap(errors -> Mono.error(new SubscriptionPublisherException(errors, ex))); + } + } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerExceptionResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerExceptionResolver.java new file mode 100644 index 00000000..632637ba --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerExceptionResolver.java @@ -0,0 +1,450 @@ +/* + * Copyright 2002-2023 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.method.annotation.support; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import graphql.GraphQLError; +import graphql.schema.DataFetchingEnvironment; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.context.ApplicationContext; +import org.springframework.core.ExceptionDepthComparator; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.graphql.data.method.HandlerMethod; +import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite; +import org.springframework.graphql.data.method.annotation.GraphQlExceptionHandler; +import org.springframework.graphql.execution.DataFetcherExceptionResolver; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.ObjectUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.method.ControllerAdviceBean; + +/** + * Resolves exceptions via {@link GraphQlExceptionHandler @GraphQlExceptionHandler} + * handler methods, which can be either local to a controller, or applicable + * across controllers and {@link graphql.schema.DataFetcher}s when declared in + * an {@link org.springframework.web.bind.annotation.ControllerAdvice} bean. + * + *

This {@link #resolveException(Throwable, DataFetchingEnvironment, Object)} + * method is similar to the {@link DataFetcherExceptionResolver} contract except + * it takes an additional, optional argument with the controller that raised the + * exception, for finding exception handler methods relative to the controller. + * + *

{@code AnnotatedControllerExceptionResolver} is package private and + * automatically applied from {@link AnnotatedControllerConfigurer} to controller + * method invocations. In addition, you can access it as a + * {@link DataFetcherExceptionResolver} via + * {@link AnnotatedControllerConfigurer#getExceptionResolver()} to extend + * exception handling with {@code @ControllerAdvice} exception handlers to + * non-controller {@link graphql.schema.DataFetcher}s. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +final class AnnotatedControllerExceptionResolver { + + private static final Log logger = LogFactory.getLog(AnnotatedControllerExceptionResolver.class); + + + private final HandlerMethodArgumentResolverComposite argumentResolvers; + + private final Map, MethodResolver> controllerCache = new ConcurrentHashMap<>(64); + + private final Map controllerAdviceCache = new ConcurrentHashMap<>(64); + + + AnnotatedControllerExceptionResolver(HandlerMethodArgumentResolverComposite resolvers) { + Assert.notNull(resolvers, "'resolvers' are required"); + this.argumentResolvers = resolvers; + } + + + /** + * Detect {@link GraphQlExceptionHandler} methods in the given controller + * class, and save this information for use at runtime. Method return types + * are validated to ensure they are within a range of supported types. + * @param controllerType the controller type to register + */ + public void registerController(Class controllerType) { + this.controllerCache.computeIfAbsent( + controllerType, type -> new MethodResolver(findExceptionHandlers(controllerType))); + } + + /** + * Find {@link org.springframework.web.bind.annotation.ControllerAdvice} + * beans in the given {@code ApplicationContext}, and detect + * {@link GraphQlExceptionHandler} methods in them, saving this information + * for use at runtime. + * @param context the context to look into + */ + public void registerControllerAdvice(ApplicationContext context) { + for (ControllerAdviceBean bean : ControllerAdviceBean.findAnnotatedBeans(context)) { + Class beanType = bean.getBeanType(); + if (beanType != null) { + Map, Method> methods = findExceptionHandlers(beanType); + if (!methods.isEmpty()) { + this.controllerAdviceCache.put(bean, new MethodResolver(methods)); + } + } + } + if (logger.isDebugEnabled()) { + logger.debug("@GraphQlException methods in ControllerAdvice beans: " + + (this.controllerAdviceCache.size() == 0 ? "none" : this.controllerAdviceCache.size())); + } + } + + @SuppressWarnings("unchecked") + private static Map, Method> findExceptionHandlers(Class handlerType) { + + Map handlerMap = MethodIntrospector.selectMethods( + handlerType, (MethodIntrospector.MetadataLookup) method -> + AnnotatedElementUtils.findMergedAnnotation(method, GraphQlExceptionHandler.class)); + + Map, Method> mappings = new HashMap<>(handlerMap.size()); + handlerMap.forEach((method, annotation) -> { + List> exceptionTypes = new ArrayList<>(); + if (!ObjectUtils.isEmpty(annotation.value())) { + exceptionTypes.addAll(Arrays.asList(annotation.value())); + } + else { + for (Class parameterType : method.getParameterTypes()) { + if (Throwable.class.isAssignableFrom(parameterType)) { + exceptionTypes.add((Class) parameterType); + } + } + } + Assert.state(!exceptionTypes.isEmpty(), () -> "No exception types for " + method); + for (Class type : exceptionTypes) { + Method oldMethod = mappings.put(type, method); + Assert.state(oldMethod == null || oldMethod.equals(method), () -> + "Ambiguous @GraphQlExceptionHandler for [" + type + "]: {" + oldMethod + ", " + method + "}"); + } + }); + return mappings; + } + + + /** + * Resolve the exception with an {@code @GraphQlExceptionHandler} method. + * If a controller is provided, look for a matching exception handler in the + * controller first, and then in any applicable {@code @ControllerAdvice}. + * If a controller is not provided, look in all {@code @ControllerAdvice}. + * @param ex the exception to resolve + * @param environment the environment for the invoked {@code DataFetcher} + * @param controller the controller that raised the exception, if applicable + * @return a {@code Mono} with errors as specified in + * {@link DataFetcherExceptionResolver#resolveException(Throwable, DataFetchingEnvironment)} + */ + public Mono> resolveException( + Throwable ex, DataFetchingEnvironment environment, @Nullable Object controller) { + + Object controllerOrAdvice = null; + MethodHolder methodHolder = null; + + if (controller != null) { + MethodResolver methodResolver = this.controllerCache.get(controller.getClass()); + if (methodResolver != null) { + controllerOrAdvice = controller; + methodHolder = methodResolver.resolveMethod(ex); + } + else if (logger.isWarnEnabled()) { + logger.warn("No registration for controller type: " + controller.getClass().getName()); + } + } + + if (methodHolder == null) { + for (Map.Entry entry : this.controllerAdviceCache.entrySet()) { + ControllerAdviceBean advice = entry.getKey(); + if (controller == null || advice.isApplicableToBeanType(controller.getClass())) { + methodHolder = entry.getValue().resolveMethod(ex); + if (methodHolder != null) { + controllerOrAdvice = advice.resolveBean(); + break; + } + } + } + } + + if (methodHolder == null) { + return Mono.error(ex); + } + + return invokeExceptionHandler(ex, environment, controllerOrAdvice, methodHolder); + } + + private Mono> invokeExceptionHandler( + Throwable exception, DataFetchingEnvironment env, Object controllerOrAdvice, MethodHolder methodHolder) { + + DataFetcherHandlerMethod exceptionHandler = new DataFetcherHandlerMethod( + new HandlerMethod(controllerOrAdvice, methodHolder.getMethod()), this.argumentResolvers, + null, null, false); + + List exceptions = new ArrayList<>(); + try { + if (logger.isDebugEnabled()) { + logger.debug("Handling exception with " + exceptionHandler); + } + + // Expose causes as provided arguments as well + Throwable exToExpose = exception; + while (exToExpose != null) { + exceptions.add(exToExpose); + Throwable cause = exToExpose.getCause(); + exToExpose = (cause != exToExpose ? cause : null); + } + Object[] arguments = new Object[exceptions.size() + 1]; + exceptions.toArray(arguments); // efficient arraycopy call in ArrayList + arguments[arguments.length - 1] = exceptionHandler; + + Object result = exceptionHandler.invoke(env, arguments); + + return methodHolder.adapt(result, exception); + } + catch (Throwable invocationEx) { + // Any other than the original exception (or a cause) is unintended here, + // probably an accident (e.g. failed assertion or the like). + if (!exceptions.contains(invocationEx) && logger.isWarnEnabled()) { + logger.warn("Failure while handling exception with " + exceptionHandler, invocationEx); + } + // Continue with processing of the original exception... + return Mono.error(exception); + } + } + + + /** + * Helps to resolve Exception instances to handler methods. + */ + private static final class MethodResolver { + + @SuppressWarnings("DataFlowIssue") + private static final MethodHolder NO_MATCH = + new MethodHolder(ReflectionUtils.findMethod(MethodResolver.class, "noMatch")); + + + private final Map, MethodHolder> exceptionMappings = new HashMap<>(16); + + private final Map, MethodHolder> resolvedExceptionCache = new ConcurrentReferenceHashMap<>(16); + + MethodResolver(Map, Method> methodMap) { + methodMap.forEach((exceptionType, method) -> + this.exceptionMappings.put(exceptionType, new MethodHolder(method))); + } + + /** + * Find an exception handler method mapped to the given exception, using + * {@link ExceptionDepthComparator} if more than one match is found. + * @param exception the exception + * @return the exception handler to use, or {@code null} if no match + */ + @Nullable + public MethodHolder resolveMethod(Throwable exception) { + MethodHolder method = resolveMethodByExceptionType(exception.getClass()); + if (method == null) { + Throwable cause = exception.getCause(); + if (cause != null) { + method = resolveMethod(cause); + } + } + return method; + } + + @Nullable + private MethodHolder resolveMethodByExceptionType(Class exceptionType) { + MethodHolder method = this.resolvedExceptionCache.get(exceptionType); + if (method == null) { + method = getMappedMethod(exceptionType); + this.resolvedExceptionCache.put(exceptionType, method); + } + return (method != NO_MATCH ? method : null); + } + + private MethodHolder getMappedMethod(Class exceptionType) { + List> matches = new ArrayList<>(); + for (Class mappedException : this.exceptionMappings.keySet()) { + if (mappedException.isAssignableFrom(exceptionType)) { + matches.add(mappedException); + } + } + if (!matches.isEmpty()) { + if (matches.size() > 1) { + matches.sort(new ExceptionDepthComparator(exceptionType)); + } + return this.exceptionMappings.get(matches.get(0)); + } + else { + return NO_MATCH; + } + } + + @SuppressWarnings("unused") + private void noMatch() { + } + + } + + + /** + * Container for an exception handler method, and an adapter for its return values. + */ + private static class MethodHolder { + + private final Method method; + + private final MethodParameter returnType; + + private final ReturnValueAdapter adapter; + + MethodHolder(Method method) { + Assert.notNull(method, "Method is required"); + this.method = method; + this.returnType = new MethodParameter(method, -1); + this.adapter = ReturnValueAdapter.createFor(this.returnType); + } + + public Method getMethod() { + return this.method; + } + + public Mono> adapt(@Nullable Object result, Throwable ex) { + return this.adapter.adapt(result, this.returnType, ex); + } + + } + + + /** + * Contract to adapt the value returned from a {@code @GraphQlExceptionHandler}. + */ + @SuppressWarnings("unchecked") + private interface ReturnValueAdapter { + + /** + * Adapt the given return value to {@code Mono>}. + * @param result the return value + * @param returnType the return type of the method, mainly used for error logging + * @param ex the exception being handled + * @return the adapted result according to the contact for + * {@link DataFetcherExceptionResolver#resolveException(Throwable, DataFetchingEnvironment)} + */ + Mono> adapt(@Nullable Object result, MethodParameter returnType, Throwable ex); + + /** + * Verify the method return type is supported and can be adapted to + * {@code Mono>}, and create a suitable adapter. + * @param returnType the return type of the method + * @return the chosen adapter + * @throws IllegalStateException if the return value type that cannot be + * adapted to {@code Mono>} and is not supported + */ + static ReturnValueAdapter createFor(MethodParameter returnType) { + Class parameterType = returnType.getParameterType(); + if (parameterType == void.class || parameterType == Void.class) { + return forVoid; + } + else if (parameterType.equals(GraphQLError.class)) { + return forSingleError; + } + else if (Collection.class.isAssignableFrom(parameterType)) { + if (returnType.nested().getNestedParameterType().equals(GraphQLError.class)) { + return forCollection; + } + } + else if (Mono.class.isAssignableFrom(parameterType)) { + returnType = returnType.nested(); + Class nestedType = returnType.getNestedParameterType(); + if (nestedType == void.class || nestedType == Void.class) { + return forMonoVoid; + } + if (Collection.class.isAssignableFrom(nestedType)) { + returnType = returnType.nested(); + nestedType = returnType.getNestedParameterType(); + } + if (nestedType.equals(GraphQLError.class) || nestedType.equals(Object.class)) { + return forMono; + } + } + else if (parameterType.equals(Object.class)) { + return forObject; + } + throw new IllegalStateException( + "Invalid return type for @GraphQlExceptionHandler method: " + returnType); + } + + /** Adapter for void */ + ReturnValueAdapter forVoid = (result, returnType, ex) -> Mono.just(Collections.emptyList()); + + /** Adapter for a single GraphQLError */ + ReturnValueAdapter forSingleError = (result, returnType, ex) -> + (result == null ? + Mono.error(ex) : + Mono.just(Collections.singletonList((GraphQLError) result))); + + /** Adapter for a collection of GraphQLError's */ + ReturnValueAdapter forCollection = (result, returnType, ex) -> + (result == null ? + Mono.error(ex) : + Mono.just((result instanceof List ? + (List) result : + new ArrayList<>((Collection) result)))); + + /** Adapter for Object */ + ReturnValueAdapter forObject = (result, returnType, ex) -> { + if (result == null) { + return Mono.error(ex); + } + else if (result instanceof GraphQLError) { + return forSingleError.adapt(result, returnType, ex); + } + else if (result instanceof Collection) { + return forCollection.adapt(result, returnType, ex); + } + else { + if (logger.isWarnEnabled()) { + logger.warn("Unexpected return value of type " + + result.getClass().getName() + " from method " + returnType); + } + return Mono.error(ex); + } + }; + + /** Adapter for {@code Mono} */ + ReturnValueAdapter forMonoVoid = (result, returnType, ex) -> + (result == null ? Mono.error(ex) : Mono.just(Collections.emptyList())); + + /** Adapter for a {@code Mono} wrapping any of the other synchronous return value types */ + ReturnValueAdapter forMono = (result, returnType, ex) -> + (result == null ? + Mono.error(ex) : + ((Mono) result).flatMap(o -> forObject.adapt(o, returnType, ex)).switchIfEmpty(Mono.error(ex))); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java index 6795e842..b9de76cd 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java @@ -84,7 +84,6 @@ public class DataFetcherHandlerMethod extends InvocableHandlerMethodSupport { } - /** * Invoke the method after resolving its argument values in the context of * the given {@link DataFetchingEnvironment}. @@ -95,8 +94,7 @@ public class DataFetcherHandlerMethod extends InvocableHandlerMethodSupport { * be used directly, i.e. without argument resolution. Provided argument * values are checked before argument resolvers. * - * @param environment the GraphQL {@link DataFetchingEnvironment} to use to - * resolve arguments. + * @param environment the environment to resolve arguments from * * @return the raw value returned by the invoked method, possibly a * {@code Mono} in case a method argument requires asynchronous resolution; @@ -104,9 +102,19 @@ public class DataFetcherHandlerMethod extends InvocableHandlerMethodSupport { */ @Nullable public Object invoke(DataFetchingEnvironment environment) { + return invoke(environment, new Object[0]); + } + + /** + * Variant of {@link #invoke(DataFetchingEnvironment)} that also accepts + * "given" arguments, which are matched by type. + * @since 1.2 + */ + @Nullable + public Object invoke(DataFetchingEnvironment environment, Object... providedArgs) { Object[] args; try { - args = getMethodArgumentValues(environment); + args = getMethodArgumentValues(environment, providedArgs); } catch (Throwable ex) { return Mono.error(ex); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/ContextDataFetcherDecorator.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/ContextDataFetcherDecorator.java index 65151064..0b5746d3 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/execution/ContextDataFetcherDecorator.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/ContextDataFetcherDecorator.java @@ -77,9 +77,14 @@ final class ContextDataFetcherDecorator implements DataFetcher { if (this.subscription) { Assert.state(value instanceof Publisher, "Expected Publisher for a subscription"); - Flux flux = Flux.from((Publisher) value).onErrorResume(exception -> - this.subscriptionExceptionResolver.resolveException(exception) - .flatMap(errors -> Mono.error(new SubscriptionPublisherException(errors, exception)))); + Flux flux = Flux.from((Publisher) value).onErrorResume(exception -> { + // Already handled, e.g. controller methods? + if (exception instanceof SubscriptionPublisherException) { + return Mono.error(exception); + } + return this.subscriptionExceptionResolver.resolveException(exception) + .flatMap(errors -> Mono.error(new SubscriptionPublisherException(errors, exception))); + }); return flux.contextWrite(snapshot::updateContext); } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerExceptionResolverTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerExceptionResolverTests.java new file mode 100644 index 00000000..eeed59c9 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerExceptionResolverTests.java @@ -0,0 +1,232 @@ +/* + * Copyright 2002-2023 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.method.annotation.support; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import graphql.GraphQLError; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.DataFetchingEnvironmentImpl; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite; +import org.springframework.graphql.data.method.annotation.GraphQlExceptionHandler; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Controller; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.ControllerAdvice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Unit tests for {@link AnnotatedControllerExceptionResolver}. + * + * @author Rossen Stoyanchev + * @since 1.2 + */ +public class AnnotatedControllerExceptionResolverTests { + + private final DataFetchingEnvironment environment = + DataFetchingEnvironmentImpl.newDataFetchingEnvironment().build(); + + + @Test + void resolveToSingleError() { + Exception ex = new IllegalArgumentException("Bad input"); + testResolve(ex, new TestController(), Collections.singletonList("handleToSingleError: " + ex.getMessage())); + } + + @Test + void resolveToList() { + Exception ex = new IllegalAccessException("No access"); + testResolve(ex, new TestController(), Arrays.asList( + "handleToList[1]: " + ex.getMessage(), "handleToList[2]: " + ex.getMessage())); + } + + @Test + void resolveToMono() { + Exception ex = new InstantiationException("Failed to instantiate"); + testResolve(ex, new TestController(), Collections.singletonList("handleToMono: " + ex.getMessage())); + } + + @Test + void resolveToObject() { + Exception ex = new ClassCastException("Wrong type"); + testResolve(ex, new TestController(), Collections.singletonList("handleToObject: " + ex.getMessage())); + } + + @Test + void resolveToVoid() { + Exception ex = new ArithmeticException(); + testResolve(ex, new TestController(), Collections.emptyList()); + } + + @Test + void resolveTypeDeclaredOnAnnotation() { + Exception ex = new SecurityException(); + testResolve(ex, new TestController(), Collections.singletonList("handleWithTypeOnAnnotation")); + } + + @Test + void resolveFromRootCause() { + Exception ex = new Exception("A", new Exception("B", new IndexOutOfBoundsException(5))); + testResolve(ex, new TestController(), Collections.singletonList("handleRootCause: Index out of range: 5")); + } + + @Test + void leaveUnresolvedViaNullReturnValue() { + Exception ex = new ClassNotFoundException("Not found"); + + TestController controller = new TestController(); + AnnotatedControllerExceptionResolver resolver = exceptionResolver(); + resolver.registerController(controller.getClass()); + + StepVerifier.create(resolver.resolveException(ex, this.environment, controller)) + .expectErrorSatisfies(actualEx -> assertThat(actualEx).isSameAs(ex)) + .verify(); + } + + @Test + void resolveWithControllerAdvice() { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(TestControllerAdvice.class); + context.refresh(); + + Exception ex = new IllegalArgumentException("Bad input"); + List actual = exceptionResolver(context).resolveException(ex, this.environment, null).block(); + + assertThat(actual).hasSize(1); + assertThat(actual.get(0).getMessage()).isEqualTo("handle: Bad input"); + } + + @Test + void invalidReturnType() { + assertThatIllegalStateException().isThrownBy(() -> + exceptionResolver().registerController(InvalidReturnTypeController.class)); + } + + private void testResolve(Throwable ex, TestController controller, List expected) { + + AnnotatedControllerExceptionResolver resolver = exceptionResolver(); + resolver.registerController(controller.getClass()); + + List actual = resolver.resolveException(ex, this.environment, controller).block(); + + assertThat(actual).hasSize(expected.size()); + for (int i = 0; i < expected.size(); i++) { + assertThat(actual.get(i).getMessage()).isEqualTo(expected.get(i)); + } + } + + private AnnotatedControllerExceptionResolver exceptionResolver() { + return exceptionResolver(new StaticApplicationContext()); + } + + private AnnotatedControllerExceptionResolver exceptionResolver(ApplicationContext applicationContext) { + AnnotatedControllerConfigurer configurer = new AnnotatedControllerConfigurer(); + configurer.setApplicationContext(applicationContext); + configurer.afterPropertiesSet(); + + HandlerMethodArgumentResolverComposite argumentResolvers = configurer.getArgumentResolvers(); + AnnotatedControllerExceptionResolver resolver = new AnnotatedControllerExceptionResolver(argumentResolvers); + resolver.registerControllerAdvice(applicationContext); + return resolver; + } + + + @SuppressWarnings("unused") + @Controller + private static class TestController { + + @GraphQlExceptionHandler + GraphQLError handleToSingleError(IllegalArgumentException ex) { + return createError("handleToSingleError", ex); + } + + @GraphQlExceptionHandler + List handleToList(IllegalAccessException ex) { + return Arrays.asList(createError("handleToList[1]", ex), createError("handleToList[2]", ex)); + } + + @GraphQlExceptionHandler + Mono handleToMono(InstantiationException ex) { + return Mono.just(createError("handleToMono", ex)); + } + + @GraphQlExceptionHandler + Object handleToObject(ClassCastException ex) { + return createError("handleToObject", ex); + } + + @GraphQlExceptionHandler(ArithmeticException.class) + public void handleToVoid() { + } + + @Nullable + @GraphQlExceptionHandler + GraphQLError handleAndLeaveNotHandled(ClassNotFoundException ex) { + return null; + } + + @GraphQlExceptionHandler + public GraphQLError handleRootCause(IndexOutOfBoundsException ex) { + return createError("handleRootCause", ex); + } + + @GraphQlExceptionHandler(SecurityException.class) + public GraphQLError handleWithTypeOnAnnotation() { + return createError("handleWithTypeOnAnnotation", null); + } + + private static GraphQLError createError(String methodName, @Nullable Throwable ex) { + return GraphQLError.newError() + .message(methodName + (ex != null && StringUtils.hasText(ex.getMessage()) ? ": " + ex.getMessage() : "")) + .build(); + } + + } + + + @SuppressWarnings("unused") + @ControllerAdvice + private static class TestControllerAdvice { + + @GraphQlExceptionHandler + GraphQLError handle(IllegalArgumentException ex) { + return GraphQLError.newError().message("handle: " + ex.getMessage()).build(); + } + + } + + + private static class InvalidReturnTypeController { + + @GraphQlExceptionHandler + public String handle(IllegalArgumentException ex) { + return "Handled"; + } + + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingInvocationTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingInvocationTests.java index 290f9104..bc9f8c18 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingInvocationTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingInvocationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2023 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. @@ -21,6 +21,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; import graphql.GraphQLContext; +import graphql.GraphQLError; import graphql.schema.DataFetchingEnvironment; import org.dataloader.DataLoader; import org.junit.jupiter.api.Test; @@ -42,13 +43,17 @@ import org.springframework.graphql.GraphQlSetup; import org.springframework.graphql.ResponseHelper; 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.MutationMapping; import org.springframework.graphql.data.method.annotation.QueryMapping; import org.springframework.graphql.data.method.annotation.SchemaMapping; import org.springframework.graphql.data.method.annotation.SubscriptionMapping; import org.springframework.graphql.execution.BatchLoaderRegistry; import org.springframework.graphql.execution.DefaultBatchLoaderRegistry; +import org.springframework.graphql.execution.ErrorType; +import org.springframework.graphql.execution.SubscriptionPublisherException; import org.springframework.stereotype.Controller; +import org.springframework.util.Assert; import static org.assertj.core.api.Assertions.assertThat; @@ -206,6 +211,50 @@ public class SchemaMappingInvocationTests { .verifyComplete(); } + @Test + void handleExceptionFromQuery() { + String document = "{ " + + " booksByCriteria(criteria: {author:\"Fitzgerald\"}) { " + + " id" + + " name" + + " }" + + "}"; + + Mono responseMono = + graphQlService().execute(TestExecutionRequest.forDocument(document)); + + ResponseHelper responseHelper = ResponseHelper.forResponse(responseMono); + assertThat(responseHelper.errorCount()).isEqualTo(1); + assertThat(responseHelper.error(0).errorType()).isEqualTo("BAD_REQUEST"); + assertThat(responseHelper.error(0).message()).isEqualTo("Rejected: Bad input"); + } + + @Test + void handleExceptionFromSubscription() { + String document = "subscription { " + + " bookSearch(author:\"Fitzgerald\") { " + + " id" + + " name" + + " }" + + "}"; + + Mono responseMono = + graphQlService().execute(TestExecutionRequest.forDocument(document)); + + Flux bookFlux = ResponseHelper.forSubscription(responseMono) + .map(response -> response.toEntity("bookSearch", Book.class)); + + StepVerifier.create(bookFlux) + .expectErrorSatisfies(ex -> { + SubscriptionPublisherException theEx = (SubscriptionPublisherException) ex; + List errors = theEx.getErrors(); + assertThat(errors).hasSize(1); + assertThat(errors.get(0).getErrorType().toString()).isEqualTo("BAD_REQUEST"); + assertThat(errors.get(0).getMessage()).isEqualTo("Rejected: Bad input"); + }) + .verify(); + } + private ExecutionGraphQlService graphQlService() { BatchLoaderRegistry registry = new DefaultBatchLoaderRegistry(); @@ -243,6 +292,7 @@ public class SchemaMappingInvocationTests { @QueryMapping public List booksByCriteria(@Argument BookCriteria criteria) { + Assert.isTrue(!criteria.getAuthor().equalsIgnoreCase("Fitzgerald"), "Bad input"); return BookSource.findBooksByAuthor(criteria.getAuthor()); } @@ -277,7 +327,16 @@ public class SchemaMappingInvocationTests { @SubscriptionMapping public Flux bookSearch(@Argument String author) { - return Flux.fromIterable(BookSource.findBooksByAuthor(author)); + return (author.equalsIgnoreCase("Fitzgerald") ? + Flux.error(new IllegalArgumentException("Bad input")) : + Flux.fromIterable(BookSource.findBooksByAuthor(author))); + } + + @GraphQlExceptionHandler + public GraphQLError handleInputError(IllegalArgumentException ex) { + return GraphQLError.newError().errorType(ErrorType.BAD_REQUEST) + .message("Rejected: " + ex.getMessage()) + .build(); } }