Apply Spring JavaFormat to Spring GraphQL
See gh-54
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
|
||||
@@ -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() : "");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user