diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/DataFetcherExceptionResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/DataFetcherExceptionResolver.java index c053032c..19d7966c 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/execution/DataFetcherExceptionResolver.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/DataFetcherExceptionResolver.java @@ -19,29 +19,33 @@ import java.util.List; import graphql.GraphQLError; import graphql.schema.DataFetchingEnvironment; - -import org.springframework.lang.Nullable; +import reactor.core.publisher.Mono; /** * Contract to resolve exceptions raised by {@link graphql.schema.DataFetcher}'s - * into errors to be added to the GraphQL response. Implementations are typically - * declared as beans in Spring configuration and invoked in order until one - * returns a non-null list of {@link GraphQLError}'s. + * to {@code GraphQLError}'s to add to the GraphQL response. Implementations are + * typically declared as beans in Spring configuration and invoked in order until + * one emits a List. + * + *

Use the {@link SingleErrorExceptionResolver} convenience adapter when you + * need to resolve exceptions to a single {@code GraphQLError} only. */ public interface DataFetcherExceptionResolver { /** - * Resolve the given exception and return errors to add to the response. + * Resolve the given exception and return the error(s) to add to the response. *

Implementations can use * {@link graphql.GraphqlErrorBuilder#newError(DataFetchingEnvironment)} to * create an error with the coordinates of the target field, and use * {@link ErrorType} to specify a category for the error. * @param exception the exception to resolve * @param environment the environment for the invoked {@code DataFetcher} - * @return a (possibly empty) list of {@link GraphQLError}'s to add to the - * response, or {@code null} to indicate the exception is unresolved. + * @return a {@code Mono} with errors to add to the GraphQL response; + * if the {@code Mono} completes with an empty List, the exception is + * resolved without any errors added to the response; + * if the {@code Mono} completes empty, without emitting a List, the + * exception remains unresolved and gives other resolvers a chance. */ - @Nullable - List resolveException(Throwable exception, DataFetchingEnvironment environment); + Mono> resolveException(Throwable exception, DataFetchingEnvironment environment); } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/ExceptionResolversExceptionHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/ExceptionResolversExceptionHandler.java index c2ef22a3..b113eafa 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/execution/ExceptionResolversExceptionHandler.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/ExceptionResolversExceptionHandler.java @@ -16,6 +16,7 @@ package org.springframework.graphql.execution; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.concurrent.CompletionException; @@ -25,6 +26,9 @@ import graphql.execution.DataFetcherExceptionHandler; import graphql.execution.DataFetcherExceptionHandlerParameters; import graphql.execution.DataFetcherExceptionHandlerResult; import graphql.schema.DataFetchingEnvironment; +import reactor.core.publisher.Flux; +import reactor.core.scheduler.Schedulers; +import reactor.util.context.ContextView; import org.springframework.util.Assert; @@ -54,18 +58,22 @@ class ExceptionResolversExceptionHandler implements DataFetcherExceptionHandler return invokeChain(exception, parameters.getDataFetchingEnvironment()); } + @SuppressWarnings("ConstantConditions") public DataFetcherExceptionHandlerResult invokeChain(Throwable ex, DataFetchingEnvironment env) { - for (DataFetcherExceptionResolver resolver : this.resolvers) { - List errors = resolver.resolveException(ex, env); - if (errors != null) { - return DataFetcherExceptionHandlerResult.newResult().errors(errors).build(); - } - } - GraphQLError error = applyDefaultHandling(ex, env); - return DataFetcherExceptionHandlerResult.newResult(error).build(); + return Flux.fromIterable(this.resolvers) + .publishOn(Schedulers.boundedElastic()) // until GraphQL Java supports async exception handling + .flatMap(resolver -> resolver.resolveException(ex, env)) + .next() + .defaultIfEmpty(Collections.singletonList(applyDefaultHandling(ex, env))) + .map(errors -> DataFetcherExceptionHandlerResult.newResult().errors(errors).build()) + .contextWrite(context -> { + ContextView contextToAdd = ContextManager.getReactorContext(env); + return (contextToAdd != null ? context.putAll(contextToAdd) : context); + }) + .block(); } - public GraphQLError applyDefaultHandling(Throwable ex, DataFetchingEnvironment env) { + private GraphQLError applyDefaultHandling(Throwable ex, DataFetchingEnvironment env) { return GraphqlErrorBuilder.newError(env) .message(ex.getMessage()) .errorType(ErrorType.INTERNAL_ERROR) diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/SingleErrorExceptionResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/SingleErrorExceptionResolver.java new file mode 100644 index 00000000..0683ef31 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/SingleErrorExceptionResolver.java @@ -0,0 +1,42 @@ +/* + * Copyright 2002-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. + */ +package org.springframework.graphql.execution; + +import java.util.Collections; +import java.util.List; + +import graphql.GraphQLError; +import graphql.schema.DataFetchingEnvironment; +import reactor.core.publisher.Mono; + +/** + * Simple adapter for {@link DataFetcherExceptionResolver} implementations that + * resolve exceptions to a single error only. + */ +public abstract class SingleErrorExceptionResolver implements DataFetcherExceptionResolver { + + + @Override + public final Mono> resolveException(Throwable exception, DataFetchingEnvironment environment) { + return doResolve(exception, environment).map(Collections::singletonList); + } + + /** + * Implement this method to resolve the exception to an error. + */ + protected abstract Mono doResolve(Throwable exception, DataFetchingEnvironment environment); + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/execution/ExceptionResolversExceptionHandlerTests.java b/spring-graphql/src/test/java/org/springframework/graphql/execution/ExceptionResolversExceptionHandlerTests.java index 535d8e5f..ae46c117 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/execution/ExceptionResolversExceptionHandlerTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/execution/ExceptionResolversExceptionHandlerTests.java @@ -27,11 +27,13 @@ import graphql.GraphQL; import graphql.GraphQLError; import graphql.GraphqlErrorBuilder; import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; import graphql.schema.idl.RuntimeWiring; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; import org.springframework.core.io.ByteArrayResource; -import org.springframework.lang.Nullable; import static org.assertj.core.api.Assertions.assertThat; @@ -46,13 +48,19 @@ public class ExceptionResolversExceptionHandlerTests { "Query", "greeting", env -> { throw new IllegalArgumentException("Invalid greeting"); }, - (ex, env) -> Collections.singletonList( - GraphqlErrorBuilder.newError(env) - .message("Resolved error: " + ex.getMessage()) - .errorType(ErrorType.BAD_REQUEST) - .build())); + new SingleErrorExceptionResolver() { + @Override + protected Mono doResolve(Throwable ex, DataFetchingEnvironment env) { + return Mono.deferContextual(view -> + Mono.just(GraphqlErrorBuilder.newError(env) + .message("Resolved error: " + ex.getMessage() + ", name=" + view.get("name")) + .errorType(ErrorType.BAD_REQUEST) + .build())); + }}); ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }").build(); + ContextManager.setReactorContext(Context.of("name", "007"), input); + ExecutionResult result = graphQl.executeAsync(input).get(); Map data = result.getData(); @@ -61,7 +69,7 @@ public class ExceptionResolversExceptionHandlerTests { List errors = result.getErrors(); assertThat(errors).hasSize(1); GraphQLError error = errors.get(0); - assertThat(error.getMessage()).isEqualTo("Resolved error: Invalid greeting"); + assertThat(error.getMessage()).isEqualTo("Resolved error: Invalid greeting, name=007"); assertThat(error.getErrorType().toString()).isEqualTo("BAD_REQUEST"); } @@ -70,7 +78,8 @@ public class ExceptionResolversExceptionHandlerTests { GraphQL graphQl = graphQl("type Query { greeting: String }", "Query", "greeting", env -> { throw new IllegalArgumentException("Invalid greeting"); - }); + }, + (exception, environment) -> Mono.empty()); ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }").build(); ExecutionResult result = graphQl.executeAsync(input).get(); @@ -85,9 +94,25 @@ public class ExceptionResolversExceptionHandlerTests { assertThat(error.getErrorType().toString()).isEqualTo("INTERNAL_ERROR"); } + @Test + void suppressedException() throws Exception { + GraphQL graphQl = graphQl("type Query { greeting: String }", + "Query", "greeting", env -> { + throw new IllegalArgumentException("Invalid greeting"); + }, + (ex, env) -> Mono.just(Collections.emptyList())); + + ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }").build(); + ExecutionResult result = graphQl.executeAsync(input).get(); + + Map data = result.getData(); + assertThat(data).hasSize(1).containsEntry("greeting", null); + assertThat(result.getErrors()).hasSize(0); + } + private GraphQL graphQl(String schemaContent, String typeName, String fieldName, DataFetcher dataFetcher, - @Nullable DataFetcherExceptionResolver... resolvers) { + DataFetcherExceptionResolver... resolvers) { RuntimeWiring wiring = RuntimeWiring.newRuntimeWiring() .type(typeName, builder -> builder.dataFetcher(fieldName, dataFetcher))