Add DataFetcherExceptionResolverAdapter

The adapter aims to:
- simplify the common case of synchronous resolution to a single error
- support ThreadLocal context propagation on an opt-in basis

This replaces the SyncDataFetcherExceptionResolver and removes the need
to propagate ThreadLocal context to every resolver.
This commit is contained in:
Rossen Stoyanchev
2021-07-05 19:35:04 +01:00
parent a6211bda93
commit bac7461678
8 changed files with 186 additions and 92 deletions

View File

@@ -1,14 +1,11 @@
package io.spring.sample.graphql;
import java.util.Collections;
import java.util.List;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.graphql.execution.SyncDataFetcherExceptionResolver;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationTrustResolver;
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
@@ -20,22 +17,22 @@ import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
@Component
public class SecurityDataFetcherExceptionResolver implements SyncDataFetcherExceptionResolver {
public class SecurityDataFetcherExceptionResolver extends DataFetcherExceptionResolverAdapter {
private AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl();
@Override
public List<GraphQLError> doResolveException(Throwable exception, DataFetchingEnvironment environment) {
if (exception instanceof AuthenticationException) {
return unauthorized(environment);
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
if (ex instanceof AuthenticationException) {
return unauthorized(env);
}
if (exception instanceof AccessDeniedException) {
if (ex instanceof AccessDeniedException) {
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
if (this.authenticationTrustResolver.isAnonymous(authentication)) {
return unauthorized(environment);
return unauthorized(env);
}
return forbidden(environment);
return forbidden(env);
}
return null;
}
@@ -45,20 +42,18 @@ public class SecurityDataFetcherExceptionResolver implements SyncDataFetcherExce
this.authenticationTrustResolver = authenticationTrustResolver;
}
private List<GraphQLError> unauthorized(DataFetchingEnvironment environment) {
return Collections.singletonList(
GraphqlErrorBuilder.newError(environment)
.errorType(ErrorType.UNAUTHORIZED)
.message("Unauthorized")
.build());
private GraphQLError unauthorized(DataFetchingEnvironment environment) {
return GraphqlErrorBuilder.newError(environment)
.errorType(ErrorType.UNAUTHORIZED)
.message("Unauthorized")
.build();
}
private List<GraphQLError> forbidden(DataFetchingEnvironment environment) {
return Collections.singletonList(
GraphqlErrorBuilder.newError(environment)
private GraphQLError forbidden(DataFetchingEnvironment environment) {
return GraphqlErrorBuilder.newError(environment)
.errorType(ErrorType.FORBIDDEN)
.message("Forbidden")
.build());
.build();
}
}

View File

@@ -271,6 +271,11 @@ by the <<execution-graphqlsource>> builder. It enables applications to register
more Spring `DataFetcherExceptionResolver` components that are invoked sequentially
until one resolves the `Exception` to a list of `graphql.GraphQLError` objects.
`DataFetcherExceptionResolver` is an asynchronous contract. For most implementations, it
would be sufficient to extend `DataFetcherExceptionResolverAdapter` and override
one of its `resolveToSingleError` or `resolveToMultipleErrors` methods that
resolve exceptions synchronously.
A `GraphQLError` can be assigned an `graphql.ErrorClassification`. Spring GraphQL
defines an `ErrorType` enum with common, error classification categories:

View File

@@ -23,27 +23,33 @@ import graphql.schema.DataFetchingEnvironment;
import reactor.core.publisher.Mono;
/**
* Contract for resolving exceptions from {@link graphql.schema.DataFetcher}'s
* to {@code GraphQLError}'s to be added to the GraphQL response, possibly also
* using Spring's {@link graphql.ErrorType} for the error category.
* Contract to resolve exceptions from {@link graphql.schema.DataFetcher}s.
* Implementations are typically declared as beans in Spring configuration and
* are invoked sequentially until one emits a List of {@link GraphQLError}s.
*
* <p>Implementations are typically declared as beans in Spring configuration
* and invoked in order until one emits a List.
* <p>Most resolver implementations can extend
* {@link DataFetcherExceptionResolverAdapter} and override one of its
* {@link DataFetcherExceptionResolverAdapter#resolveToSingleError resolveToSingleError} or
* {@link DataFetcherExceptionResolverAdapter#resolveToMultipleErrors resolveToMultipleErrors}
* methods that resolve the exception synchronously.
*
* <p>Resolver implementations can use {@link ErrorType} to classify errors
* using one of several common categories.
*
* @author Rossen Stoyanchev
* @since 1.0.0
* @see SyncDataFetcherExceptionResolver
* @see ErrorType
* @see DataFetcherExceptionResolverAdapter
* @see ExceptionResolversExceptionHandler
*/
public interface DataFetcherExceptionResolver {
/**
* Resolve the given exception and return the error(s) 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 {@code Mono} with errors to add to the GraphQL response;

View File

@@ -0,0 +1,114 @@
/*
* 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;
import reactor.util.context.ContextView;
import org.springframework.lang.Nullable;
/**
* Adapter for {@link DataFetcherExceptionResolver} that pre-implements the
* asynchronous contract and exposes the following synchronous methods:
* <ul>
* <li>{@link #resolveToSingleError}
* <li>{@link #resolveToMultipleErrors}
* </ul>
*
* <p>Implementations can also express interest in ThreadLocal context
* propagation, from the underlying transport thread, via
* {@link #setThreadLocalContextAware(boolean)}.
*
* @author Rossen Stoyanchev
*/
public class DataFetcherExceptionResolverAdapter implements DataFetcherExceptionResolver {
private boolean threadLocalContextAware;
/**
* Sub-classes can set this to indicate that ThreadLocal context from the
* transport handler (e.g. HTTP handler) should be restored when resolving
* exceptions.
* <p><strong>Note:</strong> This property is applicable only if transports
* use ThreadLocal's' (e.g. Spring MVC) and if a {@link ThreadLocalAccessor}
* is registered to extract ThreadLocal values of interest. There is no
* impact from setting this property otherwise.
* <p>By default this is set to "false" in which case there is no attempt
* to propagate ThreadLocal context.
* @param threadLocalContextAware whether this resolver needs access to
* ThreadLocal context or not.
*/
public void setThreadLocalContextAware(boolean threadLocalContextAware) {
this.threadLocalContextAware = threadLocalContextAware;
}
/**
* Whether ThreadLocal context needs to be restored for this resolver.
*/
public boolean isThreadLocalContextAware() {
return this.threadLocalContextAware;
}
@Override
public final Mono<List<GraphQLError>> resolveException(Throwable ex, DataFetchingEnvironment env) {
return Mono.defer(() -> Mono.justOrEmpty(resolveInternal(ex, env)));
}
@Nullable
private List<GraphQLError> resolveInternal(Throwable ex, DataFetchingEnvironment env) {
if (!this.threadLocalContextAware) {
return resolveToMultipleErrors(ex, env);
}
ContextView contextView = ReactorContextManager.getReactorContext(env);
try {
ReactorContextManager.restoreThreadLocalValues(contextView);
return resolveToMultipleErrors(ex, env);
}
finally {
ReactorContextManager.resetThreadLocalValues(contextView);
}
}
/**
* Override this method to resolve an Exception to multiple GraphQL errors.
* @param ex the exception to resolve
* @param env the environment for the invoked {@code DataFetcher}
* @return the resolved errors or {@code null} if unresolved
*/
@Nullable
protected List<GraphQLError> resolveToMultipleErrors(Throwable ex, DataFetchingEnvironment env) {
GraphQLError error = resolveToSingleError(ex, env);
return (error != null ? Collections.singletonList(error) : null);
}
/**
* Override this method to resolve an Exception to a single GraphQL error.
* @param ex the exception to resolve
* @param env the environment for the invoked {@code DataFetcher}
* @return the resolved error or {@code null} if unresolved
*/
@Nullable
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
return null;
}
}

View File

@@ -70,7 +70,7 @@ class ExceptionResolversExceptionHandler implements DataFetcherExceptionHandler
// https://github.com/graphql-java/graphql-java/issues/2356
try {
return Flux.fromIterable(this.resolvers)
.flatMap((resolver) -> resolveErrors(ex, env, resolver))
.flatMap((resolver) -> resolver.resolveException(ex, env))
.next()
.map((errors) -> DataFetcherExceptionHandlerResult.newResult().errors(errors).build())
.switchIfEmpty(Mono.fromCallable(() -> applyDefaultHandling(ex, env)))
@@ -89,19 +89,6 @@ class ExceptionResolversExceptionHandler implements DataFetcherExceptionHandler
}
}
private Mono<List<GraphQLError>> resolveErrors(
Throwable ex, DataFetchingEnvironment environment, DataFetcherExceptionResolver resolver) {
ContextView contextView = ReactorContextManager.getReactorContext(environment);
try {
ReactorContextManager.restoreThreadLocalValues(contextView);
return resolver.resolveException(ex, environment);
}
finally {
ReactorContextManager.resetThreadLocalValues(contextView);
}
}
private DataFetcherExceptionHandlerResult applyDefaultHandling(Throwable ex, DataFetchingEnvironment env) {
GraphQLError error = GraphqlErrorBuilder.newError(env)
.message(ex.getMessage())

View File

@@ -1,46 +0,0 @@
/*
* 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.List;
import graphql.GraphQLError;
import graphql.schema.DataFetchingEnvironment;
import reactor.core.publisher.Mono;
/**
* {@link DataFetcherExceptionResolver} that resolves exceptions synchronously.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public interface SyncDataFetcherExceptionResolver extends DataFetcherExceptionResolver {
@Override
default Mono<List<GraphQLError>> resolveException(Throwable exception, DataFetchingEnvironment env) {
return Mono.just(doResolveException(exception, env));
}
/**
* Implement this method to resolve exceptions.
* @param exception the exception to resolve
* @param env the environment for the invoked {@code DataFetcher}
* @return the list of resolved GraphQL errors
*/
List<GraphQLError> doResolveException(Throwable exception, DataFetchingEnvironment env);
}

View File

@@ -20,12 +20,14 @@ import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import graphql.ExecutionInput;
import graphql.ExecutionResult;
import graphql.GraphQL;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import reactor.util.context.Context;
@@ -96,7 +98,7 @@ public class ExceptionResolversExceptionHandlerTests {
(env) -> {
throw new IllegalArgumentException("Invalid greeting");
},
(SyncDataFetcherExceptionResolver) (ex, env) -> Collections.singletonList(
threadLocalContextAwareExceptionResolver((ex, env) ->
GraphqlErrorBuilder.newError(env)
.message("Resolved error: " + ex.getMessage() + ", name=" + nameThreadLocal.get())
.errorType(ErrorType.BAD_REQUEST)
@@ -155,4 +157,18 @@ public class ExceptionResolversExceptionHandlerTests {
assertThat(result.getErrors()).hasSize(0);
}
private static DataFetcherExceptionResolver threadLocalContextAwareExceptionResolver(
BiFunction<Throwable, DataFetchingEnvironment, GraphQLError> resolver) {
DataFetcherExceptionResolverAdapter adapter = new DataFetcherExceptionResolverAdapter() {
@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
return resolver.apply(ex, env);
}
};
adapter.setThreadLocalContextAware(true);
return adapter;
}
}

View File

@@ -21,10 +21,12 @@ import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.BiFunction;
import graphql.GraphQL;
import graphql.GraphQLError;
import graphql.GraphqlErrorBuilder;
import graphql.schema.DataFetchingEnvironment;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
@@ -32,9 +34,10 @@ import org.springframework.graphql.GraphQlService;
import org.springframework.graphql.GraphQlTestUtils;
import org.springframework.graphql.TestGraphQlSource;
import org.springframework.graphql.TestThreadLocalAccessor;
import org.springframework.graphql.execution.DataFetcherExceptionResolver;
import org.springframework.graphql.execution.DataFetcherExceptionResolverAdapter;
import org.springframework.graphql.execution.ErrorType;
import org.springframework.graphql.execution.ExecutionGraphQlService;
import org.springframework.graphql.execution.SyncDataFetcherExceptionResolver;
import org.springframework.http.HttpHeaders;
import static org.assertj.core.api.Assertions.assertThat;
@@ -127,7 +130,7 @@ public class WebGraphQlHandlerTests {
(env) -> {
throw new IllegalArgumentException("Invalid greeting");
},
(SyncDataFetcherExceptionResolver) (ex, env) -> Collections.singletonList(
threadLocalContextAwareExceptionResolver((ex, env) ->
GraphqlErrorBuilder.newError(env)
.message("Resolved error: " + ex.getMessage() + ", name=" + nameThreadLocal.get())
.errorType(ErrorType.BAD_REQUEST).build()));
@@ -149,4 +152,18 @@ public class WebGraphQlHandlerTests {
}
}
private static DataFetcherExceptionResolver threadLocalContextAwareExceptionResolver(
BiFunction<Throwable, DataFetchingEnvironment, GraphQLError> resolver) {
DataFetcherExceptionResolverAdapter adapter = new DataFetcherExceptionResolverAdapter() {
@Override
protected GraphQLError resolveToSingleError(Throwable ex, DataFetchingEnvironment env) {
return resolver.apply(ex, env);
}
};
adapter.setThreadLocalContextAware(true);
return adapter;
}
}