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
This commit is contained in:
Rossen Stoyanchev
2021-05-14 10:15:19 +01:00
parent de8e2540b7
commit 3792de375a
7 changed files with 309 additions and 1 deletions

View File

@@ -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<DataFetcherExceptionResolver> exceptionResolversProvider,
ResourceLoader resourceLoader, ObjectProvider<Instrumentation> 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()));
}
}

View File

@@ -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.
* <p>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<GraphQLError> resolveException(Throwable exception, DataFetchingEnvironment environment);
}

View File

@@ -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;
}

View File

@@ -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<DataFetcherExceptionResolver> exceptionResolvers = new ArrayList<>();
private final List<GraphQLTypeVisitor> typeVisitors = new ArrayList<>();
private final List<Instrumentation> instrumentations = new ArrayList<>();
@@ -73,6 +76,12 @@ class DefaultGraphQLSourceBuilder implements GraphQLSource.Builder {
return this;
}
@Override
public GraphQLSource.Builder exceptionResolvers(List<DataFetcherExceptionResolver> resolvers) {
this.exceptionResolvers.addAll(resolvers);
return this;
}
@Override
public GraphQLSource.Builder typeVisitors(List<GraphQLTypeVisitor> 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));
}

View File

@@ -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<DataFetcherExceptionResolver> resolvers;
/**
* Create an instance
* @param resolvers the resolvers to use
*/
public ExceptionResolversExceptionHandler(List<DataFetcherExceptionResolver> 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<GraphQLError> 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();
}
}

View File

@@ -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<DataFetcherExceptionResolver> resolvers);
/**
* Add {@link GraphQLTypeVisitor}'s to transform the underlying
* {@link graphql.schema.GraphQLSchema} with.
* @see graphql.schema.SchemaTransformer#transformSchema(GraphQLSchema, GraphQLTypeVisitor)
*/

View File

@@ -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<String, Object> data = result.getData();
assertThat(data).hasSize(1).containsEntry("greeting", null);
List<GraphQLError> 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<String, Object> data = result.getData();
assertThat(data).hasSize(1).containsEntry("greeting", null);
List<GraphQLError> 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();
}
}