Apply Spring JavaFormat to Spring GraphQL

See gh-54
This commit is contained in:
Brian Clozel
2021-06-02 14:28:40 +02:00
parent f8b90b592d
commit d048c2e4fe
42 changed files with 927 additions and 888 deletions

View File

@@ -2,6 +2,7 @@
plugins {
id 'io.spring.dependency-management' version '1.0.10.RELEASE'
id 'java-library'
id "org.springframework.graphql.conventions"
}
description = "GraphQL Support for Spring Applications"

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql;
import graphql.ExecutionInput;
@@ -20,8 +21,11 @@ import graphql.ExecutionResult;
import reactor.core.publisher.Mono;
/**
* Strategy to perform GraphQL request execution with input for and output from
* the invocation of {@link graphql.GraphQL}.
* Strategy to perform GraphQL request execution with input for and output from the
* invocation of {@link graphql.GraphQL}.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public interface GraphQlService {

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql;
import java.util.ArrayList;
@@ -30,9 +31,11 @@ import org.springframework.util.CollectionUtils;
/**
* Common representation for GraphQL request input. This can be converted to
* {@link ExecutionInput} via {@link #toExecutionInput()} and the
* {@code ExecutionInput} further customized via
* {@link #configureExecutionInput(BiFunction)}.
* {@link ExecutionInput} via {@link #toExecutionInput()} and the {@code ExecutionInput}
* further customized via {@link #configureExecutionInput(BiFunction)}.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public class RequestInput {
@@ -45,12 +48,11 @@ public class RequestInput {
private final List<BiFunction<ExecutionInput, ExecutionInput.Builder, ExecutionInput>> executionInputConfigurers = new ArrayList<>();
public RequestInput(String query, @Nullable String operationName, @Nullable Map<String, Object> vars) {
Assert.notNull(query, "'query' is required");
this.query = query;
this.operationName = operationName;
this.variables = (vars != null ? vars : Collections.emptyMap());
this.variables = ((vars != null) ? vars : Collections.emptyMap());
}
public RequestInput(Map<String, Object> body) {
@@ -62,18 +64,19 @@ public class RequestInput {
return (T) body.get(key);
}
/**
* Return the query name extracted from the request body. This is guaranteed
* to be a non-empty string.
* Return the query name extracted from the request body. This is guaranteed to be a
* non-empty string.
* @return the query name
*/
public String getQuery() {
return this.query;
}
/**
* Return the operation name extracted from the request body or
* {@code null} if not provided.
* Return the operation name extracted from the request body or {@code null} if not
* provided.
* @return the operation name or {@code null}
*/
@Nullable
public String getOperationName() {
@@ -81,20 +84,21 @@ public class RequestInput {
}
/**
* Return the variables that can be referenced via $syntax extracted
* from the request body or a {@code null} if not provided.
* Return the variables that can be referenced via $syntax extracted from the request
* body or a {@code null} if not provided.
* @return the request variables or {@code null}
*/
public Map<String, Object> getVariables() {
return this.variables;
}
/**
* Provide a consumer to configure the {@link ExecutionInput} used for input
* to {@link graphql.GraphQL#executeAsync(ExecutionInput)}.
* The builder is initially populated with the values from
* {@link #getQuery()}, {@link #getOperationName()}, and {@link #getVariables()}.
* @param configurer a {@code BiFunction} with the current
* {@code ExecutionInput} and a builder to modify it.
* Provide a consumer to configure the {@link ExecutionInput} used for input to
* {@link graphql.GraphQL#executeAsync(ExecutionInput)}. The builder is initially
* populated with the values from {@link #getQuery()}, {@link #getOperationName()},
* and {@link #getVariables()}.
* @param configurer a {@code BiFunction} with the current {@code ExecutionInput} and
* a builder to modify it.
*/
public void configureExecutionInput(BiFunction<ExecutionInput, ExecutionInput.Builder, ExecutionInput> configurer) {
this.executionInputConfigurers.add(configurer);
@@ -105,17 +109,15 @@ public class RequestInput {
* populated from {@link #getQuery()}, {@link #getOperationName()}, and
* {@link #getVariables()}, and is then further customized through
* {@link #configureExecutionInput(BiFunction)}.
* @return the execution input
*/
public ExecutionInput toExecutionInput() {
ExecutionInput executionInput = ExecutionInput.newExecutionInput()
.query(this.query)
.operationName(this.operationName)
.variables(this.variables)
.build();
ExecutionInput executionInput = ExecutionInput.newExecutionInput().query(this.query)
.operationName(this.operationName).variables(this.variables).build();
for (BiFunction<ExecutionInput, ExecutionInput.Builder, ExecutionInput> configurer : this.executionInputConfigurers) {
ExecutionInput current = executionInput;
executionInput = executionInput.transform(builder -> configurer.apply(current, builder));
executionInput = executionInput.transform((builder) -> configurer.apply(current, builder));
}
return executionInput;
@@ -123,6 +125,7 @@ public class RequestInput {
/**
* Return a Map representation of the request input.
* @return map representation of the input
*/
public Map<String, Object> toMap() {
Map<String, Object> map = new LinkedHashMap<>(3);
@@ -136,12 +139,11 @@ public class RequestInput {
return map;
}
@Override
public String toString() {
return "Query='" + getQuery() + "'" +
(getOperationName() != null ? ", Operation='" + getOperationName() + "'" : "") +
(!CollectionUtils.isEmpty(getVariables()) ? ", Variables=" + getVariables() : "");
return "Query='" + getQuery() + "'"
+ ((getOperationName() != null) ? ", Operation='" + getOperationName() + "'" : "")
+ (!CollectionUtils.isEmpty(getVariables()) ? ", Variables=" + getVariables() : "");
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.execution;
import java.util.List;
@@ -21,29 +22,30 @@ import java.util.Map;
/**
* Default implementation of a composite accessor that is returned from
* {@link ThreadLocalAccessor#composite(List)}.
*
* @author Rossen Stoyanchev
*/
class CompositeThreadLocalAccessor implements ThreadLocalAccessor {
private final List<ThreadLocalAccessor> accessors;
public CompositeThreadLocalAccessor(List<ThreadLocalAccessor> accessors) {
CompositeThreadLocalAccessor(List<ThreadLocalAccessor> accessors) {
this.accessors = accessors;
}
@Override
public void extractValues(Map<String, Object> container) {
this.accessors.forEach(accessor -> accessor.extractValues(container));
this.accessors.forEach((accessor) -> accessor.extractValues(container));
}
@Override
public void restoreValues(Map<String, Object> values) {
this.accessors.forEach(accessor -> accessor.restoreValues(values));
this.accessors.forEach((accessor) -> accessor.restoreValues(values));
}
@Override
public void resetValues(Map<String, Object> values) {
this.accessors.forEach(accessor -> accessor.resetValues(values));
this.accessors.forEach((accessor) -> accessor.resetValues(values));
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.execution;
import graphql.ExecutionInput;
@@ -41,21 +42,21 @@ import org.springframework.util.Assert;
* <li>Re-establish Reactor Context passed via {@link ExecutionInput}.
* <li>Re-establish ThreadLocal context passed via {@link ExecutionInput}.
* </ul>
*
* @author Rossen Stoyanchev
*/
class ContextDataFetcherDecorator implements DataFetcher<Object> {
final class ContextDataFetcherDecorator implements DataFetcher<Object> {
private final DataFetcher<?> delegate;
private final boolean subscription;
private ContextDataFetcherDecorator(DataFetcher<?> delegate, boolean subscription) {
Assert.notNull(delegate, "'delegate' DataFetcher is required");
this.delegate = delegate;
this.subscription = subscription;
}
@Override
public Object get(DataFetchingEnvironment environment) throws Exception {
ContextView contextView = ContextManager.getReactorContext(environment);
@@ -88,16 +89,15 @@ class ContextDataFetcherDecorator implements DataFetcher<Object> {
return value;
}
/**
* {@link GraphQLTypeVisitor} that wraps non-GraphQL data fetchers and
* adapts them if they return {@link Flux} or {@link Mono}.
* {@link GraphQLTypeVisitor} that wraps non-GraphQL data fetchers and adapts them if
* they return {@link Flux} or {@link Mono}.
*/
static GraphQLTypeVisitor TYPE_VISITOR = new GraphQLTypeVisitorStub() {
@Override
public TraversalControl visitGraphQLFieldDefinition(
GraphQLFieldDefinition fieldDefinition, TraverserContext<GraphQLSchemaElement> context) {
public TraversalControl visitGraphQLFieldDefinition(GraphQLFieldDefinition fieldDefinition,
TraverserContext<GraphQLSchemaElement> context) {
GraphQLCodeRegistry.Builder codeRegistry = context.getVarFromParents(GraphQLCodeRegistry.Builder.class);
GraphQLFieldsContainer parent = (GraphQLFieldsContainer) context.getParentNode();

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.execution;
import java.util.LinkedHashMap;
@@ -27,32 +28,34 @@ import reactor.util.context.ContextView;
import org.springframework.lang.Nullable;
/**
* Package private utility class for propagating a Reactor {@link ContextView}
* through the {@link ExecutionInput} and the {@link DataFetchingEnvironment}
* of a request.
* Package private utility class for propagating a Reactor {@link ContextView} through the
* {@link ExecutionInput} and the {@link DataFetchingEnvironment} of a request.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public abstract class ContextManager {
private static final String CONTEXT_VIEW_KEY =
ContextManager.class.getName() + ".CONTEXT_VIEW";
private static final String CONTEXT_VIEW_KEY = ContextManager.class.getName() + ".CONTEXT_VIEW";
private static final String THREAD_LOCAL_VALUES_KEY =
ContextManager.class.getName() + ".THREAD_VALUES_ACCESSOR";
private static final String THREAD_LOCAL_ACCESSOR_KEY =
ContextManager.class.getName() + ".THREAD_LOCAL_ACCESSOR";
private static final String THREAD_LOCAL_VALUES_KEY = ContextManager.class.getName() + ".THREAD_VALUES_ACCESSOR";
private static final String THREAD_LOCAL_ACCESSOR_KEY = ContextManager.class.getName() + ".THREAD_LOCAL_ACCESSOR";
/**
* Save the given Reactor ContextView in the an {@link ExecutionInput} for
* Save the given Reactor {@link ContextView} in the an {@link ExecutionInput} for
* later access through the {@link DataFetchingEnvironment}.
* @param contextView the reactor context view
* @param input the GraphQL query input
*/
static void setReactorContext(ContextView contextView, ExecutionInput input) {
((GraphQLContext) input.getContext()).put(CONTEXT_VIEW_KEY, contextView);
}
/**
* Return the Reactor ContextView saved in the given DataFetchingEnvironment.
* Return the Reactor {@link ContextView} saved in the given DataFetchingEnvironment.
* @param environment the DataFetchingEnvironment
* @return the reactor {@link ContextView}
*/
static ContextView getReactorContext(DataFetchingEnvironment environment) {
GraphQLContext graphQlContext = environment.getContext();
@@ -60,9 +63,10 @@ public abstract class ContextManager {
}
/**
* Use the given accessor to extract ThreadLocal value, and return a Reactor
* context that contains both the extracted values and the accessor.
* Use the given accessor to extract ThreadLocal value, and return a Reactor context
* that contains both the extracted values and the accessor.
* @param accessor the accessor to use
* @return the reactor {@link ContextView}
*/
public static ContextView extractThreadLocalValues(ThreadLocalAccessor accessor) {
Map<String, Object> valuesMap = new LinkedHashMap<>();
@@ -72,6 +76,7 @@ public abstract class ContextManager {
/**
* Look up saved ThreadLocal values and use them to re-establish ThreadLocal context.
* @param contextView the reactor {@link ContextView}
*/
static void restoreThreadLocalValues(ContextView contextView) {
ThreadLocalAccessor accessor = getThreadLocalAccessor(contextView);
@@ -82,6 +87,7 @@ public abstract class ContextManager {
/**
* Look up saved ThreadLocal values and remove associated ThreadLocal context.
* @param contextView the reactor {@link ContextView}
*/
static void resetThreadLocalValues(ContextView contextView) {
ThreadLocalAccessor accessor = getThreadLocalAccessor(contextView);

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.execution;
import java.util.List;
@@ -22,29 +23,32 @@ import graphql.schema.DataFetchingEnvironment;
import reactor.core.publisher.Mono;
/**
* Contract to resolve exceptions raised by {@link graphql.schema.DataFetcher}'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.
* Contract to resolve exceptions raised by {@link graphql.schema.DataFetcher}'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.
*
* <p>Use the {@link SingleErrorExceptionResolver} convenience adapter when you
* need to resolve exceptions to a single {@code GraphQLError} only.
* <p>
* Use the {@link SingleErrorExceptionResolver} convenience adapter when you need to
* resolve exceptions to a single {@code GraphQLError} only.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
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.
* <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;
* 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.
* @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.
*/
Mono<List<GraphQLError>> resolveException(Throwable exception, DataFetchingEnvironment environment);

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.execution;
import java.io.IOException;
@@ -38,8 +39,9 @@ import org.springframework.util.Assert;
/**
* Default implementation of {@link GraphQlSource.Builder} that initializes a
* {@link GraphQL} instance and wraps it with a {@link GraphQlSource} that
* returns it.
* {@link GraphQL} instance and wraps it with a {@link GraphQlSource} that returns it.
*
* @author Rossen Stoyanchev
*/
class DefaultGraphQlSourceBuilder implements GraphQlSource.Builder {
@@ -54,14 +56,13 @@ class DefaultGraphQlSourceBuilder implements GraphQlSource.Builder {
private final List<Instrumentation> instrumentations = new ArrayList<>();
private Consumer<GraphQL.Builder> graphQlConfigurers = builder -> {};
private Consumer<GraphQL.Builder> graphQlConfigurers = (builder) -> {
};
DefaultGraphQlSourceBuilder() {
this.typeVisitors.add(ContextDataFetcherDecorator.TYPE_VISITOR);
}
@Override
public GraphQlSource.Builder schemaResource(Resource resource) {
this.schemaResource = resource;
@@ -128,12 +129,10 @@ class DefaultGraphQlSourceBuilder implements GraphQlSource.Builder {
}
}
catch (IOException ex) {
throw new IllegalArgumentException(
"Failed to load resourceLocation " + this.schemaResource.toString());
throw new IllegalArgumentException("Failed to load resourceLocation " + this.schemaResource.toString());
}
}
/**
* GraphQlSource that returns the built GraphQL instance and its schema.
*/
@@ -157,6 +156,7 @@ class DefaultGraphQlSourceBuilder implements GraphQlSource.Builder {
public GraphQLSchema schema() {
return this.schema;
}
}
}

View File

@@ -13,46 +13,49 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.execution;
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.
* {@link graphql.schema.DataFetcher}'s that can enable a client to make automated
* decisions.
*
* @author Rossen Stoyanchev
* @since 1.0.0
* @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.
* {@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.
* {@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.
* {@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.
* {@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.
* {@link graphql.schema.DataFetcher} encountered an unexpected condition that
* prevented it from fetching the data value.
*/
INTERNAL_ERROR;

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.execution;
import java.util.ArrayList;
@@ -37,48 +38,42 @@ import org.springframework.web.client.ExtractingResponseErrorHandler;
/**
* {@link DataFetcherExceptionHandler} that invokes {@link DataFetcherExceptionResolver}'s
* in a sequence until one returns a non-null list of {@link GraphQLError}'s.
*
* @author Rossen Stoyanchev
*/
class ExceptionResolversExceptionHandler implements DataFetcherExceptionHandler {
private static Log logger = LogFactory.getLog(ExtractingResponseErrorHandler.class);
private final List<DataFetcherExceptionResolver> resolvers;
/**
* Create an instance
* Create an instance.
* @param resolvers the resolvers to use
*/
public ExceptionResolversExceptionHandler(List<DataFetcherExceptionResolver> resolvers) {
ExceptionResolversExceptionHandler(List<DataFetcherExceptionResolver> resolvers) {
Assert.notNull(resolvers, "'resolvers' is required");
this.resolvers = new ArrayList<>(resolvers);
}
@Override
public DataFetcherExceptionHandlerResult onException(DataFetcherExceptionHandlerParameters parameters) {
Throwable exception = parameters.getException();
exception = (exception instanceof CompletionException ? exception.getCause() : exception);
exception = ((exception instanceof CompletionException) ? exception.getCause() : exception);
return invokeChain(exception, parameters.getDataFetchingEnvironment());
}
@SuppressWarnings("ConstantConditions")
public DataFetcherExceptionHandlerResult invokeChain(Throwable ex, DataFetchingEnvironment env) {
DataFetcherExceptionHandlerResult invokeChain(Throwable ex, DataFetchingEnvironment env) {
// For now we have to block:
// https://github.com/graphql-java/graphql-java/issues/2356
try {
return Flux.fromIterable(this.resolvers)
.flatMap(resolver -> resolver.resolveException(ex, env))
.next()
.map(errors -> DataFetcherExceptionHandlerResult.newResult().errors(errors).build())
.switchIfEmpty(Mono.fromCallable(() -> applyDefaultHandling(ex, env)))
.contextWrite(context -> {
return Flux.fromIterable(this.resolvers).flatMap((resolver) -> resolver.resolveException(ex, env)).next()
.map((errors) -> DataFetcherExceptionHandlerResult.newResult().errors(errors).build())
.switchIfEmpty(Mono.fromCallable(() -> applyDefaultHandling(ex, env))).contextWrite((context) -> {
ContextView contextView = ContextManager.getReactorContext(env);
return (contextView.isEmpty() ? context : context.putAll(contextView));
})
.toFuture()
.get();
}).toFuture().get();
}
catch (Exception ex2) {
if (logger.isWarnEnabled()) {
@@ -89,10 +84,8 @@ class ExceptionResolversExceptionHandler implements DataFetcherExceptionHandler
}
private DataFetcherExceptionHandlerResult applyDefaultHandling(Throwable ex, DataFetchingEnvironment env) {
GraphQLError error = GraphqlErrorBuilder.newError(env)
.message(ex.getMessage())
.errorType(ErrorType.INTERNAL_ERROR)
.build();
GraphQLError error = GraphqlErrorBuilder.newError(env).message(ex.getMessage())
.errorType(ErrorType.INTERNAL_ERROR).build();
return DataFetcherExceptionHandlerResult.newResult(error).build();
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.execution;
import graphql.ExecutionInput;
@@ -25,21 +26,22 @@ import org.springframework.graphql.GraphQlService;
/**
* Implementation of {@link GraphQlService} that performs GraphQL request execution
* through {@link GraphQL#executeAsync(ExecutionInput)}.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public class ExecutionGraphQlService implements GraphQlService {
private final GraphQlSource graphQlSource;
public ExecutionGraphQlService(GraphQlSource graphQlSource) {
this.graphQlSource = graphQlSource;
}
@Override
public Mono<ExecutionResult> execute(ExecutionInput input) {
GraphQL graphQl = this.graphQlSource.graphQl();
return Mono.deferContextual(contextView -> {
return Mono.deferContextual((contextView) -> {
ContextManager.setReactorContext(contextView, input);
return Mono.fromFuture(graphQl.executeAsync(input));
});

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.execution;
import java.io.File;
@@ -31,34 +32,38 @@ import org.springframework.core.io.Resource;
/**
* Strategy to resolve the {@link GraphQL} instance to use.
*
* <p>This contract also includes a {@link GraphQlSource} builder encapsulating
* the initialization of the {@link GraphQL} instance and associated
* <p>
* This contract also includes a {@link GraphQlSource} builder encapsulating the
* initialization of the {@link GraphQL} instance and associated
* {@link graphql.schema.GraphQLSchema}.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public interface GraphQlSource {
/**
* Return the {@link GraphQL} to use. This can be a cached instance or a
* different one from time to time (e.g. based on a reloaded schema).
* Return the {@link GraphQL} to use. This can be a cached instance or a different one
* from time to time (e.g. based on a reloaded schema).
* @return the GraphQL instance to use
*/
GraphQL graphQl();
/**
* Return the {@link GraphQLSchema} used by the current {@link GraphQL}.
* @return the current GraphQL schema
*/
GraphQLSchema schema();
/**
* Return a builder for a {@link GraphQlSource} given input for the
* initialization of {@link GraphQL} and {@link graphql.schema.GraphQLSchema}.
* Return a builder for a {@link GraphQlSource} given input for the initialization of
* {@link GraphQL} and {@link graphql.schema.GraphQLSchema}.
* @return a builder for a GraphQlSource
*/
static Builder builder() {
return new DefaultGraphQlSourceBuilder();
}
/**
* Builder for a {@link GraphQlSource}.
*/
@@ -66,47 +71,62 @@ public interface GraphQlSource {
/**
* Provide the resource for the GraphQL {@literal ".schema"} file to parse.
* @param resource the resource for the GraphQL schema
* @return the current builder
* @see graphql.schema.idl.SchemaParser#parse(File)
*/
Builder schemaResource(Resource resource);
/**
* Set a {@link RuntimeWiring} to contribute data fetchers and more.
* @see graphql.schema.idl.SchemaGenerator#makeExecutableSchema(TypeDefinitionRegistry, RuntimeWiring)
* @param runtimeWiring the runtime wiring for contribution
* @return the current builder
* @see graphql.schema.idl.SchemaGenerator#makeExecutableSchema(TypeDefinitionRegistry,
* RuntimeWiring)
*/
Builder runtimeWiring(RuntimeWiring runtimeWiring);
/**
* Add {@link DataFetcherExceptionResolver}'s to use for resolving
* exceptions from {@link graphql.schema.DataFetcher}'s.
* Add {@link DataFetcherExceptionResolver}'s to use for resolving exceptions from
* {@link graphql.schema.DataFetcher}'s.
* @param resolvers the resolvers to add
* @return the current builder
*/
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)
* @param typeVisitors the type visitors
* @return the current builder
* @see graphql.schema.SchemaTransformer#transformSchema(GraphQLSchema,
* GraphQLTypeVisitor)
*/
Builder typeVisitors(List<GraphQLTypeVisitor> typeVisitors);
/**
* Provide {@link Instrumentation} components to instrument the execution
* of GraphQL queries.
* Provide {@link Instrumentation} components to instrument the execution of
* GraphQL queries.
* @param instrumentations the instrumentation components
* @return the current builder
* @see graphql.GraphQL.Builder#instrumentation(Instrumentation)
*/
Builder instrumentation(List<Instrumentation> instrumentations);
/**
* Configure consumers to be given access to the {@link GraphQL.Builder}
* used to build {@link GraphQL}.
* Configure consumers to be given access to the {@link GraphQL.Builder} used to
* build {@link GraphQL}.
* @param configurer the configurer
* @return the current builder
*/
Builder configureGraphQl(Consumer<GraphQL.Builder> configurer);
/**
* Build the {@link GraphQlSource}.
* @return the built GraphQlSource
*/
GraphQlSource build();
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.execution;
import java.util.List;
@@ -24,6 +25,9 @@ import reactor.util.context.ContextView;
/**
* {@link DataFetcherExceptionResolver} that resolves exceptions synchronously.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public interface SyncDataFetcherExceptionResolver extends DataFetcherExceptionResolver {
@@ -43,6 +47,7 @@ public interface SyncDataFetcherExceptionResolver extends DataFetcherExceptionRe
* Implement this method to resolve exceptions.
* @param exception the exception to resolve
* @param environment the environment for the invoked {@code DataFetcher}
* @return the list of resolved GraphQL errors
*/
List<GraphQLError> doResolveException(Throwable exception, DataFetchingEnvironment environment);

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.execution;
import java.util.List;
@@ -21,39 +22,41 @@ import java.util.Map;
import org.springframework.beans.factory.ObjectProvider;
/**
* Interface to be implemented by a framework or an application in order to
* assist with extracting ThreadLocal values at the web layer, which can then be
* re-established for DataFetcher's that are potentially executing on a
* different thread.
* Interface to be implemented by a framework or an application in order to assist with
* extracting ThreadLocal values at the web layer, which can then be re-established for
* DataFetcher's that are potentially executing on a different thread.
*
* <p>Implementations may be declared as beans in Spring configuration and
* ordered as defined in {@link ObjectProvider#orderedStream()}.
* <p>
* Implementations may be declared as beans in Spring configuration and ordered as defined
* in {@link ObjectProvider#orderedStream()}.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public interface ThreadLocalAccessor {
/**
* Extract ThreadLocal values and add them to the given Map which is then
* passed to {@link #restoreValues(Map)} and {@link #resetValues(Map)}
* before and after the execution of a {@link graphql.schema.DataFetcher}.
* Extract ThreadLocal values and add them to the given Map which is then passed to
* {@link #restoreValues(Map)} and {@link #resetValues(Map)} before and after the
* execution of a {@link graphql.schema.DataFetcher}.
* @param container container for ThreadLocal values
*/
void extractValues(Map<String, Object> container);
/**
* Re-establish ThreadLocal context by looking up values, previously
* extracted via {@link #extractValues(Map)}.
* Re-establish ThreadLocal context by looking up values, previously extracted via
* {@link #extractValues(Map)}.
* @param values the saved ThreadLocal values
*/
void restoreValues(Map<String, Object> values);
/**
* Reset ThreadLocal context for the given values, previously extracted
* via {@link #extractValues(Map)}.
* Reset ThreadLocal context for the given values, previously extracted via
* {@link #extractValues(Map)}.
* @param values the saved ThreadLocal values
*/
void resetValues(Map<String, Object> values);
/**
* Create a composite accessor that delegates to all of the given accessors.
* @param accessors the accessors to aggregate

View File

@@ -1,6 +1,22 @@
/*
* Copyright 2020-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.
*/
/**
* Support for GraphQL request execution, including abstractions to configure
* and invoke {@link graphql.GraphQL}.
* Support for GraphQL request execution, including abstractions to configure and invoke
* {@link graphql.GraphQL}.
*/
@NonNullApi
@NonNullFields

View File

@@ -1,8 +1,23 @@
/*
* Copyright 2020-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.
*/
/**
* Top level abstractions for processing GraphQL requests including
* {@link org.springframework.graphql.GraphQlService} for executing a request and
* {@link org.springframework.graphql.RequestInput} to represent the input for
* a request.
* {@link org.springframework.graphql.RequestInput} to represent the input for a request.
*/
@NonNullApi
@NonNullFields

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web;
import java.util.ArrayList;
@@ -33,6 +34,8 @@ import org.springframework.util.CollectionUtils;
/**
* Default implementation of {@link WebGraphQlHandler.Builder}.
*
* @author Rossen Stoyanchev
*/
class DefaultWebGraphQlHandlerBuilder implements WebGraphQlHandler.Builder {
@@ -44,13 +47,11 @@ class DefaultWebGraphQlHandlerBuilder implements WebGraphQlHandler.Builder {
@Nullable
private List<ThreadLocalAccessor> accessors;
DefaultWebGraphQlHandlerBuilder(GraphQlService service) {
Assert.notNull(service, "GraphQlService is required");
this.service = service;
}
@Override
public WebGraphQlHandler.Builder interceptor(WebInterceptor... interceptors) {
return interceptors(Arrays.asList(interceptors));
@@ -59,7 +60,7 @@ class DefaultWebGraphQlHandlerBuilder implements WebGraphQlHandler.Builder {
@Override
public WebGraphQlHandler.Builder interceptors(List<WebInterceptor> interceptors) {
if (!CollectionUtils.isEmpty(interceptors)) {
this.interceptors = (this.interceptors != null ? this.interceptors : new ArrayList<>());
this.interceptors = (this.interceptors != null) ? this.interceptors : new ArrayList<>();
this.interceptors.addAll(interceptors);
}
return this;
@@ -73,7 +74,7 @@ class DefaultWebGraphQlHandlerBuilder implements WebGraphQlHandler.Builder {
@Override
public WebGraphQlHandler.Builder threadLocalAccessors(List<ThreadLocalAccessor> accessors) {
if (!CollectionUtils.isEmpty(accessors)) {
this.accessors = (this.accessors != null ? this.accessors : new ArrayList<>());
this.accessors = (this.accessors != null) ? this.accessors : new ArrayList<>();
this.accessors.addAll(accessors);
}
return this;
@@ -81,27 +82,25 @@ class DefaultWebGraphQlHandlerBuilder implements WebGraphQlHandler.Builder {
@Override
public WebGraphQlHandler build() {
List<WebInterceptor> interceptorsToUse =
(this.interceptors != null ? this.interceptors : Collections.emptyList());
List<WebInterceptor> interceptorsToUse = (this.interceptors != null) ? this.interceptors
: Collections.emptyList();
WebGraphQlHandler targetHandler = webInput -> {
WebGraphQlHandler targetHandler = (webInput) -> {
ExecutionInput executionInput = webInput.toExecutionInput();
return this.service.execute(executionInput).map(result -> new WebOutput(webInput, result));
return this.service.execute(executionInput).map((result) -> new WebOutput(webInput, result));
};
WebGraphQlHandler interceptionChain = interceptorsToUse.stream()
.reduce(WebInterceptor::andThen)
.map(interceptor -> (WebGraphQlHandler) input -> interceptor.intercept(input, targetHandler))
WebGraphQlHandler interceptionChain = interceptorsToUse.stream().reduce(WebInterceptor::andThen)
.map((interceptor) -> (WebGraphQlHandler) (input) -> interceptor.intercept(input, targetHandler))
.orElse(targetHandler);
return (CollectionUtils.isEmpty(this.accessors) ? interceptionChain :
new ThreadLocalExtractingHandler(interceptionChain, ThreadLocalAccessor.composite(this.accessors)));
return (CollectionUtils.isEmpty(this.accessors) ? interceptionChain
: new ThreadLocalExtractingHandler(interceptionChain, ThreadLocalAccessor.composite(this.accessors)));
}
/**
* {@link WebGraphQlHandler} that extracts ThreadLocal values and saves them
* in the Reactor context for subsequent use for DataFetcher's.
* {@link WebGraphQlHandler} that extracts ThreadLocal values and saves them in the
* Reactor context for subsequent use for DataFetcher's.
*/
private static class ThreadLocalExtractingHandler implements WebGraphQlHandler {
@@ -116,12 +115,12 @@ class DefaultWebGraphQlHandlerBuilder implements WebGraphQlHandler.Builder {
@Override
public Mono<WebOutput> handle(WebInput input) {
return this.delegate.handle(input)
.contextWrite(context -> {
ContextView view = ContextManager.extractThreadLocalValues(this.accessor);
return (!view.isEmpty() ? context.putAll(view) : context);
});
return this.delegate.handle(input).contextWrite((context) -> {
ContextView view = ContextManager.extractThreadLocalValues(this.accessor);
return (!view.isEmpty() ? context.putAll(view) : context);
});
}
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web;
import java.util.List;
@@ -23,30 +24,31 @@ import org.springframework.graphql.GraphQlService;
import org.springframework.graphql.execution.ThreadLocalAccessor;
/**
* Contract to handle a GraphQL over HTTP or WebSocket request that forms the
* basis of a {@link WebInterceptor} delegation chain.
* Contract to handle a GraphQL over HTTP or WebSocket request that forms the basis of a
* {@link WebInterceptor} delegation chain.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public interface WebGraphQlHandler {
/**
* Perform request execution for the given input and return the result.
*
* @param input the GraphQL request input container
* @return the execution result
*/
Mono<WebOutput> handle(WebInput input);
/**
* Provides access to a builder to create a {@link WebGraphQlHandler} instance.
* @param graphQlService the {@link GraphQlService} to use for actual
* execution of the request.
* @param graphQlService the {@link GraphQlService} to use for actual execution of the
* request.
* @return a builder for a WebGraphQlHandler
*/
static Builder builder(GraphQlService graphQlService) {
return new DefaultWebGraphQlHandlerBuilder(graphQlService);
}
/**
* Builder for {@link WebGraphQlHandler} that represents a {@link WebInterceptor}
* chain followed by a {@link GraphQlService}.
@@ -56,31 +58,40 @@ public interface WebGraphQlHandler {
/**
* Configure interceptors to be invoked before the target {@code GraphQlService}.
* @param interceptors the interceptors to add
* @return this builder
*/
Builder interceptor(WebInterceptor... interceptors);
/**
* Alternative to {@link #interceptor(WebInterceptor...)} with a List.
* @param interceptors the list of interceptors to add
* @return this builder
*/
Builder interceptors(List<WebInterceptor> interceptors);
/**
* Configure accessors for ThreadLocal variables to use to extract
* ThreadLocal values at the Web framework level, have those propagated
* and re-established at the DataFetcher level.
* Configure accessors for ThreadLocal variables to use to extract ThreadLocal
* values at the Web framework level, have those propagated and re-established at
* the DataFetcher level.
* @param accessors the accessors to add
* @return this builder
*/
Builder threadLocalAccessor(ThreadLocalAccessor... accessors);
/**
* Alternative to {@link #threadLocalAccessor(ThreadLocalAccessor...)} with a List.
* Alternative to {@link #threadLocalAccessor(ThreadLocalAccessor...)} with a
* List.
* @param accessors the list of accessors to add
* @return this builder
*/
Builder threadLocalAccessors(List<ThreadLocalAccessor> accessors);
/**
* Build the {@link WebGraphQlHandler} instance.
* @return the built WebGraphQlHandler
*/
WebGraphQlHandler build();
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web;
import java.net.URI;
@@ -30,8 +31,11 @@ import org.springframework.web.util.UriComponentsBuilder;
/**
* Container for the input of a GraphQL query over HTTP. The input includes the
* {@link UriComponents URL} and the headers of the request, as well as the
* query name, operation name, and variables from the request body.
* {@link UriComponents URL} and the headers of the request, as well as the query name,
* operation name, and variables from the request body.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public class WebInput extends RequestInput {
@@ -41,15 +45,14 @@ public class WebInput extends RequestInput {
private final String id;
/**
* Create an instance.
* @param uri the url for the HTTP request, or WebSocket handshake
* @param headers the HTTP request headers
* @param body the content of the request deserialized from JSON
* @param id an identifier for the GraphQL request, e.g. a subscription id
* for correlating request and response messages, or it could be an id
* associated with the underlying request/connection id, if available
* @param id an identifier for the GraphQL request, e.g. a subscription id for
* correlating request and response messages, or it could be an id associated with the
* underlying request/connection id, if available
*/
public WebInput(URI uri, HttpHeaders headers, Map<String, Object> body, @Nullable String id) {
super(validateQuery(body));
@@ -57,7 +60,7 @@ public class WebInput extends RequestInput {
Assert.notNull(headers, "HttpHeaders is required'");
this.uri = UriComponentsBuilder.fromUri(uri).build(true);
this.headers = headers;
this.id = (id != null ? id : ObjectUtils.identityToString(this));
this.id = (id != null) ? id : ObjectUtils.identityToString(this);
}
private static Map<String, Object> validateQuery(Map<String, Object> body) {
@@ -68,10 +71,10 @@ public class WebInput extends RequestInput {
return body;
}
/**
* Return the URI of the HTTP request including
* {@link UriComponents#getQueryParams() URL query parameters}.
* Return the URI of the HTTP request including {@link UriComponents#getQueryParams()
* URL query parameters}.
* @return the HTTP request URI
*/
public UriComponents getUri() {
return this.uri;
@@ -79,6 +82,7 @@ public class WebInput extends RequestInput {
/**
* Return the headers of the request.
* @return the HTTP request headers
*/
public HttpHeaders getHeaders() {
return this.headers;
@@ -86,13 +90,14 @@ public class WebInput extends RequestInput {
/**
* Return the identifier for the request, which may be a subscription id for
* correlating request and response messages, or the underlying request or
* connection id, when available, or otherwise it's an
* correlating request and response messages, or the underlying request or connection
* id, when available, or otherwise it's an
* {@link ObjectUtils#identityToString(Object) identity} hash based this
* {@code WebInput} instance.
* @return the HTTP request identifier
*/
public String getId() {
return this.id;
}
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web;
import graphql.ExecutionInput;
@@ -23,36 +24,41 @@ import org.springframework.beans.factory.ObjectProvider;
import org.springframework.util.Assert;
/**
* Interceptor for intercepting GraphQL over HTTP or WebSocket requests.
* Provides information about the HTTP request or WebSocket handshake, allows
* customization of the {@link ExecutionInput} and of the {@link ExecutionResult}
* from request execution.
* Interceptor for intercepting GraphQL over HTTP or WebSocket requests. Provides
* information about the HTTP request or WebSocket handshake, allows customization of the
* {@link ExecutionInput} and of the {@link ExecutionResult} from request execution.
*
* <p>Interceptors may be declared as beans in Spring configuration and ordered
* as defined in {@link ObjectProvider#orderedStream()}.
* <p>
* Interceptors may be declared as beans in Spring configuration and ordered as defined in
* {@link ObjectProvider#orderedStream()}.
*
* <p>Supported for Spring MVC and WebFlux.
* <p>
* Supported for Spring MVC and WebFlux.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public interface WebInterceptor {
/**
* Intercept a request and delegate for further handling and request execution
* via {@link WebGraphQlHandler#handle(WebInput)}.
*
* @param webInput container with HTTP request information and options to
* customize the {@link ExecutionInput}.
* Intercept a request and delegate for further handling and request execution via
* {@link WebGraphQlHandler#handle(WebInput)}.
* @param webInput container with HTTP request information and options to customize
* the {@link ExecutionInput}.
* @param next the handler to delegate to for request execution
* @return a {@link Mono} with the result
*/
Mono<WebOutput> intercept(WebInput webInput, WebGraphQlHandler next);
/**
* Return a composed {@link WebInterceptor} that invokes the current
* interceptor first one and then the one one passed in.
* Return a composed {@link WebInterceptor} that invokes the current interceptor first
* one and then the one one passed in.
* @param interceptor the interceptor to compose the current one with
* @return the composed WebInterceptor
*/
default WebInterceptor andThen(WebInterceptor interceptor) {
Assert.notNull(interceptor, "WebInterceptor must not be null");
return (currentInput, next) -> intercept(currentInput, nextInput -> interceptor.intercept(nextInput, next));
return (currentInput, next) -> intercept(currentInput, (nextInput) -> interceptor.intercept(nextInput, next));
}
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web;
import java.util.Collections;
@@ -28,11 +29,13 @@ import org.springframework.http.HttpHeaders;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Decorate an {@link ExecutionResult}, provide a way to
* {@link #transform(Consumer) transform} it, and collect input for custom
* HTTP response headers for GraphQL over HTTP requests.
* Decorate an {@link ExecutionResult}, provide a way to {@link #transform(Consumer)
* transform} it, and collect input for custom HTTP response headers for GraphQL over HTTP
* requests.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public class WebOutput implements ExecutionResult {
@@ -43,9 +46,10 @@ public class WebOutput implements ExecutionResult {
@Nullable
private final HttpHeaders responseHeaders;
/**
* Create an instance that wraps the given {@link ExecutionResult}.
* @param input the container for the GraphQL input
* @param executionResult the result of performing a graphql query
*/
public WebOutput(WebInput input, ExecutionResult executionResult) {
this(input, executionResult, null);
@@ -59,9 +63,9 @@ public class WebOutput implements ExecutionResult {
this.responseHeaders = responseHeaders;
}
/**
* Return the associated {@link WebInput} used for the execution.
* @return the associated WebInput
*/
public WebInput getWebInput() {
return this.input;
@@ -93,20 +97,22 @@ public class WebOutput implements ExecutionResult {
}
/**
* Return a read-only view of any custom headers to be added to the HTTP
* response, or {@code null} until {@link #transform(Consumer)} is used to
* add such headers.
* Return a read-only view of any custom headers to be added to the HTTP response, or
* {@code null} until {@link #transform(Consumer)} is used to add such headers.
* @return the read-only HTTP response headers
* @see #transform(Consumer)
* @see Builder#responseHeader(String, String...)
*/
@Nullable
public HttpHeaders getResponseHeaders() {
return (this.responseHeaders != null ? HttpHeaders.readOnlyHttpHeaders(this.responseHeaders) : null);
return (this.responseHeaders != null) ? HttpHeaders.readOnlyHttpHeaders(this.responseHeaders) : null;
}
/**
* Transform this {@code WebOutput} instance through a {@link Builder} and
* return a new instance with the modified values.
* Transform this {@code WebOutput} instance through a {@link Builder} and return a
* new instance with the modified values.
* @param consumer teh callback that will transform the WebOutput
* @return the transformed WebOutput
*/
public WebOutput transform(Consumer<Builder> consumer) {
Builder builder = new Builder(this);
@@ -114,11 +120,10 @@ public class WebOutput implements ExecutionResult {
return builder.build();
}
/**
* Builder to transform a {@link WebOutput}.
*/
public static class Builder {
public static final class Builder {
private final WebInput input;
@@ -133,7 +138,6 @@ public class WebOutput implements ExecutionResult {
@Nullable
private HttpHeaders headers;
private Builder(WebOutput output) {
this.input = output.getWebInput();
this.data = output.getData();
@@ -142,10 +146,10 @@ public class WebOutput implements ExecutionResult {
this.headers = output.responseHeaders;
}
/**
* Set the {@link ExecutionResult#getData() data} of the GraphQL
* execution result.
* Set the {@link ExecutionResult#getData() data} of the GraphQL execution result.
* @param data the execution result data
* @return the current builder
*/
public Builder data(@Nullable Object data) {
this.data = data;
@@ -153,17 +157,21 @@ public class WebOutput implements ExecutionResult {
}
/**
* Set the {@link ExecutionResult#getErrors() errors} of the GraphQL
* execution result.
* Set the {@link ExecutionResult#getErrors() errors} of the GraphQL execution
* result.
* @param errors the execution result errors
* @return the current builder
*/
public Builder errors(@Nullable List<GraphQLError> errors) {
this.errors = (errors != null ? errors : Collections.emptyList());
this.errors = (errors != null) ? errors : Collections.emptyList();
return this;
}
/**
* Set the {@link ExecutionResult#getExtensions() extensions} of the
* GraphQL execution result.
* Set the {@link ExecutionResult#getExtensions() extensions} of the GraphQL
* execution result.
* @param extensions the execution result extensions
* @return the current builder
*/
public Builder extensions(@Nullable Map<Object, Object> extensions) {
this.extensions = extensions;
@@ -173,9 +181,13 @@ public class WebOutput implements ExecutionResult {
/**
* Add a custom header to be set on the HTTP response.
*
* <p><strong>Note:</strong> This can be used for GraphQL over HTTP
* requests but has no impact for queries over a WebSocket session where
* the initial handshake request completes before queries begin.
* <p>
* <strong>Note:</strong> This can be used for GraphQL over HTTP requests but has
* no impact for queries over a WebSocket session where the initial handshake
* request completes before queries begin.
* @param name the HTTP header name
* @param values the HTTP header values
* @return the current builder
*/
public Builder responseHeader(String name, String... values) {
initHeaders();
@@ -188,9 +200,12 @@ public class WebOutput implements ExecutionResult {
/**
* Consume and update the headers to be set on the HTTP response.
*
* <p><strong>Note:</strong> This can be used for GraphQL over HTTP
* requests but has no impact for queries over a WebSocket session where
* the initial handshake request completes before queries begin.
* <p>
* <strong>Note:</strong> This can be used for GraphQL over HTTP requests but has
* no impact for queries over a WebSocket session where the initial handshake
* request completes before queries begin.
* @param consumer callback that updates the HTTP headers
* @return the current builder
*/
public Builder responseHeaders(Consumer<HttpHeaders> consumer) {
initHeaders();
@@ -199,13 +214,14 @@ public class WebOutput implements ExecutionResult {
}
private void initHeaders() {
this.headers = (this.headers != null ? this.headers : new HttpHeaders());
this.headers = (this.headers != null) ? this.headers : new HttpHeaders();
}
public WebOutput build() {
ExecutionResult result = new ExecutionResultImpl(this.data, this.errors, this.extensions);
return new WebOutput(this.input, result, this.headers);
}
}
}

View File

@@ -1,10 +1,26 @@
/*
* Copyright 2020-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.
*/
/**
* Support for executing GraphQL requests over the Web, including handlers for
* HTTP and WebSocket. Handlers are provided for use in ether
* Support for executing GraphQL requests over the Web, including handlers for HTTP and
* WebSocket. Handlers are provided for use in ether
* {@link org.springframework.graphql.web.webmvc Spring WebMvc} or
* {@link org.springframework.graphql.web.webflux Spring WebFlux} with a common
* {@link org.springframework.graphql.web.WebInterceptor interception} model
* that allows applications to customize request input and output.
* {@link org.springframework.graphql.web.WebInterceptor interception} model that allows
* applications to customize request input and output.
*/
@NonNullApi
@NonNullFields

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web.webflux;
import java.util.Map;
@@ -30,18 +31,19 @@ import org.springframework.web.reactive.function.server.ServerResponse;
/**
* WebFlux.fn Handler for GraphQL over HTTP requests.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public class GraphQlHttpHandler {
private static final Log logger = LogFactory.getLog(GraphQlHttpHandler.class);
private static final ParameterizedTypeReference<Map<String, Object>> MAP_PARAMETERIZED_TYPE_REF =
new ParameterizedTypeReference<Map<String, Object>>() {};
private static final ParameterizedTypeReference<Map<String, Object>> MAP_PARAMETERIZED_TYPE_REF = new ParameterizedTypeReference<Map<String, Object>>() {
};
private final WebGraphQlHandler graphQlHandler;
/**
* Create a new instance.
* @param graphQlHandler common handler for GraphQL over HTTP requests
@@ -51,31 +53,30 @@ public class GraphQlHttpHandler {
this.graphQlHandler = graphQlHandler;
}
/**
* Handle GraphQL requests over HTTP.
* @param request the incoming HTTP request
* @return the HTTP response
*/
public Mono<ServerResponse> handleRequest(ServerRequest request) {
return request.bodyToMono(MAP_PARAMETERIZED_TYPE_REF)
.flatMap(body -> {
String id = request.exchange().getRequest().getId();
WebInput input = new WebInput(request.uri(), request.headers().asHttpHeaders(), body, id);
if (logger.isDebugEnabled()) {
logger.debug("Executing: " + input);
}
return this.graphQlHandler.handle(input);
})
.flatMap(output -> {
Map<String, Object> spec = output.toSpecification();
if (logger.isDebugEnabled()) {
logger.debug("Execution complete");
}
ServerResponse.BodyBuilder builder = ServerResponse.ok();
if (output.getResponseHeaders() != null) {
builder.headers(headers -> headers.putAll(output.getResponseHeaders()));
}
return builder.bodyValue(spec);
});
return request.bodyToMono(MAP_PARAMETERIZED_TYPE_REF).flatMap((body) -> {
String id = request.exchange().getRequest().getId();
WebInput input = new WebInput(request.uri(), request.headers().asHttpHeaders(), body, id);
if (logger.isDebugEnabled()) {
logger.debug("Executing: " + input);
}
return this.graphQlHandler.handle(input);
}).flatMap((output) -> {
Map<String, Object> spec = output.toSpecification();
if (logger.isDebugEnabled()) {
logger.debug("Execution complete");
}
ServerResponse.BodyBuilder builder = ServerResponse.ok();
if (output.getResponseHeaders() != null) {
builder.headers((headers) -> headers.putAll(output.getResponseHeaders()));
}
return builder.bodyValue(spec);
});
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web.webflux;
import java.time.Duration;
@@ -58,19 +59,22 @@ import org.springframework.web.reactive.socket.WebSocketSession;
/**
* WebSocketHandler for GraphQL based on
* <a href="https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md">GraphQL Over WebSocket Protocol</a>
* and for use in a Spring WebFlux application.
* <a href="https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md">GraphQL Over
* WebSocket Protocol</a> and for use in a Spring WebFlux application.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public class GraphQlWebSocketHandler implements WebSocketHandler {
private static final Log logger = LogFactory.getLog(GraphQlWebSocketHandler.class);
private static final List<String> SUB_PROTOCOL_LIST =
Arrays.asList("graphql-transport-ws", "subscriptions-transport-ws");
static final ResolvableType MAP_RESOLVABLE_TYPE =
ResolvableType.forType(new ParameterizedTypeReference<Map<String, Object>>() {});
private static final List<String> SUB_PROTOCOL_LIST = Arrays.asList("graphql-transport-ws",
"subscriptions-transport-ws");
static final ResolvableType MAP_RESOLVABLE_TYPE = ResolvableType
.forType(new ParameterizedTypeReference<Map<String, Object>>() {
});
private final WebGraphQlHandler graphQlHandler;
@@ -80,16 +84,15 @@ public class GraphQlWebSocketHandler implements WebSocketHandler {
private final Duration initTimeoutDuration;
/**
* Create a new instance.
* @param graphQlHandler common handler for GraphQL over HTTP requests
* @param configurer codec configurer for JSON encoding and decoding
* @param connectionInitTimeout the time within which the {@code CONNECTION_INIT}
* type message must be received.
* @param connectionInitTimeout the time within which the {@code CONNECTION_INIT} type
* message must be received.
*/
public GraphQlWebSocketHandler(
WebGraphQlHandler graphQlHandler, ServerCodecConfigurer configurer, Duration connectionInitTimeout) {
public GraphQlWebSocketHandler(WebGraphQlHandler graphQlHandler, ServerCodecConfigurer configurer,
Duration connectionInitTimeout) {
Assert.notNull(graphQlHandler, "WebGraphQlHandler is required");
this.graphQlHandler = graphQlHandler;
@@ -100,34 +103,30 @@ public class GraphQlWebSocketHandler implements WebSocketHandler {
private static Decoder<?> initDecoder(ServerCodecConfigurer configurer) {
return configurer.getReaders().stream()
.filter(reader -> reader.canRead(MAP_RESOLVABLE_TYPE, MediaType.APPLICATION_JSON))
.map(reader -> ((DecoderHttpMessageReader<?>) reader).getDecoder())
.findFirst()
.filter((reader) -> reader.canRead(MAP_RESOLVABLE_TYPE, MediaType.APPLICATION_JSON))
.map((reader) -> ((DecoderHttpMessageReader<?>) reader).getDecoder()).findFirst()
.orElseThrow(() -> new IllegalArgumentException("No JSON Decoder"));
}
private static Encoder<?> initEncoder(ServerCodecConfigurer configurer) {
return configurer.getWriters().stream()
.filter(writer -> writer.canWrite(MAP_RESOLVABLE_TYPE, MediaType.APPLICATION_JSON))
.map(writer -> ((EncoderHttpMessageWriter<?>) writer).getEncoder())
.findFirst()
.filter((writer) -> writer.canWrite(MAP_RESOLVABLE_TYPE, MediaType.APPLICATION_JSON))
.map((writer) -> ((EncoderHttpMessageWriter<?>) writer).getEncoder()).findFirst()
.orElseThrow(() -> new IllegalArgumentException("No JSON Encoder"));
}
@Override
public List<String> getSubProtocols() {
return SUB_PROTOCOL_LIST;
}
@Override
public Mono<Void> handle(WebSocketSession session) {
HandshakeInfo handshakeInfo = session.getHandshakeInfo();
if ("subscriptions-transport-ws".equalsIgnoreCase(handshakeInfo.getSubProtocol())) {
if (logger.isDebugEnabled()) {
logger.debug("apollographql/subscriptions-transport-ws is not supported, nor maintained. " +
"Please, use https://github.com/enisdenjo/graphql-ws.");
logger.debug("apollographql/subscriptions-transport-ws is not supported, nor maintained. "
+ "Please, use https://github.com/enisdenjo/graphql-ws.");
}
return session.close(GraphQlStatus.INVALID_MESSAGE_STATUS);
}
@@ -136,60 +135,54 @@ public class GraphQlWebSocketHandler implements WebSocketHandler {
AtomicBoolean connectionInitProcessed = new AtomicBoolean();
Map<String, Subscription> subscriptions = new ConcurrentHashMap<>();
Mono.delay(this.initTimeoutDuration)
.then(Mono.defer(() ->
connectionInitProcessed.compareAndSet(false, true) ?
session.close(GraphQlStatus.INIT_TIMEOUT_STATUS) :
Mono.empty()))
.subscribe();
Mono.delay(this.initTimeoutDuration).then(Mono.defer(() -> connectionInitProcessed.compareAndSet(false, true)
? session.close(GraphQlStatus.INIT_TIMEOUT_STATUS) : Mono.empty())).subscribe();
return session.send(session.receive()
.flatMap(message -> {
Map<String, Object> map = decode(message);
String id = (String) map.get("id");
MessageType messageType = MessageType.resolve((String) map.get("type"));
if (messageType == null) {
return GraphQlStatus.close(session, GraphQlStatus.INVALID_MESSAGE_STATUS);
return session.send(session.receive().flatMap((message) -> {
Map<String, Object> map = decode(message);
String id = (String) map.get("id");
MessageType messageType = MessageType.resolve((String) map.get("type"));
if (messageType == null) {
return GraphQlStatus.close(session, GraphQlStatus.INVALID_MESSAGE_STATUS);
}
switch (messageType) {
case SUBSCRIBE:
if (!connectionInitProcessed.get()) {
return GraphQlStatus.close(session, GraphQlStatus.UNAUTHORIZED_STATUS);
}
if (id == null) {
return GraphQlStatus.close(session, GraphQlStatus.INVALID_MESSAGE_STATUS);
}
WebInput input = new WebInput(handshakeInfo.getUri(), handshakeInfo.getHeaders(), getPayload(map), id);
if (logger.isDebugEnabled()) {
logger.debug("Executing: " + input);
}
return this.graphQlHandler.handle(input)
.flatMapMany((output) -> handleWebOutput(session, id, subscriptions, output))
.doOnTerminate(() -> subscriptions.remove(id));
case COMPLETE:
if (id != null) {
Subscription subscription = subscriptions.remove(id);
if (subscription != null) {
subscription.cancel();
}
switch (messageType) {
case SUBSCRIBE:
if (!connectionInitProcessed.get()) {
return GraphQlStatus.close(session, GraphQlStatus.UNAUTHORIZED_STATUS);
}
if (id == null) {
return GraphQlStatus.close(session, GraphQlStatus.INVALID_MESSAGE_STATUS);
}
WebInput input = new WebInput(
handshakeInfo.getUri(), handshakeInfo.getHeaders(), getPayload(map), id);
if (logger.isDebugEnabled()) {
logger.debug("Executing: " + input);
}
return this.graphQlHandler.handle(input)
.flatMapMany(output -> handleWebOutput(session, id, subscriptions, output))
.doOnTerminate(() -> subscriptions.remove(id));
case COMPLETE:
if (id != null) {
Subscription subscription = subscriptions.remove(id);
if (subscription != null) {
subscription.cancel();
}
}
return Flux.empty();
case CONNECTION_INIT:
if (!connectionInitProcessed.compareAndSet(false, true)) {
return GraphQlStatus.close(session, GraphQlStatus.TOO_MANY_INIT_REQUESTS_STATUS);
}
return Flux.just(encode(session, null, MessageType.CONNECTION_ACK, null));
default:
return GraphQlStatus.close(session, GraphQlStatus.INVALID_MESSAGE_STATUS);
}
}));
}
return Flux.empty();
case CONNECTION_INIT:
if (!connectionInitProcessed.compareAndSet(false, true)) {
return GraphQlStatus.close(session, GraphQlStatus.TOO_MANY_INIT_REQUESTS_STATUS);
}
return Flux.just(encode(session, null, MessageType.CONNECTION_ACK, null));
default:
return GraphQlStatus.close(session, GraphQlStatus.INVALID_MESSAGE_STATUS);
}
}));
}
@SuppressWarnings({"unchecked", "ConstantConditions"})
@SuppressWarnings({ "unchecked", "ConstantConditions" })
private Map<String, Object> decode(WebSocketMessage message) {
DataBuffer buffer = DataBufferUtils.retain(message.getPayload());
return (Map<String, Object>) decoder.decode(buffer, MAP_RESOLVABLE_TYPE, null, null);
return (Map<String, Object>) this.decoder.decode(buffer, MAP_RESOLVABLE_TYPE, null, null);
}
@SuppressWarnings("unchecked")
@@ -200,58 +193,50 @@ public class GraphQlWebSocketHandler implements WebSocketHandler {
}
@SuppressWarnings("unchecked")
private Flux<WebSocketMessage> handleWebOutput(
WebSocketSession session, String id, Map<String, Subscription> subscriptions, WebOutput output) {
private Flux<WebSocketMessage> handleWebOutput(WebSocketSession session, String id,
Map<String, Subscription> subscriptions, WebOutput output) {
if (logger.isDebugEnabled()) {
logger.debug("Execution result ready" +
(!CollectionUtils.isEmpty(output.getErrors()) ?
" with errors: " + output.getErrors() : "") + ".");
logger.debug("Execution result ready"
+ (!CollectionUtils.isEmpty(output.getErrors()) ? " with errors: " + output.getErrors() : "")
+ ".");
}
Flux<ExecutionResult> outputFlux;
if (output.getData() instanceof Publisher) {
// Subscription
outputFlux = Flux.from((Publisher<ExecutionResult>) output.getData())
.doOnSubscribe(subscription -> {
Subscription previous = subscriptions.putIfAbsent(id, subscription);
if (previous != null) {
throw new SubscriptionExistsException();
}
});
outputFlux = Flux.from((Publisher<ExecutionResult>) output.getData()).doOnSubscribe((subscription) -> {
Subscription previous = subscriptions.putIfAbsent(id, subscription);
if (previous != null) {
throw new SubscriptionExistsException();
}
});
}
else {
// Single response operation (query or mutation)
outputFlux = (CollectionUtils.isEmpty(output.getErrors()) ?
Flux.just(output) :
Flux.error(new IllegalStateException("Execution failed: " + output.getErrors())));
outputFlux = (CollectionUtils.isEmpty(output.getErrors()) ? Flux.just(output)
: Flux.error(new IllegalStateException("Execution failed: " + output.getErrors())));
}
return outputFlux
.map(result -> {
Map<String, Object> dataMap = result.toSpecification();
return encode(session, id, MessageType.NEXT, dataMap);
})
.concatWith(Mono.fromCallable(() -> encode(session, id, MessageType.COMPLETE, null)))
.onErrorResume(ex -> {
if (ex instanceof SubscriptionExistsException) {
CloseStatus status = new CloseStatus(4409, "Subscriber for " + id + " already exists");
return GraphQlStatus.close(session, status);
}
ErrorType errorType = ErrorType.DataFetchingException;
String message = ex.getMessage();
Map<String, Object> errorMap = GraphqlErrorBuilder.newError()
.errorType(errorType)
.message(message)
.build()
.toSpecification();
return Mono.just(encode(session, id, MessageType.ERROR, errorMap));
});
return outputFlux.map((result) -> {
Map<String, Object> dataMap = result.toSpecification();
return encode(session, id, MessageType.NEXT, dataMap);
}).concatWith(Mono.fromCallable(() -> encode(session, id, MessageType.COMPLETE, null))).onErrorResume((ex) -> {
if (ex instanceof SubscriptionExistsException) {
CloseStatus status = new CloseStatus(4409, "Subscriber for " + id + " already exists");
return GraphQlStatus.close(session, status);
}
ErrorType errorType = ErrorType.DataFetchingException;
String message = ex.getMessage();
Map<String, Object> errorMap = GraphqlErrorBuilder.newError().errorType(errorType).message(message).build()
.toSpecification();
return Mono.just(encode(session, id, MessageType.ERROR, errorMap));
});
}
@SuppressWarnings("unchecked")
private <T> WebSocketMessage encode(
WebSocketSession session, @Nullable String id, MessageType messageType, @Nullable Object payload) {
private <T> WebSocketMessage encode(WebSocketSession session, @Nullable String id, MessageType messageType,
@Nullable Object payload) {
Map<String, Object> payloadMap = new HashMap<>(3);
if (id != null) {
@@ -262,23 +247,16 @@ public class GraphQlWebSocketHandler implements WebSocketHandler {
payloadMap.put("payload", payload);
}
DataBuffer buffer = ((Encoder<T>) encoder).encodeValue(
(T) payloadMap, session.bufferFactory(), MAP_RESOLVABLE_TYPE,
MimeTypeUtils.APPLICATION_JSON, null);
DataBuffer buffer = ((Encoder<T>) this.encoder).encodeValue((T) payloadMap, session.bufferFactory(),
MAP_RESOLVABLE_TYPE, MimeTypeUtils.APPLICATION_JSON, null);
return new WebSocketMessage(WebSocketMessage.Type.TEXT, buffer);
}
private enum MessageType {
CONNECTION_INIT("connection_init"),
CONNECTION_ACK("connection_ack"),
SUBSCRIBE("subscribe"),
NEXT("next"),
ERROR("error"),
COMPLETE("complete");
CONNECTION_INIT("connection_init"), CONNECTION_ACK("connection_ack"), SUBSCRIBE("subscribe"), NEXT(
"next"), ERROR("error"), COMPLETE("complete");
private static final Map<String, MessageType> messageTypes = new HashMap<>(6);
@@ -288,7 +266,6 @@ public class GraphQlWebSocketHandler implements WebSocketHandler {
}
}
private final String type;
MessageType(String type) {
@@ -301,10 +278,10 @@ public class GraphQlWebSocketHandler implements WebSocketHandler {
@Nullable
public static MessageType resolve(@Nullable String type) {
return (type != null ? messageTypes.get(type) : null);
return (type != null) ? messageTypes.get(type) : null;
}
}
}
private static class GraphQlStatus {
@@ -314,16 +291,17 @@ public class GraphQlWebSocketHandler implements WebSocketHandler {
static final CloseStatus INIT_TIMEOUT_STATUS = new CloseStatus(4408, "Connection initialisation timeout");
static final CloseStatus TOO_MANY_INIT_REQUESTS_STATUS = new CloseStatus(4429, "Too many initialisation requests");
static final CloseStatus TOO_MANY_INIT_REQUESTS_STATUS = new CloseStatus(4429,
"Too many initialisation requests");
static <V> Flux<V> close(WebSocketSession session, CloseStatus status) {
return session.close(status).thenMany(Mono.empty());
}
}
private static class SubscriptionExistsException extends RuntimeException {
}
}

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2020-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.
*/
/**
* HTTP and WebSocket handlers for use in a Spring WebFlux application.
*/

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web.webmvc;
import java.io.IOException;
@@ -36,18 +37,20 @@ import org.springframework.web.servlet.function.ServerResponse;
/**
* GraphQL handler to expose as a WebMvc.fn endpoint via
* {@link org.springframework.web.servlet.function.RouterFunctions}.
*
* @author Rossen Stoyanchev
* @author Brian Clozel
* @since 1.0.0
*/
public class GraphQlHttpHandler {
private final static Log logger = LogFactory.getLog(GraphQlHttpHandler.class);
private static final ParameterizedTypeReference<Map<String, Object>> MAP_PARAMETERIZED_TYPE_REF =
new ParameterizedTypeReference<Map<String, Object>>() {};
private static final Log logger = LogFactory.getLog(GraphQlHttpHandler.class);
private static final ParameterizedTypeReference<Map<String, Object>> MAP_PARAMETERIZED_TYPE_REF = new ParameterizedTypeReference<Map<String, Object>>() {
};
private final WebGraphQlHandler graphQlHandler;
/**
* Create a new instance.
* @param graphQlHandler common handler for GraphQL over HTTP requests
@@ -57,29 +60,28 @@ public class GraphQlHttpHandler {
this.graphQlHandler = graphQlHandler;
}
/**
* {@inheritDoc}
*
* @throws ServletException may be raised when reading the request body,
* e.g. {@link HttpMediaTypeNotSupportedException}.
* Handle GraphQL requests over HTTP.
* @param request the incoming HTTP request
* @return the HTTP response
* @throws ServletException may be raised when reading the request body, e.g.
* {@link HttpMediaTypeNotSupportedException}.
*/
public ServerResponse handleRequest(ServerRequest request) throws ServletException {
WebInput input = new WebInput(request.uri(), request.headers().asHttpHeaders(), readBody(request), null);
if (logger.isDebugEnabled()) {
logger.debug("Executing: " + input);
}
Mono<ServerResponse> responseMono = this.graphQlHandler.handle(input)
.map(output -> {
if (logger.isDebugEnabled()) {
logger.debug("Execution complete");
}
ServerResponse.BodyBuilder builder = ServerResponse.ok();
if (output.getResponseHeaders() != null) {
builder.headers(headers -> headers.putAll(output.getResponseHeaders()));
}
return builder.body(output.toSpecification());
});
Mono<ServerResponse> responseMono = this.graphQlHandler.handle(input).map((output) -> {
if (logger.isDebugEnabled()) {
logger.debug("Execution complete");
}
ServerResponse.BodyBuilder builder = ServerResponse.ok();
if (output.getResponseHeaders() != null) {
builder.headers((headers) -> headers.putAll(output.getResponseHeaders()));
}
return builder.body(output.toSpecification());
});
return ServerResponse.async(responseMono);
}
@@ -92,4 +94,4 @@ public class GraphQlHttpHandler {
}
}
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web.webmvc;
import java.io.ByteArrayInputStream;
@@ -60,16 +61,19 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
/**
* WebSocketHandler for GraphQL based on
* <a href="https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md">GraphQL Over WebSocket Protocol</a>
* and for use on a Servlet container with {@code spring-websocket}.
* <a href="https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md">GraphQL Over
* WebSocket Protocol</a> and for use on a Servlet container with
* {@code spring-websocket}.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public class GraphQlWebSocketHandler extends TextWebSocketHandler implements SubProtocolCapable {
public class GraphQlWebSocketHandler extends TextWebSocketHandler implements SubProtocolCapable {
private static final Log logger = LogFactory.getLog(GraphQlWebSocketHandler.class);
private static final List<String> SUB_PROTOCOL_LIST =
Arrays.asList("graphql-transport-ws", "subscriptions-transport-ws");
private static final List<String> SUB_PROTOCOL_LIST = Arrays.asList("graphql-transport-ws",
"subscriptions-transport-ws");
private final WebGraphQlHandler graphQlHandler;
@@ -79,16 +83,15 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
private final Map<String, SessionState> sessionInfoMap = new ConcurrentHashMap<>();
/**
* Create a new instance.
* @param graphQlHandler common handler for GraphQL over HTTP requests
* @param converter for JSON encoding and decoding
* @param connectionInitTimeout the time within which the {@code CONNECTION_INIT}
* type message must be received.
* @param converter for JSON encoding and decoding
* @param connectionInitTimeout the time within which the {@code CONNECTION_INIT} type
* message must be received.
*/
public GraphQlWebSocketHandler(
WebGraphQlHandler graphQlHandler, HttpMessageConverter<?> converter, Duration connectionInitTimeout) {
public GraphQlWebSocketHandler(WebGraphQlHandler graphQlHandler, HttpMessageConverter<?> converter,
Duration connectionInitTimeout) {
Assert.notNull(graphQlHandler, "WebGraphQlHandler is required");
Assert.notNull(converter, "HttpMessageConverter for JSON is required");
@@ -97,19 +100,17 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
this.converter = converter;
}
@Override
public List<String> getSubProtocols() {
return SUB_PROTOCOL_LIST;
}
@Override
public void afterConnectionEstablished(WebSocketSession session) {
if ("subscriptions-transport-ws".equalsIgnoreCase(session.getAcceptedProtocol())) {
if (logger.isDebugEnabled()) {
logger.debug("apollographql/subscriptions-transport-ws is not supported, nor maintained. " +
"Please, use https://github.com/enisdenjo/graphql-ws.");
logger.debug("apollographql/subscriptions-transport-ws is not supported, nor maintained. "
+ "Please, use https://github.com/enisdenjo/graphql-ws.");
}
GraphQlStatus.closeSession(session, GraphQlStatus.INVALID_MESSAGE_STATUS);
return;
@@ -118,13 +119,11 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
SessionState sessionState = new SessionState(session.getId());
this.sessionInfoMap.put(session.getId(), sessionState);
Mono.delay(this.initTimeoutDuration)
.then(Mono.fromRunnable(() -> {
if (sessionState.isConnectionInitNotProcessed()) {
GraphQlStatus.closeSession(session, GraphQlStatus.INIT_TIMEOUT_STATUS);
}
}))
.subscribe();
Mono.delay(this.initTimeoutDuration).then(Mono.fromRunnable(() -> {
if (sessionState.isConnectionInitNotProcessed()) {
GraphQlStatus.closeSession(session, GraphQlStatus.INIT_TIMEOUT_STATUS);
}
})).subscribe();
}
@Override
@@ -139,45 +138,45 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
}
SessionState sessionState = getSessionInfo(session);
switch (messageType) {
case SUBSCRIBE:
if (sessionState.isConnectionInitNotProcessed()) {
GraphQlStatus.closeSession(session, GraphQlStatus.UNAUTHORIZED_STATUS);
return;
}
if (id == null) {
GraphQlStatus.closeSession(session, GraphQlStatus.INVALID_MESSAGE_STATUS);
return;
}
URI uri = session.getUri();
Assert.notNull(uri, "Expected handshake url");
HttpHeaders headers = session.getHandshakeHeaders();
WebInput input = new WebInput(uri, headers, getPayload(map), id);
if (logger.isDebugEnabled()) {
logger.debug("Executing: " + input);
}
this.graphQlHandler.handle(input)
.flatMapMany(output -> handleWebOutput(session, input.getId(), output))
.publishOn(sessionState.getScheduler()) // Serial blocking send via single thread
.subscribe(new SendMessageSubscriber(id, session, sessionState));
case SUBSCRIBE:
if (sessionState.isConnectionInitNotProcessed()) {
GraphQlStatus.closeSession(session, GraphQlStatus.UNAUTHORIZED_STATUS);
return;
case COMPLETE:
if (id != null) {
Subscription subscription = sessionState.getSubscriptions().remove(id);
if (subscription != null) {
subscription.cancel();
}
}
return;
case CONNECTION_INIT:
if (sessionState.setConnectionInitProcessed()) {
GraphQlStatus.closeSession(session, GraphQlStatus.TOO_MANY_INIT_REQUESTS_STATUS);
return;
}
TextMessage outputMessage = encode(null, MessageType.CONNECTION_ACK, null);
session.sendMessage(outputMessage);
return;
default:
}
if (id == null) {
GraphQlStatus.closeSession(session, GraphQlStatus.INVALID_MESSAGE_STATUS);
return;
}
URI uri = session.getUri();
Assert.notNull(uri, "Expected handshake url");
HttpHeaders headers = session.getHandshakeHeaders();
WebInput input = new WebInput(uri, headers, getPayload(map), id);
if (logger.isDebugEnabled()) {
logger.debug("Executing: " + input);
}
this.graphQlHandler.handle(input).flatMapMany((output) -> handleWebOutput(session, input.getId(), output))
.publishOn(sessionState.getScheduler()) // Serial blocking send via
// single thread
.subscribe(new SendMessageSubscriber(id, session, sessionState));
return;
case COMPLETE:
if (id != null) {
Subscription subscription = sessionState.getSubscriptions().remove(id);
if (subscription != null) {
subscription.cancel();
}
}
return;
case CONNECTION_INIT:
if (sessionState.setConnectionInitProcessed()) {
GraphQlStatus.closeSession(session, GraphQlStatus.TOO_MANY_INIT_REQUESTS_STATUS);
return;
}
TextMessage outputMessage = encode(null, MessageType.CONNECTION_ACK, null);
session.sendMessage(outputMessage);
return;
default:
GraphQlStatus.closeSession(session, GraphQlStatus.INVALID_MESSAGE_STATUS);
}
}
@@ -203,49 +202,41 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
@SuppressWarnings("unchecked")
private Flux<TextMessage> handleWebOutput(WebSocketSession session, String id, WebOutput output) {
if (logger.isDebugEnabled()) {
logger.debug("Execution result ready" +
(!CollectionUtils.isEmpty(output.getErrors()) ?
" with errors: " + output.getErrors() : "") + ".");
logger.debug("Execution result ready"
+ (!CollectionUtils.isEmpty(output.getErrors()) ? " with errors: " + output.getErrors() : "")
+ ".");
}
Flux<ExecutionResult> outputFlux;
if (output.getData() instanceof Publisher) {
// Subscription
outputFlux = Flux.from((Publisher<ExecutionResult>) output.getData())
.doOnSubscribe(subscription -> {
Subscription prev = getSessionInfo(session).getSubscriptions().putIfAbsent(id, subscription);
if (prev != null) {
throw new SubscriptionExistsException();
}
});
outputFlux = Flux.from((Publisher<ExecutionResult>) output.getData()).doOnSubscribe((subscription) -> {
Subscription prev = getSessionInfo(session).getSubscriptions().putIfAbsent(id, subscription);
if (prev != null) {
throw new SubscriptionExistsException();
}
});
}
else {
// Single response operation (query or mutation)
outputFlux = (CollectionUtils.isEmpty(output.getErrors()) ?
Flux.just(output) :
Flux.error(new IllegalStateException("Execution failed: " + output.getErrors())));
outputFlux = (CollectionUtils.isEmpty(output.getErrors()) ? Flux.just(output)
: Flux.error(new IllegalStateException("Execution failed: " + output.getErrors())));
}
return outputFlux
.map(result -> {
Map<String, Object> dataMap = result.toSpecification();
return encode(id, MessageType.NEXT, dataMap);
})
.concatWith(Mono.fromCallable(() -> encode(id, MessageType.COMPLETE, null)))
.onErrorResume(ex -> {
if (ex instanceof SubscriptionExistsException) {
CloseStatus status = new CloseStatus(4409, "Subscriber for " + id + " already exists");
GraphQlStatus.closeSession(session, status);
return Flux.empty();
}
ErrorType errorType = ErrorType.DataFetchingException;
String message = ex.getMessage();
Map<String, Object> errorMap = GraphqlErrorBuilder.newError()
.errorType(errorType)
.message(message)
.build()
.toSpecification();
return Mono.just(encode(id, MessageType.ERROR, errorMap));
});
return outputFlux.map((result) -> {
Map<String, Object> dataMap = result.toSpecification();
return encode(id, MessageType.NEXT, dataMap);
}).concatWith(Mono.fromCallable(() -> encode(id, MessageType.COMPLETE, null))).onErrorResume((ex) -> {
if (ex instanceof SubscriptionExistsException) {
CloseStatus status = new CloseStatus(4409, "Subscriber for " + id + " already exists");
GraphQlStatus.closeSession(session, status);
return Flux.empty();
}
ErrorType errorType = ErrorType.DataFetchingException;
String message = ex.getMessage();
Map<String, Object> errorMap = GraphqlErrorBuilder.newError().errorType(errorType).message(message).build()
.toSpecification();
return Mono.just(encode(id, MessageType.ERROR, errorMap));
});
}
@SuppressWarnings("unchecked")
@@ -289,16 +280,10 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
return false;
}
private enum MessageType {
CONNECTION_INIT("connection_init"),
CONNECTION_ACK("connection_ack"),
SUBSCRIBE("subscribe"),
NEXT("next"),
ERROR("error"),
COMPLETE("complete");
CONNECTION_INIT("connection_init"), CONNECTION_ACK("connection_ack"), SUBSCRIBE("subscribe"), NEXT(
"next"), ERROR("error"), COMPLETE("complete");
private static final Map<String, MessageType> messageTypes = new HashMap<>(6);
@@ -308,7 +293,6 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
}
}
private final String type;
MessageType(String type) {
@@ -321,10 +305,10 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
@Nullable
public static MessageType resolve(@Nullable String type) {
return (type != null ? messageTypes.get(type) : null);
return (type != null) ? messageTypes.get(type) : null;
}
}
}
private static class GraphQlStatus {
@@ -332,12 +316,13 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
private static final CloseStatus UNAUTHORIZED_STATUS = new CloseStatus(4401, "Unauthorized");
private static final CloseStatus INIT_TIMEOUT_STATUS = new CloseStatus(4408, "Connection initialisation timeout");
private static final CloseStatus INIT_TIMEOUT_STATUS = new CloseStatus(4408,
"Connection initialisation timeout");
private static final CloseStatus TOO_MANY_INIT_REQUESTS_STATUS = new CloseStatus(4429, "Too many initialisation requests");
private static final CloseStatus TOO_MANY_INIT_REQUESTS_STATUS = new CloseStatus(4429,
"Too many initialisation requests");
public static void closeSession(WebSocketSession session, CloseStatus status) {
static void closeSession(WebSocketSession session, CloseStatus status) {
try {
session.close(status);
}
@@ -347,8 +332,8 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
}
}
}
}
}
private static class HttpInputMessageAdapter extends ByteArrayInputStream implements HttpInputMessage {
@@ -365,8 +350,8 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
public HttpHeaders getHeaders() {
return HttpHeaders.EMPTY;
}
}
}
private static class HttpOutputMessageAdapter extends ByteArrayOutputStream implements HttpOutputMessage {
@@ -381,8 +366,8 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
public HttpHeaders getHeaders() {
return noOpHeaders;
}
}
}
private static class SessionState {
@@ -392,25 +377,25 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
private final Scheduler scheduler;
public SessionState(String sessionId) {
SessionState(String sessionId) {
this.scheduler = Schedulers.newSingle("GraphQL-WsSession-" + sessionId);
}
public boolean isConnectionInitNotProcessed() {
boolean isConnectionInitNotProcessed() {
return !this.connectionInitProcessed;
}
public synchronized boolean setConnectionInitProcessed() {
synchronized boolean setConnectionInitProcessed() {
boolean previousValue = this.connectionInitProcessed;
this.connectionInitProcessed = true;
return previousValue;
}
public Map<String, Subscription> getSubscriptions() {
Map<String, Subscription> getSubscriptions() {
return this.subscriptions;
}
public void dispose() {
void dispose() {
for (Map.Entry<String, Subscription> entry : this.subscriptions.entrySet()) {
try {
entry.getValue().cancel();
@@ -423,11 +408,11 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
this.scheduler.dispose();
}
public Scheduler getScheduler() {
Scheduler getScheduler() {
return this.scheduler;
}
}
}
private static class SendMessageSubscriber extends BaseSubscriber<TextMessage> {
@@ -437,7 +422,7 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
private final SessionState sessionState;
public SendMessageSubscriber(String subscriptionId, WebSocketSession session, SessionState sessionState) {
SendMessageSubscriber(String subscriptionId, WebSocketSession session, SessionState sessionState) {
this.subscriptionId = subscriptionId;
this.session = session;
this.sessionState = sessionState;
@@ -468,10 +453,11 @@ public class GraphQlWebSocketHandler extends TextWebSocketHandler implements Sub
public void hookOnComplete() {
this.sessionState.getSubscriptions().remove(this.subscriptionId);
}
}
private static class SubscriptionExistsException extends RuntimeException {
}
}

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2020-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.
*/
/**
* HTTP and WebSocket handlers for use in a Spring WebMvc application.
*/

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql;
import java.nio.charset.StandardCharsets;
@@ -31,30 +32,23 @@ import org.springframework.graphql.execution.GraphQlSource;
*/
public abstract class GraphQlTestUtils {
public static GraphQL initGraphQl(
String schemaContent, String typeName, String fieldName, DataFetcher<?> fetcher) {
public static GraphQL initGraphQl(String schemaContent, String typeName, String fieldName, DataFetcher<?> fetcher) {
return initGraphQlSource(schemaContent, typeName, fieldName, fetcher)
.build()
.graphQl();
return initGraphQlSource(schemaContent, typeName, fieldName, fetcher).build().graphQl();
}
public static GraphQL initGraphQl(
String schemaContent, String typeName, String fieldName, DataFetcher<?> fetcher,
public static GraphQL initGraphQl(String schemaContent, String typeName, String fieldName, DataFetcher<?> fetcher,
DataFetcherExceptionResolver... resolvers) {
return initGraphQlSource(schemaContent, typeName, fieldName, fetcher)
.exceptionResolvers(Arrays.asList(resolvers))
.build()
.graphQl();
.exceptionResolvers(Arrays.asList(resolvers)).build().graphQl();
}
public static GraphQlSource.Builder initGraphQlSource(
String schemaContent, String typeName, String fieldName, DataFetcher<?> fetcher) {
public static GraphQlSource.Builder initGraphQlSource(String schemaContent, String typeName, String fieldName,
DataFetcher<?> fetcher) {
RuntimeWiring wiring = RuntimeWiring.newRuntimeWiring()
.type(typeName, builder -> builder.dataFetcher(fieldName, fetcher))
.build();
.type(typeName, (builder) -> builder.dataFetcher(fieldName, fetcher)).build();
return GraphQlSource.builder()
.schemaResource(new ByteArrayResource(schemaContent.getBytes(StandardCharsets.UTF_8)))

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql;
import graphql.GraphQL;
@@ -27,12 +28,10 @@ public class TestGraphQlSource implements GraphQlSource {
private final GraphQL graphQl;
public TestGraphQlSource(GraphQL graphQl) {
this.graphQl = graphQl;
}
@Override
public GraphQL graphQl() {
return this.graphQl;
@@ -42,4 +41,5 @@ public class TestGraphQlSource implements GraphQlSource {
public GraphQLSchema schema() {
throw new UnsupportedOperationException();
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql;
import java.util.Map;
@@ -21,7 +22,7 @@ import org.springframework.graphql.execution.ThreadLocalAccessor;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.assertj.core.api.Assertions.assertThat;
/**
* {@link ThreadLocalAccessor} that operates on the ThreadLocal it is given.
@@ -33,12 +34,10 @@ public class TestThreadLocalAccessor<T> implements ThreadLocalAccessor {
@Nullable
private Long threadId;
public TestThreadLocalAccessor(ThreadLocal<T> threadLocal) {
this.threadLocal = threadLocal;
}
@Override
public void extractValues(Map<String, Object> container) {
saveThreadId();
@@ -66,9 +65,7 @@ public class TestThreadLocalAccessor<T> implements ThreadLocalAccessor {
}
private void checkThreadId() {
assertThat(this.threadId)
.as("No threadId to check. Was extractValues not called?")
.isNotNull();
assertThat(this.threadId).as("No threadId to check. Was extractValues not called?").isNotNull();
assertThat(Thread.currentThread().getId() != this.threadId)
.as("ThreadLocal value extracted and restored on the same thread. Propagation not tested effectively.")
.isTrue();

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.execution;
import java.time.Duration;
@@ -41,12 +42,11 @@ public class ContextDataFetcherDecoratorTests {
@Test
void monoDataFetcher() throws Exception {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }",
"Query", "greeting", env ->
Mono.deferContextual(context -> {
Object name = context.get("name");
return Mono.delay(Duration.ofMillis(50)).map(aLong -> "Hello " + name);
}));
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", "Query", "greeting",
(env) -> Mono.deferContextual((context) -> {
Object name = context.get("name");
return Mono.delay(Duration.ofMillis(50)).map((aLong) -> "Hello " + name);
}));
ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }").build();
ContextManager.setReactorContext(Context.of("name", "007"), input);
@@ -58,13 +58,11 @@ public class ContextDataFetcherDecoratorTests {
@Test
void fluxDataFetcher() throws Exception {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greetings: [String] }",
"Query", "greetings", env ->
Mono.delay(Duration.ofMillis(50)).flatMapMany(aLong ->
Flux.deferContextual(context -> {
String name = context.get("name");
return Flux.just("Hi", "Bonjour", "Hola").map(s -> s + " " + name);
})));
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greetings: [String] }", "Query", "greetings",
(env) -> Mono.delay(Duration.ofMillis(50)).flatMapMany((aLong) -> Flux.deferContextual((context) -> {
String name = context.get("name");
return Flux.just("Hi", "Bonjour", "Hola").map((s) -> s + " " + name);
})));
ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greetings }").build();
ContextManager.setReactorContext(Context.of("name", "007"), input);
@@ -77,24 +75,19 @@ public class ContextDataFetcherDecoratorTests {
@Test
void fluxDataFetcherSubscription() throws Exception {
GraphQL graphQl = GraphQlTestUtils.initGraphQl(
"type Query { greeting: String } type Subscription { greetings: String }",
"Subscription", "greetings", env ->
Mono.delay(Duration.ofMillis(50)).flatMapMany(aLong ->
Flux.deferContextual(context -> {
String name = context.get("name");
return Flux.just("Hi", "Bonjour", "Hola").map(s -> s + " " + name);
})));
"type Query { greeting: String } type Subscription { greetings: String }", "Subscription", "greetings",
(env) -> Mono.delay(Duration.ofMillis(50)).flatMapMany((aLong) -> Flux.deferContextual((context) -> {
String name = context.get("name");
return Flux.just("Hi", "Bonjour", "Hola").map((s) -> s + " " + name);
})));
ExecutionInput input = ExecutionInput.newExecutionInput().query("subscription { greetings }").build();
ContextManager.setReactorContext(Context.of("name", "007"), input);
Publisher<String> publisher = graphQl.executeAsync(input).get().getData();
List<String> actual = Flux.from(publisher)
.cast(ExecutionResult.class)
.map(result -> ((Map<String, ?>) result.getData()).get("greetings"))
.cast(String.class)
.collectList()
List<String> actual = Flux.from(publisher).cast(ExecutionResult.class)
.map((result) -> ((Map<String, ?>) result.getData()).get("greetings")).cast(String.class).collectList()
.block();
assertThat(actual).containsExactly("Hi 007", "Bonjour 007", "Hola 007");
@@ -106,16 +99,15 @@ public class ContextDataFetcherDecoratorTests {
nameThreadLocal.set("007");
TestThreadLocalAccessor<String> accessor = new TestThreadLocalAccessor<>(nameThreadLocal);
try {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }",
"Query", "greeting", env -> "Hello " + nameThreadLocal.get());
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", "Query", "greeting",
(env) -> "Hello " + nameThreadLocal.get());
ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }").build();
ContextView view = ContextManager.extractThreadLocalValues(accessor);
ContextManager.setReactorContext(view, input);
ExecutionResult result = Mono.delay(Duration.ofMillis(10))
.flatMap(aLong -> Mono.fromFuture(graphQl.executeAsync(input)))
.block();
.flatMap((aLong) -> Mono.fromFuture(graphQl.executeAsync(input))).block();
Map<String, Object> data = result.getData();
assertThat(data).hasSize(1).containsEntry("greeting", "Hello 007");

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.execution;
import java.time.Duration;
@@ -42,15 +43,11 @@ public class ExceptionResolversExceptionHandlerTests {
@Test
void resolveException() throws Exception {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }",
"Query", "greeting", env -> {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", "Query", "greeting",
(env) -> {
throw new IllegalArgumentException("Invalid greeting");
},
(ex, env) -> Mono.just(Collections.singletonList(
GraphqlErrorBuilder.newError(env)
.message("Resolved error: " + ex.getMessage())
.errorType(ErrorType.BAD_REQUEST)
.build())));
}, (ex, env) -> Mono.just(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();
@@ -66,15 +63,13 @@ public class ExceptionResolversExceptionHandlerTests {
@Test
void resolveExceptionWithReactorContext() throws Exception {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }",
"Query", "greeting", env -> {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", "Query", "greeting",
(env) -> {
throw new IllegalArgumentException("Invalid greeting");
},
(ex, env) -> Mono.deferContextual(view -> Mono.just(Collections.singletonList(
GraphqlErrorBuilder.newError(env)
.message("Resolved error: " + ex.getMessage() + ", name=" + view.get("name"))
.errorType(ErrorType.BAD_REQUEST)
.build()))));
(ex, env) -> Mono.deferContextual((view) -> Mono.just(Collections.singletonList(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);
@@ -91,23 +86,21 @@ public class ExceptionResolversExceptionHandlerTests {
nameThreadLocal.set("007");
TestThreadLocalAccessor<String> accessor = new TestThreadLocalAccessor<>(nameThreadLocal);
try {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }",
"Query", "greeting", env -> {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", "Query", "greeting",
(env) -> {
throw new IllegalArgumentException("Invalid greeting");
},
(SyncDataFetcherExceptionResolver) (ex, env) -> Collections.singletonList(
GraphqlErrorBuilder.newError(env)
(SyncDataFetcherExceptionResolver) (ex,
env) -> Collections.singletonList(GraphqlErrorBuilder.newError(env)
.message("Resolved error: " + ex.getMessage() + ", name=" + nameThreadLocal.get())
.errorType(ErrorType.BAD_REQUEST)
.build()));
.errorType(ErrorType.BAD_REQUEST).build()));
ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }").build();
ContextView view = ContextManager.extractThreadLocalValues(accessor);
ContextManager.setReactorContext(view, input);
ExecutionResult result = Mono.delay(Duration.ofMillis(10))
.flatMap(aLong -> Mono.fromFuture(graphQl.executeAsync(input)))
.block();
.flatMap((aLong) -> Mono.fromFuture(graphQl.executeAsync(input))).block();
List<GraphQLError> errors = result.getErrors();
assertThat(errors.get(0).getMessage()).isEqualTo("Resolved error: Invalid greeting, name=007");
@@ -119,11 +112,10 @@ public class ExceptionResolversExceptionHandlerTests {
@Test
void unresolvedException() throws Exception {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }",
"Query", "greeting", env -> {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", "Query", "greeting",
(env) -> {
throw new IllegalArgumentException("Invalid greeting");
},
(exception, environment) -> Mono.empty());
}, (exception, environment) -> Mono.empty());
ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }").build();
ExecutionResult result = graphQl.executeAsync(input).get();
@@ -140,11 +132,10 @@ public class ExceptionResolversExceptionHandlerTests {
@Test
void suppressedException() throws Exception {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }",
"Query", "greeting", env -> {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", "Query", "greeting",
(env) -> {
throw new IllegalArgumentException("Invalid greeting");
},
(ex, env) -> Mono.just(Collections.emptyList()));
}, (ex, env) -> Mono.just(Collections.emptyList()));
ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }").build();
ExecutionResult result = graphQl.executeAsync(input).get();

View File

@@ -1,3 +1,19 @@
/*
* Copyright 2020-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.web;
public class Book {
@@ -8,7 +24,6 @@ public class Book {
String author;
public Book() {
}
@@ -18,7 +33,6 @@ public class Book {
this.author = author;
}
public Long getId() {
return this.id;
}
@@ -42,4 +56,5 @@ public class Book {
public void setAuthor(String author) {
this.author = author;
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web;
import java.util.Arrays;
@@ -20,41 +21,24 @@ import java.util.HashMap;
import java.util.Map;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.TypeRuntimeWiring;
import reactor.core.publisher.Flux;
import org.springframework.core.io.ClassPathResource;
import org.springframework.graphql.execution.ExecutionGraphQlService;
import org.springframework.graphql.execution.GraphQlSource;
import static graphql.schema.idl.TypeRuntimeWiring.newTypeWiring;
public abstract class BookTestUtils {
public static final String SUBSCRIPTION_ID = "1";
public static final String BOOK_QUERY = "{" +
"\"id\":\"" + BookTestUtils.SUBSCRIPTION_ID + "\"," +
"\"type\":\"subscribe\"," +
"\"payload\":{\"query\": \"" +
" query TestQuery {" +
" bookById(id: \\\"1\\\"){ " +
" id" +
" name" +
" author" +
" }}\"}" +
"}";
public static final String BOOK_QUERY = "{" + "\"id\":\"" + BookTestUtils.SUBSCRIPTION_ID + "\","
+ "\"type\":\"subscribe\"," + "\"payload\":{\"query\": \"" + " query TestQuery {"
+ " bookById(id: \\\"1\\\"){ " + " id" + " name" + " author" + " }}\"}" + "}";
public static final String BOOK_SUBSCRIPTION = "{" +
"\"id\":\"" + SUBSCRIPTION_ID + "\"," +
"\"type\":\"subscribe\"," +
"\"payload\":{\"query\": \"" +
" subscription TestSubscription {" +
" bookSearch(author: \\\"George\\\") {" +
" id" +
" name" +
" author" +
" }}\"}" +
"}";
public static final String BOOK_SUBSCRIPTION = "{" + "\"id\":\"" + SUBSCRIPTION_ID + "\","
+ "\"type\":\"subscribe\"," + "\"payload\":{\"query\": \"" + " subscription TestSubscription {"
+ " bookSearch(author: \\\"George\\\") {" + " id" + " name" + " author" + " }}\"}" + "}";
private static final Map<Long, Book> booksMap = new HashMap<>(4);
static {
@@ -65,27 +49,23 @@ public abstract class BookTestUtils {
booksMap.put(5L, new Book(5L, "Animal Farm", "George Orwell"));
}
public static WebGraphQlHandler initWebGraphQlHandler(WebInterceptor... interceptors) {
return WebGraphQlHandler.builder(new ExecutionGraphQlService(graphQlSource()))
.interceptors(Arrays.asList(interceptors))
.build();
.interceptors(Arrays.asList(interceptors)).build();
}
private static GraphQlSource graphQlSource() {
RuntimeWiring.Builder builder = RuntimeWiring.newRuntimeWiring();
builder.type(newTypeWiring("Query").dataFetcher("bookById", env -> {
builder.type(TypeRuntimeWiring.newTypeWiring("Query").dataFetcher("bookById", (env) -> {
Long id = Long.parseLong(env.getArgument("id"));
return booksMap.get(id);
}));
builder.type(newTypeWiring("Subscription").dataFetcher("bookSearch", env -> {
builder.type(TypeRuntimeWiring.newTypeWiring("Subscription").dataFetcher("bookSearch", (env) -> {
String author = env.getArgument("author");
return Flux.fromIterable(booksMap.values()).filter(book -> book.getAuthor().contains(author));
return Flux.fromIterable(booksMap.values()).filter((book) -> book.getAuthor().contains(author));
}));
return GraphQlSource.builder()
.schemaResource(new ClassPathResource("books/schema.graphqls"))
.runtimeWiring(builder.build())
.build();
return GraphQlSource.builder().schemaResource(new ClassPathResource("books/schema.graphqls"))
.runtimeWiring(builder.build()).build();
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web;
import org.reactivestreams.Publisher;
@@ -25,11 +26,11 @@ public class ConsumeOneAndNeverCompleteInterceptor implements WebInterceptor {
@Override
public Mono<WebOutput> intercept(WebInput webInput, WebGraphQlHandler next) {
return next.handle(webInput).map(output ->
output.transform(builder -> {
Publisher<?> publisher = output.getData();
assertThat(publisher).isNotNull();
builder.data(Flux.from(publisher).take(1).concatWith(Flux.never()));
}));
return next.handle(webInput).map((output) -> output.transform((builder) -> {
Publisher<?> publisher = output.getData();
assertThat(publisher).isNotNull();
builder.data(Flux.from(publisher).take(1).concatWith(Flux.never()));
}));
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web;
import java.net.URI;
@@ -43,25 +44,21 @@ import static org.assertj.core.api.Assertions.assertThat;
*/
public class WebGraphQlHandlerTests {
private static final WebInput webInput = new WebInput(
URI.create("http://abc.org"), new HttpHeaders(), Collections.singletonMap("query", "{ greeting }"), "1");
private static final WebInput webInput = new WebInput(URI.create("http://abc.org"), new HttpHeaders(),
Collections.singletonMap("query", "{ greeting }"), "1");
@Test
void reactorContextPropagation() {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }",
"Query", "greeting", env ->
Mono.deferContextual(context -> {
Object name = context.get("name");
return Mono.delay(Duration.ofMillis(50)).map(aLong -> "Hello " + name);
}));
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", "Query", "greeting",
(env) -> Mono.deferContextual((context) -> {
Object name = context.get("name");
return Mono.delay(Duration.ofMillis(50)).map((aLong) -> "Hello " + name);
}));
GraphQlService service = new ExecutionGraphQlService(new TestGraphQlSource(graphQl));
WebGraphQlHandler handler = WebGraphQlHandler.builder(service).build();
WebOutput webOutput = handler.handle(webInput)
.contextWrite(context -> context.put("name", "007"))
.block();
WebOutput webOutput = handler.handle(webInput).contextWrite((context) -> context.put("name", "007")).block();
Map<String, Object> data = webOutput.getData();
assertThat(data).hasSize(1).containsEntry("greeting", "Hello 007");
@@ -69,22 +66,18 @@ public class WebGraphQlHandlerTests {
@Test
void reactorContextPropagationToExceptionResolver() {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }",
"Query", "greeting", env -> {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", "Query", "greeting",
(env) -> {
throw new IllegalArgumentException("Invalid greeting");
},
(ex, env) -> Mono.deferContextual(view -> Mono.just(Collections.singletonList(
GraphqlErrorBuilder.newError(env)
.message("Resolved error: " + ex.getMessage() + ", name=" + view.get("name"))
.errorType(ErrorType.BAD_REQUEST)
.build()))));
(ex, env) -> Mono.deferContextual((view) -> Mono.just(Collections.singletonList(GraphqlErrorBuilder
.newError(env).message("Resolved error: " + ex.getMessage() + ", name=" + view.get("name"))
.errorType(ErrorType.BAD_REQUEST).build()))));
GraphQlService service = new ExecutionGraphQlService(new TestGraphQlSource(graphQl));
WebGraphQlHandler handler = WebGraphQlHandler.builder(service).build();
WebOutput webOutput = handler.handle(webInput)
.contextWrite(context -> context.put("name", "007"))
.block();
WebOutput webOutput = handler.handle(webInput).contextWrite((context) -> context.put("name", "007")).block();
Map<String, Object> data = webOutput.getData();
assertThat(data).hasSize(1).containsEntry("greeting", null);
@@ -100,15 +93,15 @@ public class WebGraphQlHandlerTests {
nameThreadLocal.set("007");
TestThreadLocalAccessor<String> threadLocalAccessor = new TestThreadLocalAccessor<>(nameThreadLocal);
try {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }",
"Query", "greeting", env -> "Hello " + nameThreadLocal.get());
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", "Query", "greeting",
(env) -> "Hello " + nameThreadLocal.get());
GraphQlService service = new ExecutionGraphQlService(new TestGraphQlSource(graphQl));
WebGraphQlHandler handler = WebGraphQlHandler.builder(service)
.interceptor((input, next) -> Mono.delay(Duration.ofMillis(10)).flatMap(aLong -> next.handle(input)))
.threadLocalAccessor(threadLocalAccessor)
.build();
.interceptor(
(input, next) -> Mono.delay(Duration.ofMillis(10)).flatMap((aLong) -> next.handle(input)))
.threadLocalAccessor(threadLocalAccessor).build();
Map<String, Object> data = handler.handle(webInput).block().getData();
@@ -125,22 +118,21 @@ public class WebGraphQlHandlerTests {
nameThreadLocal.set("007");
TestThreadLocalAccessor<String> threadLocalAccessor = new TestThreadLocalAccessor<>(nameThreadLocal);
try {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }",
"Query", "greeting", env -> {
GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", "Query", "greeting",
(env) -> {
throw new IllegalArgumentException("Invalid greeting");
},
(SyncDataFetcherExceptionResolver) (ex, env) -> Collections.singletonList(
GraphqlErrorBuilder.newError(env)
(SyncDataFetcherExceptionResolver) (ex,
env) -> Collections.singletonList(GraphqlErrorBuilder.newError(env)
.message("Resolved error: " + ex.getMessage() + ", name=" + nameThreadLocal.get())
.errorType(ErrorType.BAD_REQUEST)
.build()));
.errorType(ErrorType.BAD_REQUEST).build()));
GraphQlService service = new ExecutionGraphQlService(new TestGraphQlSource(graphQl));
WebGraphQlHandler handler = WebGraphQlHandler.builder(service)
.interceptor((input, next) -> Mono.delay(Duration.ofMillis(10)).flatMap(aLong -> next.handle(input)))
.threadLocalAccessor(threadLocalAccessor)
.build();
.interceptor(
(input, next) -> Mono.delay(Duration.ofMillis(10)).flatMap((aLong) -> next.handle(input)))
.threadLocalAccessor(threadLocalAccessor).build();
WebOutput webOutput = handler.handle(webInput).block();

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web;
import java.net.URI;
@@ -34,21 +35,17 @@ import static org.assertj.core.api.Assertions.assertThat;
* Unit tests for a {@link WebInterceptor} chain.
*/
public class WebInterceptorTests {
private static final WebInput webInput = new WebInput(
URI.create("http://abc.org"), new HttpHeaders(), Collections.singletonMap("query", "{ notUsed }"), "1");
private static final WebInput webInput = new WebInput(URI.create("http://abc.org"), new HttpHeaders(),
Collections.singletonMap("query", "{ notUsed }"), "1");
@Test
void interceptorOrder() {
StringBuilder output = new StringBuilder();
WebGraphQlHandler handler = WebGraphQlHandler.builder(input -> emptyExecutionResult())
.interceptors(Arrays.asList(
new OrderInterceptor(1, output),
new OrderInterceptor(2, output),
new OrderInterceptor(3, output)
))
WebGraphQlHandler handler = WebGraphQlHandler.builder((input) -> emptyExecutionResult())
.interceptors(Arrays.asList(new OrderInterceptor(1, output), new OrderInterceptor(2, output),
new OrderInterceptor(3, output)))
.build();
handler.handle(webInput).block();
@@ -57,10 +54,9 @@ public class WebInterceptorTests {
@Test
void responseHeader() {
WebGraphQlHandler handler = WebGraphQlHandler.builder(input -> emptyExecutionResult())
.interceptor((input, next) ->
next.handle(input).map(output ->
output.transform(builder -> builder.responseHeader("testHeader", "testValue"))))
WebGraphQlHandler handler = WebGraphQlHandler.builder((input) -> emptyExecutionResult())
.interceptor((input, next) -> next.handle(input).map(
(output) -> output.transform((builder) -> builder.responseHeader("testHeader", "testValue"))))
.build();
HttpHeaders headers = handler.handle(webInput).block().getResponseHeaders();
@@ -72,16 +68,13 @@ public class WebInterceptorTests {
void executionInputCustomization() {
AtomicReference<String> actualName = new AtomicReference<>();
WebGraphQlHandler handler = WebGraphQlHandler
.builder(input -> {
actualName.set(input.getOperationName());
return emptyExecutionResult();
})
.interceptor((webInput, next) -> {
webInput.configureExecutionInput((input, builder) -> builder.operationName("testOp").build());
return next.handle(webInput);
})
.build();
WebGraphQlHandler handler = WebGraphQlHandler.builder((input) -> {
actualName.set(input.getOperationName());
return emptyExecutionResult();
}).interceptor((webInput, next) -> {
webInput.configureExecutionInput((input, builder) -> builder.operationName("testOp").build());
return next.handle(webInput);
}).build();
handler.handle(webInput).block();
@@ -92,14 +85,13 @@ public class WebInterceptorTests {
return Mono.just(ExecutionResultImpl.newExecutionResult().build());
}
private static class OrderInterceptor implements WebInterceptor {
private final StringBuilder output;
private final int order;
public OrderInterceptor(int order, StringBuilder output) {
OrderInterceptor(int order, StringBuilder output) {
this.output = output;
this.order = order;
}
@@ -107,13 +99,12 @@ public class WebInterceptorTests {
@Override
public Mono<WebOutput> intercept(WebInput input, WebGraphQlHandler next) {
this.output.append(":pre").append(this.order);
return next.handle(input)
.map(output -> {
this.output.append(":post").append(this.order);
return output;
})
.subscribeOn(Schedulers.boundedElastic());
return next.handle(input).map((output) -> {
this.output.append(":post").append(this.order);
return output;
}).subscribeOn(Schedulers.boundedElastic());
}
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web.webflux;
import java.nio.charset.StandardCharsets;
@@ -23,6 +24,7 @@ import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;
@@ -41,7 +43,6 @@ import org.springframework.web.reactive.socket.WebSocketMessage;
import static org.assertj.core.api.Assertions.as;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.InstanceOfAssertFactories.map;
/**
* Unit tests for {@link GraphQlWebSocketHandler}.
@@ -50,81 +51,64 @@ public class GraphQlWebSocketHandlerTests {
private static final Jackson2JsonDecoder decoder = new Jackson2JsonDecoder();
@Test
void query() {
TestWebSocketSession session = handle(Flux.just(
toWebSocketMessage("{\"type\":\"connection_init\"}"),
TestWebSocketSession session = handle(Flux.just(toWebSocketMessage("{\"type\":\"connection_init\"}"),
toWebSocketMessage(BookTestUtils.BOOK_QUERY)));
StepVerifier.create(session.getOutput())
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
.consumeNextWith(message ->
assertThat(decode(message))
.hasSize(3)
.containsEntry("id", BookTestUtils.SUBSCRIPTION_ID)
.containsEntry("type", "next")
.extractingByKey("payload", as(map(String.class, Object.class)))
.extractingByKey("data", as(map(String.class, Object.class)))
.extractingByKey("bookById", as(map(String.class, Object.class)))
.containsEntry("name", "Nineteen Eighty-Four"))
.consumeNextWith(message -> assertMessageType(message, "complete"))
.verifyComplete();
.consumeNextWith((message) -> assertMessageType(message, "connection_ack"))
.consumeNextWith((message) -> assertThat(decode(message)).hasSize(3)
.containsEntry("id", BookTestUtils.SUBSCRIPTION_ID).containsEntry("type", "next")
.extractingByKey("payload", as(InstanceOfAssertFactories.map(String.class, Object.class)))
.extractingByKey("data", as(InstanceOfAssertFactories.map(String.class, Object.class)))
.extractingByKey("bookById", as(InstanceOfAssertFactories.map(String.class, Object.class)))
.containsEntry("name", "Nineteen Eighty-Four"))
.consumeNextWith((message) -> assertMessageType(message, "complete")).verifyComplete();
}
@Test
void subscription() {
TestWebSocketSession session = handle(Flux.just(
toWebSocketMessage("{\"type\":\"connection_init\"}"),
TestWebSocketSession session = handle(Flux.just(toWebSocketMessage("{\"type\":\"connection_init\"}"),
toWebSocketMessage(BookTestUtils.BOOK_SUBSCRIPTION)));
BiConsumer<WebSocketMessage, String> bookPayloadAssertion = (message, bookId) ->
assertThat(decode(message))
.hasSize(3)
.containsEntry("id", BookTestUtils.SUBSCRIPTION_ID)
.containsEntry("type", "next")
.extractingByKey("payload", as(map(String.class, Object.class)))
.extractingByKey("data", as(map(String.class, Object.class)))
.extractingByKey("bookSearch", as(map(String.class, Object.class)))
.containsEntry("id", bookId);
BiConsumer<WebSocketMessage, String> bookPayloadAssertion = (message, bookId) -> assertThat(decode(message))
.hasSize(3).containsEntry("id", BookTestUtils.SUBSCRIPTION_ID).containsEntry("type", "next")
.extractingByKey("payload", as(InstanceOfAssertFactories.map(String.class, Object.class)))
.extractingByKey("data", as(InstanceOfAssertFactories.map(String.class, Object.class)))
.extractingByKey("bookSearch", as(InstanceOfAssertFactories.map(String.class, Object.class)))
.containsEntry("id", bookId);
StepVerifier.create(session.getOutput())
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
.consumeNextWith(message -> bookPayloadAssertion.accept(message, "1"))
.consumeNextWith(message -> bookPayloadAssertion.accept(message, "5"))
.consumeNextWith(message -> assertMessageType(message, "complete"))
.verifyComplete();
.consumeNextWith((message) -> assertMessageType(message, "connection_ack"))
.consumeNextWith((message) -> bookPayloadAssertion.accept(message, "1"))
.consumeNextWith((message) -> bookPayloadAssertion.accept(message, "5"))
.consumeNextWith((message) -> assertMessageType(message, "complete")).verifyComplete();
}
@Test
void unauthorizedWithoutMessageType() {
TestWebSocketSession session = handle(Flux.just(
toWebSocketMessage("{\"type\":\"connection_init\"}"),
TestWebSocketSession session = handle(Flux.just(toWebSocketMessage("{\"type\":\"connection_init\"}"),
toWebSocketMessage("{\"id\":\"" + BookTestUtils.SUBSCRIPTION_ID + "\"}")));
StepVerifier.create(session.getOutput())
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
.verifyComplete();
.consumeNextWith((message) -> assertMessageType(message, "connection_ack")).verifyComplete();
StepVerifier.create(session.closeStatus())
.expectNext(new CloseStatus(4400, "Invalid message"))
StepVerifier.create(session.closeStatus()).expectNext(new CloseStatus(4400, "Invalid message"))
.verifyComplete();
}
@Test
void invalidMessageWithoutId() {
Flux<WebSocketMessage> input = Flux.just(
toWebSocketMessage("{\"type\":\"connection_init\"}"),
toWebSocketMessage("{\"type\":\"subscribe\"}")); // No message id
Flux<WebSocketMessage> input = Flux.just(toWebSocketMessage("{\"type\":\"connection_init\"}"),
toWebSocketMessage("{\"type\":\"subscribe\"}")); // No message id
TestWebSocketSession session = handle(input);
StepVerifier.create(session.getOutput())
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
.verifyComplete();
.consumeNextWith((message) -> assertMessageType(message, "connection_ack")).verifyComplete();
StepVerifier.create(session.closeStatus())
.expectNext(new CloseStatus(4400, "Invalid message"))
StepVerifier.create(session.closeStatus()).expectNext(new CloseStatus(4400, "Invalid message"))
.verifyComplete();
}
@@ -133,52 +117,48 @@ public class GraphQlWebSocketHandlerTests {
TestWebSocketSession session = handle(Flux.just(toWebSocketMessage(BookTestUtils.BOOK_SUBSCRIPTION)));
StepVerifier.create(session.getOutput()).verifyComplete();
StepVerifier.create(session.closeStatus())
.expectNext(new CloseStatus(4401, "Unauthorized"))
.verifyComplete();
StepVerifier.create(session.closeStatus()).expectNext(new CloseStatus(4401, "Unauthorized")).verifyComplete();
}
@Test
void tooManyConnectionInitRequests() {
TestWebSocketSession session = handle(Flux.just(
toWebSocketMessage("{\"type\":\"connection_init\"}"),
TestWebSocketSession session = handle(Flux.just(toWebSocketMessage("{\"type\":\"connection_init\"}"),
toWebSocketMessage("{\"type\":\"connection_init\"}")));
StepVerifier.create(session.getOutput())
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
.verifyComplete();
.consumeNextWith((message) -> assertMessageType(message, "connection_ack")).verifyComplete();
StepVerifier.create(session.closeStatus())
.expectNext(new CloseStatus(4429, "Too many initialisation requests"))
StepVerifier.create(session.closeStatus()).expectNext(new CloseStatus(4429, "Too many initialisation requests"))
.verifyComplete();
}
@Test
void connectionInitTimeout() {
GraphQlWebSocketHandler handler = new GraphQlWebSocketHandler(
BookTestUtils.initWebGraphQlHandler(), ServerCodecConfigurer.create(), Duration.ofMillis(50));
GraphQlWebSocketHandler handler = new GraphQlWebSocketHandler(BookTestUtils.initWebGraphQlHandler(),
ServerCodecConfigurer.create(), Duration.ofMillis(50));
TestWebSocketSession session = new TestWebSocketSession(Flux.empty());
handler.handle(session).block();
StepVerifier.create(session.closeStatus())
.expectNext(new CloseStatus(4408, "Connection initialisation timeout"))
.verifyComplete();
.expectNext(new CloseStatus(4408, "Connection initialisation timeout")).verifyComplete();
}
@Test
void subscriptionExists() {
TestWebSocketSession session = handle(Flux.just(
toWebSocketMessage("{\"type\":\"connection_init\"}"),
toWebSocketMessage(BookTestUtils.BOOK_SUBSCRIPTION),
toWebSocketMessage(BookTestUtils.BOOK_SUBSCRIPTION)), new ConsumeOneAndNeverCompleteInterceptor());
TestWebSocketSession session = handle(
Flux.just(toWebSocketMessage("{\"type\":\"connection_init\"}"),
toWebSocketMessage(BookTestUtils.BOOK_SUBSCRIPTION),
toWebSocketMessage(BookTestUtils.BOOK_SUBSCRIPTION)),
new ConsumeOneAndNeverCompleteInterceptor());
// Collect messages until session closed
List<Map<String, Object>> messages = new ArrayList<>();
session.getOutput().subscribe(message -> messages.add(decode(message)));
session.getOutput().subscribe((message) -> messages.add(decode(message)));
StepVerifier.create(session.closeStatus())
.expectNext(new CloseStatus(4409, "Subscriber for " + BookTestUtils.SUBSCRIPTION_ID + " already exists"))
.expectNext(
new CloseStatus(4409, "Subscriber for " + BookTestUtils.SUBSCRIPTION_ID + " already exists"))
.verifyComplete();
assertThat(messages.size()).isEqualTo(2);
@@ -192,27 +172,24 @@ public class GraphQlWebSocketHandlerTests {
input.tryEmitNext(toWebSocketMessage("{\"type\":\"connection_init\"}"));
input.tryEmitNext(toWebSocketMessage(BookTestUtils.BOOK_SUBSCRIPTION));
TestWebSocketSession session =
handle(input.asFlux(), new ConsumeOneAndNeverCompleteInterceptor());
TestWebSocketSession session = handle(input.asFlux(), new ConsumeOneAndNeverCompleteInterceptor());
String completeMessage = "{\"id\":\"" + BookTestUtils.SUBSCRIPTION_ID + "\",\"type\":\"complete\"}";
StepVerifier.create(session.getOutput())
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
.consumeNextWith(message -> assertMessageType(message, "next"))
.consumeNextWith((message) -> assertMessageType(message, "connection_ack"))
.consumeNextWith((message) -> assertMessageType(message, "next"))
.then(() -> input.tryEmitNext(toWebSocketMessage(completeMessage)))
.as("Second subscription with same id is possible only if the first was properly removed")
.then(() -> input.tryEmitNext(toWebSocketMessage(BookTestUtils.BOOK_SUBSCRIPTION)))
.consumeNextWith(message -> assertMessageType(message, "next"))
.consumeNextWith((message) -> assertMessageType(message, "next"))
.then(() -> input.tryEmitNext(toWebSocketMessage(completeMessage)))
.verifyTimeout(Duration.ofMillis(500));
}
private TestWebSocketSession handle(Flux<WebSocketMessage> input, WebInterceptor... interceptors) {
GraphQlWebSocketHandler handler = new GraphQlWebSocketHandler(
BookTestUtils.initWebGraphQlHandler(interceptors),
ServerCodecConfigurer.create(),
Duration.ofSeconds(60));
GraphQlWebSocketHandler handler = new GraphQlWebSocketHandler(BookTestUtils.initWebGraphQlHandler(interceptors),
ServerCodecConfigurer.create(), Duration.ofSeconds(60));
TestWebSocketSession session = new TestWebSocketSession(input);
handler.handle(session).block();
@@ -224,10 +201,9 @@ public class GraphQlWebSocketHandlerTests {
return new WebSocketMessage(WebSocketMessage.Type.TEXT, buffer);
}
@SuppressWarnings({"unchecked", "ConstantConditions"})
@SuppressWarnings({ "unchecked", "ConstantConditions" })
private Map<String, Object> decode(WebSocketMessage message) {
return (Map<String, Object>) decoder.decode(
DataBufferUtils.retain(message.getPayload()),
return (Map<String, Object>) decoder.decode(DataBufferUtils.retain(message.getPayload()),
GraphQlWebSocketHandler.MAP_RESOLVABLE_TYPE, null, Collections.emptyMap());
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web.webflux;
import java.net.URI;
@@ -30,8 +31,8 @@ import org.springframework.web.reactive.socket.WebSocketMessage;
import org.springframework.web.reactive.socket.adapter.AbstractWebSocketSession;
/**
* {@link org.springframework.web.reactive.socket.WebSocketSession} that is given
* an input Flux of messages and exposes a Flux of published output messages.
* {@link org.springframework.web.reactive.socket.WebSocketSession} that is given an input
* Flux of messages and exposes a Flux of published output messages.
*/
class TestWebSocketSession extends AbstractWebSocketSession<Object> {
@@ -41,19 +42,16 @@ class TestWebSocketSession extends AbstractWebSocketSession<Object> {
private final Sinks.One<CloseStatus> closeStatusSink = Sinks.one();
public TestWebSocketSession(Flux<WebSocketMessage> input) {
TestWebSocketSession(Flux<WebSocketMessage> input) {
this("1", URI.create("https://example.org/graphql"), input);
}
public TestWebSocketSession(String id, URI uri, Flux<WebSocketMessage> input) {
super(new Object(), id,
new HandshakeInfo(uri, new HttpHeaders(), Mono.empty(), null),
TestWebSocketSession(String id, URI uri, Flux<WebSocketMessage> input) {
super(new Object(), id, new HandshakeInfo(uri, new HttpHeaders(), Mono.empty(), null),
DefaultDataBufferFactory.sharedInstance);
this.input = input;
}
@Override
public Flux<WebSocketMessage> receive() {
return this.input;
@@ -65,7 +63,7 @@ class TestWebSocketSession extends AbstractWebSocketSession<Object> {
return Mono.empty();
}
public Flux<WebSocketMessage> getOutput() {
Flux<WebSocketMessage> getOutput() {
return this.output;
}
@@ -84,4 +82,5 @@ class TestWebSocketSession extends AbstractWebSocketSession<Object> {
public Mono<CloseStatus> closeStatus() {
return this.closeStatusSink.asMono();
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web.webmvc;
import java.io.ByteArrayInputStream;
@@ -25,6 +26,7 @@ import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.jupiter.api.Test;
import reactor.test.StepVerifier;
@@ -41,7 +43,6 @@ import org.springframework.web.socket.WebSocketMessage;
import static org.assertj.core.api.Assertions.as;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.InstanceOfAssertFactories.map;
/**
* Unit tests for {@link GraphQlWebSocketHandler}.
@@ -50,81 +51,69 @@ public class GraphQlWebSocketHandlerTests {
private static final HttpMessageConverter<?> converter = new MappingJackson2HttpMessageConverter();
private final TestWebSocketSession session = new TestWebSocketSession();
private final GraphQlWebSocketHandler handler = initWebSocketHandler();
@Test
void query() throws Exception {
handle(this.handler,
new TextMessage("{\"type\":\"connection_init\"}"),
handle(this.handler, new TextMessage("{\"type\":\"connection_init\"}"),
new TextMessage(BookTestUtils.BOOK_QUERY));
StepVerifier.create(this.session.getOutput())
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
.consumeNextWith(message ->
assertThat(decode(message))
.hasSize(3)
.containsEntry("id", BookTestUtils.SUBSCRIPTION_ID)
.containsEntry("type", "next")
.extractingByKey("payload", as(map(String.class, Object.class)))
.extractingByKey("data", as(map(String.class, Object.class)))
.extractingByKey("bookById", as(map(String.class, Object.class)))
.containsEntry("name", "Nineteen Eighty-Four"))
.consumeNextWith(message -> assertMessageType(message, "complete"))
.then(this.session::close) // Complete output Flux
.consumeNextWith((message) -> assertMessageType(message, "connection_ack"))
.consumeNextWith((message) -> assertThat(decode(message)).hasSize(3)
.containsEntry("id", BookTestUtils.SUBSCRIPTION_ID).containsEntry("type", "next")
.extractingByKey("payload", as(InstanceOfAssertFactories.map(String.class, Object.class)))
.extractingByKey("data", as(InstanceOfAssertFactories.map(String.class, Object.class)))
.extractingByKey("bookById", as(InstanceOfAssertFactories.map(String.class, Object.class)))
.containsEntry("name", "Nineteen Eighty-Four"))
.consumeNextWith((message) -> assertMessageType(message, "complete")).then(this.session::close) // Complete
// output
// Flux
.verifyComplete();
}
@Test
void subscription() throws Exception {
handle(this.handler,
new TextMessage("{\"type\":\"connection_init\"}"),
handle(this.handler, new TextMessage("{\"type\":\"connection_init\"}"),
new TextMessage(BookTestUtils.BOOK_SUBSCRIPTION));
BiConsumer<WebSocketMessage<?>, String> bookPayloadAssertion = (message, bookId) ->
assertThat(decode(message))
.hasSize(3)
.containsEntry("id", BookTestUtils.SUBSCRIPTION_ID)
.containsEntry("type", "next")
.extractingByKey("payload", as(map(String.class, Object.class)))
.extractingByKey("data", as(map(String.class, Object.class)))
.extractingByKey("bookSearch", as(map(String.class, Object.class)))
.containsEntry("id", bookId);
BiConsumer<WebSocketMessage<?>, String> bookPayloadAssertion = (message, bookId) -> assertThat(decode(message))
.hasSize(3).containsEntry("id", BookTestUtils.SUBSCRIPTION_ID).containsEntry("type", "next")
.extractingByKey("payload", as(InstanceOfAssertFactories.map(String.class, Object.class)))
.extractingByKey("data", as(InstanceOfAssertFactories.map(String.class, Object.class)))
.extractingByKey("bookSearch", as(InstanceOfAssertFactories.map(String.class, Object.class)))
.containsEntry("id", bookId);
StepVerifier.create(this.session.getOutput())
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
.consumeNextWith(message -> bookPayloadAssertion.accept(message, "1"))
.consumeNextWith(message -> bookPayloadAssertion.accept(message, "5"))
.consumeNextWith(message -> assertMessageType(message, "complete"))
.then(this.session::close) // Complete output Flux
.consumeNextWith((message) -> assertMessageType(message, "connection_ack"))
.consumeNextWith((message) -> bookPayloadAssertion.accept(message, "1"))
.consumeNextWith((message) -> bookPayloadAssertion.accept(message, "5"))
.consumeNextWith((message) -> assertMessageType(message, "complete")).then(this.session::close)
// Complete output Flux
.verifyComplete();
}
@Test
void unauthorizedWithoutMessageType() throws Exception {
handle(this.handler,
new TextMessage("{\"type\":\"connection_init\"}"),
new TextMessage("{\"id\":\"" + BookTestUtils.SUBSCRIPTION_ID + "\"}")); // No message type
handle(this.handler, new TextMessage("{\"type\":\"connection_init\"}"),
new TextMessage("{\"id\":\"" + BookTestUtils.SUBSCRIPTION_ID + "\"}"));
// No message type
StepVerifier.create(this.session.getOutput())
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
.verifyComplete();
.consumeNextWith((message) -> assertMessageType(message, "connection_ack")).verifyComplete();
assertThat(this.session.getCloseStatus()).isEqualTo(new CloseStatus(4400, "Invalid message"));
}
@Test
void invalidMessageWithoutId() throws Exception {
handle(this.handler,
new TextMessage("{\"type\":\"connection_init\"}"),
new TextMessage("{\"type\":\"subscribe\"}")); // No message id
handle(this.handler, new TextMessage("{\"type\":\"connection_init\"}"),
new TextMessage("{\"type\":\"subscribe\"}")); // No message id
StepVerifier.create(this.session.getOutput())
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
.verifyComplete();
.consumeNextWith((message) -> assertMessageType(message, "connection_ack")).verifyComplete();
assertThat(this.session.getCloseStatus()).isEqualTo(new CloseStatus(4400, "Invalid message"));
}
@@ -139,43 +128,39 @@ public class GraphQlWebSocketHandlerTests {
@Test
void tooManyConnectionInitRequests() throws Exception {
handle(this.handler,
new TextMessage("{\"type\":\"connection_init\"}"),
handle(this.handler, new TextMessage("{\"type\":\"connection_init\"}"),
new TextMessage("{\"type\":\"connection_init\"}"));
StepVerifier.create(this.session.getOutput())
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
.verifyComplete();
.consumeNextWith((message) -> assertMessageType(message, "connection_ack")).verifyComplete();
assertThat(this.session.getCloseStatus())
.isEqualTo(new CloseStatus(4429, "Too many initialisation requests"));
assertThat(this.session.getCloseStatus()).isEqualTo(new CloseStatus(4429, "Too many initialisation requests"));
}
@Test
void connectionInitTimeout() {
GraphQlWebSocketHandler handler = new GraphQlWebSocketHandler(
BookTestUtils.initWebGraphQlHandler(), converter, Duration.ofMillis(50));
GraphQlWebSocketHandler handler = new GraphQlWebSocketHandler(BookTestUtils.initWebGraphQlHandler(), converter,
Duration.ofMillis(50));
handler.afterConnectionEstablished(this.session);
StepVerifier.create(this.session.closeStatus())
.expectNext(new CloseStatus(4408, "Connection initialisation timeout"))
.verifyComplete();
.expectNext(new CloseStatus(4408, "Connection initialisation timeout")).verifyComplete();
}
@Test
void subscriptionExists() throws Exception {
handle(initWebSocketHandler(new ConsumeOneAndNeverCompleteInterceptor()),
new TextMessage("{\"type\":\"connection_init\"}"),
new TextMessage(BookTestUtils.BOOK_SUBSCRIPTION),
new TextMessage("{\"type\":\"connection_init\"}"), new TextMessage(BookTestUtils.BOOK_SUBSCRIPTION),
new TextMessage(BookTestUtils.BOOK_SUBSCRIPTION));
// Collect messages until session closed
List<Map<String, Object>> messages = new ArrayList<>();
session.getOutput().subscribe(message -> messages.add(decode(message)));
this.session.getOutput().subscribe((message) -> messages.add(decode(message)));
StepVerifier.create(this.session.closeStatus())
.expectNext(new CloseStatus(4409, "Subscriber for " + BookTestUtils.SUBSCRIPTION_ID + " already exists"))
.expectNext(
new CloseStatus(4409, "Subscriber for " + BookTestUtils.SUBSCRIPTION_ID + " already exists"))
.verifyComplete();
assertThat(messages.size()).isEqualTo(2);
@@ -185,15 +170,13 @@ public class GraphQlWebSocketHandlerTests {
@Test
void clientCompletion() throws Exception {
GraphQlWebSocketHandler handler =
initWebSocketHandler(new ConsumeOneAndNeverCompleteInterceptor());
GraphQlWebSocketHandler handler = initWebSocketHandler(new ConsumeOneAndNeverCompleteInterceptor());
handle(handler,
new TextMessage("{\"type\":\"connection_init\"}"),
handle(handler, new TextMessage("{\"type\":\"connection_init\"}"),
new TextMessage(BookTestUtils.BOOK_SUBSCRIPTION));
String completeMessage = "{\"id\":\"" + BookTestUtils.SUBSCRIPTION_ID + "\",\"type\":\"complete\"}";
Consumer<String> messageSender = body -> {
Consumer<String> messageSender = (body) -> {
try {
handler.handleTextMessage(this.session, new TextMessage(body));
}
@@ -203,14 +186,13 @@ public class GraphQlWebSocketHandlerTests {
};
StepVerifier.create(this.session.getOutput())
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
.consumeNextWith(message -> assertMessageType(message, "next"))
.consumeNextWith((message) -> assertMessageType(message, "connection_ack"))
.consumeNextWith((message) -> assertMessageType(message, "next"))
.then(() -> messageSender.accept(completeMessage))
.as("Second subscription with same id is possible only if the first was properly removed")
.then(() -> messageSender.accept(BookTestUtils.BOOK_SUBSCRIPTION))
.consumeNextWith(message -> assertMessageType(message, "next"))
.then(() -> messageSender.accept(completeMessage))
.verifyTimeout(Duration.ofMillis(500));
.consumeNextWith((message) -> assertMessageType(message, "next"))
.then(() -> messageSender.accept(completeMessage)).verifyTimeout(Duration.ofMillis(500));
}
private void handle(GraphQlWebSocketHandler handler, TextMessage... textMessages) throws Exception {
@@ -222,8 +204,8 @@ public class GraphQlWebSocketHandlerTests {
private GraphQlWebSocketHandler initWebSocketHandler(WebInterceptor... interceptors) {
try {
return new GraphQlWebSocketHandler(
BookTestUtils.initWebGraphQlHandler(interceptors), converter, Duration.ofSeconds(60));
return new GraphQlWebSocketHandler(BookTestUtils.initWebGraphQlHandler(interceptors), converter,
Duration.ofSeconds(60));
}
catch (Exception ex) {
throw new IllegalStateException(ex);
@@ -254,7 +236,6 @@ public class GraphQlWebSocketHandlerTests {
}
}
private static class HttpInputMessageAdapter extends ByteArrayInputStream implements HttpInputMessage {
HttpInputMessageAdapter(TextMessage message) {
@@ -270,5 +251,7 @@ public class GraphQlWebSocketHandlerTests {
public HttpHeaders getHeaders() {
return HttpHeaders.EMPTY;
}
}
}

View File

@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.web.webmvc;
import java.net.InetSocketAddress;
@@ -35,8 +36,8 @@ import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;
/**
* WebSocketSession that saves sent messages and exposes them as a Flux which
* makes assertions comparable to the same for WebFlux.
* WebSocketSession that saves sent messages and exposes them as a Flux which makes
* assertions comparable to the same for WebFlux.
*/
public class TestWebSocketSession implements WebSocketSession {
@@ -52,10 +53,9 @@ public class TestWebSocketSession implements WebSocketSession {
private boolean closed;
@Override
public String getId() {
return "1";
return "1";
}
@Override