From 3792de375a2f13fdbcdf9539cff9d1cddeb47757 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Fri, 14 May 2021 10:15:19 +0100 Subject: [PATCH] Add DataFetcherExceptionResolver mechanism - Support for one or more DataFetcherExceptionResolver beans - ErrorType enum with common error categories - Default handling of unresolved exceptions as ErrorType.INTERNAL_ERROR Closes gh-51 --- .../boot/GraphQLAutoConfiguration.java | 4 + .../graphql/DataFetcherExceptionResolver.java | 47 ++++++++ .../springframework/graphql/ErrorType.java | 59 ++++++++++ .../support/DefaultGraphQLSourceBuilder.java | 10 ++ .../ExceptionResolversExceptionHandler.java | 74 ++++++++++++ .../graphql/support/GraphQLSource.java | 10 +- ...ceptionResolversExceptionHandlerTests.java | 106 ++++++++++++++++++ 7 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/DataFetcherExceptionResolver.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/ErrorType.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/support/ExceptionResolversExceptionHandler.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/support/ExceptionResolversExceptionHandlerTests.java diff --git a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQLAutoConfiguration.java b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQLAutoConfiguration.java index f918def9..19130110 100644 --- a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQLAutoConfiguration.java +++ b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQLAutoConfiguration.java @@ -28,6 +28,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ResourceLoader; +import org.springframework.graphql.DataFetcherExceptionResolver; import org.springframework.graphql.support.GraphQLSource; @Configuration @@ -56,11 +57,14 @@ public class GraphQLAutoConfiguration { @Bean public GraphQLSource.Builder graphQLSourceBuilder( GraphQLProperties properties, RuntimeWiring runtimeWiring, + ObjectProvider exceptionResolversProvider, ResourceLoader resourceLoader, ObjectProvider instrumentationsProvider) { + String schemaLocation = properties.getSchema().getLocation(); return GraphQLSource.builder() .schemaResource(resourceLoader.getResource(schemaLocation)) .runtimeWiring(runtimeWiring) + .exceptionResolvers(exceptionResolversProvider.orderedStream().collect(Collectors.toList())) .instrumentation(instrumentationsProvider.orderedStream().collect(Collectors.toList())); } } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/DataFetcherExceptionResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/DataFetcherExceptionResolver.java new file mode 100644 index 00000000..3aa41f1d --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/DataFetcherExceptionResolver.java @@ -0,0 +1,47 @@ +/* + * 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; + +import java.util.List; + +import graphql.GraphQLError; +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.lang.Nullable; + +/** + * 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. + */ +public interface DataFetcherExceptionResolver { + + /** + * Resolve the given exception and return errors 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. + */ + @Nullable + List resolveException(Throwable exception, DataFetchingEnvironment environment); + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/ErrorType.java b/spring-graphql/src/main/java/org/springframework/graphql/ErrorType.java new file mode 100644 index 00000000..ea59b638 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/ErrorType.java @@ -0,0 +1,59 @@ +/* + * 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; + +import graphql.ErrorClassification; + +/** + * Common categories to use to classify for exceptions raised by + * {@link graphql.schema.DataFetcher}'s that can enable a client to make + * automated decisions. + * + * @see graphql.GraphqlErrorBuilder#errorType(ErrorClassification) + */ +public enum ErrorType implements ErrorClassification { + + /** + * {@link graphql.schema.DataFetcher} cannot or will not fetch the data value + * due to something that is perceived to be a client error. + */ + BAD_REQUEST, + + /** + * {@link graphql.schema.DataFetcher} did not fetch the data value due to a + * lack of valid authentication credentials. + */ + UNAUTHORIZED, + + /** + * {@link graphql.schema.DataFetcher} refuses to authorize the fetching of + * the data value. + */ + FORBIDDEN, + + /** + * {@link graphql.schema.DataFetcher} did not find a data value or is not + * willing to disclose that one exists. + */ + NOT_FOUND, + + /** + * {@link graphql.schema.DataFetcher} encountered an unexpected condition + * that prevented it from fetching the data value. + */ + INTERNAL_ERROR; + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/support/DefaultGraphQLSourceBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/support/DefaultGraphQLSourceBuilder.java index 711f5cb0..88c3884e 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/support/DefaultGraphQLSourceBuilder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/support/DefaultGraphQLSourceBuilder.java @@ -33,6 +33,7 @@ import graphql.schema.idl.SchemaParser; import graphql.schema.idl.TypeDefinitionRegistry; import org.springframework.core.io.Resource; +import org.springframework.graphql.DataFetcherExceptionResolver; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -48,6 +49,8 @@ class DefaultGraphQLSourceBuilder implements GraphQLSource.Builder { private RuntimeWiring runtimeWiring = RuntimeWiring.newRuntimeWiring().build(); + private final List exceptionResolvers = new ArrayList<>(); + private final List typeVisitors = new ArrayList<>(); private final List instrumentations = new ArrayList<>(); @@ -73,6 +76,12 @@ class DefaultGraphQLSourceBuilder implements GraphQLSource.Builder { return this; } + @Override + public GraphQLSource.Builder exceptionResolvers(List resolvers) { + this.exceptionResolvers.addAll(resolvers); + return this; + } + @Override public GraphQLSource.Builder typeVisitors(List typeVisitors) { this.typeVisitors.addAll(typeVisitors); @@ -101,6 +110,7 @@ class DefaultGraphQLSourceBuilder implements GraphQLSource.Builder { } GraphQL.Builder builder = GraphQL.newGraphQL(schema); + builder.defaultDataFetcherExceptionHandler(new ExceptionResolversExceptionHandler(this.exceptionResolvers)); if (!this.instrumentations.isEmpty()) { builder = builder.instrumentation(new ChainedInstrumentation(this.instrumentations)); } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/support/ExceptionResolversExceptionHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/support/ExceptionResolversExceptionHandler.java new file mode 100644 index 00000000..fc16303f --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/support/ExceptionResolversExceptionHandler.java @@ -0,0 +1,74 @@ +/* + * 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.support; + +import java.util.ArrayList; +import java.util.List; + +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; +import graphql.execution.DataFetcherExceptionHandler; +import graphql.execution.DataFetcherExceptionHandlerParameters; +import graphql.execution.DataFetcherExceptionHandlerResult; +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.graphql.DataFetcherExceptionResolver; +import org.springframework.graphql.ErrorType; +import org.springframework.util.Assert; + +/** + * {@link DataFetcherExceptionHandler} that invokes {@link DataFetcherExceptionResolver}'s + * in a sequence until one returns a non-null list of {@link GraphQLError}'s. + */ +class ExceptionResolversExceptionHandler implements DataFetcherExceptionHandler { + + private final List resolvers; + + + /** + * Create an instance + * @param resolvers the resolvers to use + */ + public ExceptionResolversExceptionHandler(List resolvers) { + Assert.notNull(resolvers, "'resolvers' is required"); + this.resolvers = new ArrayList<>(resolvers); + } + + + @Override + public DataFetcherExceptionHandlerResult onException(DataFetcherExceptionHandlerParameters parameters) { + return invokeChain(parameters.getException(), parameters.getDataFetchingEnvironment()); + } + + 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(); + } + + public GraphQLError applyDefaultHandling(Throwable ex, DataFetchingEnvironment env) { + return GraphqlErrorBuilder.newError(env) + .message(ex.getMessage()) + .errorType(ErrorType.INTERNAL_ERROR) + .build(); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/support/GraphQLSource.java b/spring-graphql/src/main/java/org/springframework/graphql/support/GraphQLSource.java index 568da443..5b4ebeb7 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/support/GraphQLSource.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/support/GraphQLSource.java @@ -27,6 +27,7 @@ import graphql.schema.idl.RuntimeWiring; import graphql.schema.idl.TypeDefinitionRegistry; import org.springframework.core.io.Resource; +import org.springframework.graphql.DataFetcherExceptionResolver; import org.springframework.lang.Nullable; /** @@ -78,7 +79,14 @@ public interface GraphQLSource { Builder runtimeWiring(RuntimeWiring runtimeWiring); /** - * Add {@link GraphQLTypeVisitor}s to transform the underlying + * Add {@link DataFetcherExceptionResolver}'s to use for resolving + * exceptions from {@link graphql.schema.DataFetcher}'s. + * @param resolvers the resolvers to add + */ + Builder exceptionResolvers(List resolvers); + + /** + * Add {@link GraphQLTypeVisitor}'s to transform the underlying * {@link graphql.schema.GraphQLSchema} with. * @see graphql.schema.SchemaTransformer#transformSchema(GraphQLSchema, GraphQLTypeVisitor) */ diff --git a/spring-graphql/src/test/java/org/springframework/graphql/support/ExceptionResolversExceptionHandlerTests.java b/spring-graphql/src/test/java/org/springframework/graphql/support/ExceptionResolversExceptionHandlerTests.java new file mode 100644 index 00000000..72131c2c --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/support/ExceptionResolversExceptionHandlerTests.java @@ -0,0 +1,106 @@ +/* + * 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.support; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import graphql.GraphQL; +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; +import graphql.schema.DataFetcher; +import graphql.schema.idl.RuntimeWiring; +import org.junit.jupiter.api.Test; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.graphql.DataFetcherExceptionResolver; +import org.springframework.graphql.ErrorType; +import org.springframework.lang.Nullable; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ExceptionResolversExceptionHandler}. + */ +public class ExceptionResolversExceptionHandlerTests { + + @Test + void resolveException() throws Exception { + GraphQL graphQL = graphQL("type Query { greeting: String }", + "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())); + + ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }").build(); + ExecutionResult result = graphQL.executeAsync(input).get(); + + Map data = result.getData(); + assertThat(data).hasSize(1).containsEntry("greeting", null); + + List errors = result.getErrors(); + assertThat(errors).hasSize(1); + GraphQLError error = errors.get(0); + assertThat(error.getMessage()).isEqualTo("Resolved error: Invalid greeting"); + assertThat(error.getErrorType().toString()).isEqualTo("BAD_REQUEST"); + } + + @Test + void unresolvedException() throws Exception { + GraphQL graphQL = graphQL("type Query { greeting: String }", + "Query", "greeting", env -> { + throw new IllegalArgumentException("Invalid greeting"); + }); + + ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }").build(); + ExecutionResult result = graphQL.executeAsync(input).get(); + + Map data = result.getData(); + assertThat(data).hasSize(1).containsEntry("greeting", null); + + List errors = result.getErrors(); + assertThat(errors).hasSize(1); + GraphQLError error = errors.get(0); + assertThat(error.getMessage()).isEqualTo("Invalid greeting"); + assertThat(error.getErrorType().toString()).isEqualTo("INTERNAL_ERROR"); + } + + private GraphQL graphQL(String schemaContent, + String typeName, String fieldName, DataFetcher dataFetcher, + @Nullable DataFetcherExceptionResolver... resolvers) { + + RuntimeWiring wiring = RuntimeWiring.newRuntimeWiring() + .type(typeName, builder -> builder.dataFetcher(fieldName, dataFetcher)) + .build(); + + return GraphQLSource.builder() + .schemaResource(new ByteArrayResource(schemaContent.getBytes(StandardCharsets.UTF_8))) + .runtimeWiring(wiring) + .exceptionResolvers(Arrays.asList(resolvers)) + .build() + .graphQL(); + } + +}