Support for subscriptions with WebMvc and spring-websocket
This commit is contained in:
@@ -33,8 +33,9 @@ dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter'
|
||||
|
||||
compileOnly 'org.springframework:spring-webflux'
|
||||
compileOnly 'javax.servlet:javax.servlet-api'
|
||||
compileOnly 'org.springframework:spring-webmvc'
|
||||
compileOnly 'org.springframework:spring-websocket'
|
||||
compileOnly 'javax.servlet:javax.servlet-api'
|
||||
|
||||
compileOnly 'io.micrometer:micrometer-core'
|
||||
compileOnly 'org.springframework.boot:spring-boot-actuator-autoconfigure'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright 2020-2020 the original author or authors.
|
||||
* 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.
|
||||
@@ -16,6 +16,7 @@
|
||||
package org.springframework.graphql.boot;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
import graphql.GraphQL;
|
||||
|
||||
@@ -24,15 +25,22 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.graphql.webmvc.GraphQLHttpHandler;
|
||||
import org.springframework.graphql.webmvc.GraphQLWebSocketHandler;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
import org.springframework.web.servlet.function.RouterFunction;
|
||||
import org.springframework.web.servlet.function.RouterFunctions;
|
||||
import org.springframework.web.servlet.function.ServerResponse;
|
||||
import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping;
|
||||
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
|
||||
import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler;
|
||||
|
||||
import static org.springframework.web.servlet.function.RequestPredicates.accept;
|
||||
import static org.springframework.web.servlet.function.RequestPredicates.contentType;
|
||||
@@ -50,6 +58,22 @@ public class WebMvcGraphQLAutoConfiguration {
|
||||
return new GraphQLHttpHandler(graphQLBuilder.build(), Collections.emptyList());
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public GraphQLWebSocketHandler graphQLWebSocketHandler(
|
||||
GraphQL.Builder graphQLBuilder, GraphQLProperties properties, HttpMessageConverters converters) {
|
||||
|
||||
HttpMessageConverter<?> converter = converters.getConverters().stream()
|
||||
.filter(candidate -> candidate.canRead(Map.class, MediaType.APPLICATION_JSON))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("No JSON converter"));
|
||||
|
||||
return new GraphQLWebSocketHandler(
|
||||
graphQLBuilder.build(), Collections.emptyList(),
|
||||
converter, properties.getConnectionInitTimeoutDuration()
|
||||
);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> graphQLQueryEndpoint(
|
||||
ResourceLoader resourceLoader, GraphQLHttpHandler handler, GraphQLProperties properties) {
|
||||
@@ -63,4 +87,16 @@ public class WebMvcGraphQLAutoConfiguration {
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public HandlerMapping graphQLWebSocketEndpoint(GraphQLWebSocketHandler handler, GraphQLProperties properties) {
|
||||
WebSocketHttpRequestHandler httpRequestHandler =
|
||||
new WebSocketHttpRequestHandler(handler, new DefaultHandshakeHandler());
|
||||
|
||||
String path = properties.getWebSocketPath();
|
||||
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
|
||||
mapping.setUrlMap(Collections.singletonMap(path, httpRequestHandler));
|
||||
mapping.setOrder(-1); // Ahead of annotated controllers
|
||||
return mapping;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -29,11 +29,14 @@ dependencies {
|
||||
compileOnly "javax.annotation:javax.annotation-api:1.3.2"
|
||||
compileOnly 'org.springframework:spring-webflux'
|
||||
compileOnly 'org.springframework:spring-webmvc'
|
||||
compileOnly 'org.springframework:spring-websocket'
|
||||
compileOnly 'javax.servlet:javax.servlet-api:4.0.1'
|
||||
|
||||
testImplementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||
testImplementation 'org.springframework:spring-webflux'
|
||||
testImplementation 'org.springframework:spring-webmvc'
|
||||
testImplementation 'org.springframework:spring-websocket'
|
||||
testImplementation 'org.springframework:spring-test'
|
||||
testImplementation 'io.projectreactor:reactor-test'
|
||||
testImplementation 'javax.servlet:javax.servlet-api:4.0.1'
|
||||
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
/*
|
||||
* Copyright 2002-2021 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.graphql.webmvc;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import graphql.ErrorType;
|
||||
import graphql.ExecutionResult;
|
||||
import graphql.GraphQL;
|
||||
import graphql.GraphqlErrorBuilder;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import org.reactivestreams.Publisher;
|
||||
import org.reactivestreams.Subscription;
|
||||
import reactor.core.publisher.BaseSubscriber;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Scheduler;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
import org.springframework.graphql.WebInterceptor;
|
||||
import org.springframework.graphql.WebInterceptorExecutionChain;
|
||||
import org.springframework.graphql.WebOutput;
|
||||
import org.springframework.graphql.WebSocketMessageInput;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpInputMessage;
|
||||
import org.springframework.http.HttpOutputMessage;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.SubProtocolCapable;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.handler.ExceptionWebSocketHandlerDecorator;
|
||||
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}.
|
||||
*/
|
||||
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 final WebInterceptorExecutionChain executionChain;
|
||||
|
||||
private final Duration initTimeoutDuration;
|
||||
|
||||
private final HttpMessageConverter<?> converter;
|
||||
|
||||
private final Map<String, SessionState> sessionInfoMap = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
/**
|
||||
* Create a new instance.
|
||||
* @param graphQL the GraphQL instance to use for query execution
|
||||
* @param interceptors 0 or more interceptors to customize input and output
|
||||
* @param converter for JSON encoding and decoding
|
||||
* @param initTimeoutDuration the time within which the {@code CONNECTION_INIT}
|
||||
* type message must be received.
|
||||
*/
|
||||
public GraphQLWebSocketHandler(GraphQL graphQL, List<WebInterceptor> interceptors,
|
||||
HttpMessageConverter<?> converter, Duration initTimeoutDuration) {
|
||||
|
||||
Assert.notNull(converter, "HttpMessageConverter for JSON is required");
|
||||
this.executionChain = new WebInterceptorExecutionChain(graphQL, interceptors);
|
||||
this.initTimeoutDuration = initTimeoutDuration;
|
||||
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.");
|
||||
}
|
||||
GraphQLStatus.closeSession(session, GraphQLStatus.INVALID_MESSAGE_STATUS);
|
||||
return;
|
||||
}
|
||||
|
||||
SessionState sessionState = new SessionState(session.getId());
|
||||
this.sessionInfoMap.put(session.getId(), sessionState);
|
||||
|
||||
Mono.delay(this.initTimeoutDuration)
|
||||
.then(Mono.fromRunnable(() -> {
|
||||
if (!sessionState.isConnectionInitProcessed()) {
|
||||
GraphQLStatus.closeSession(session, GraphQLStatus.INIT_TIMEOUT_STATUS);
|
||||
}
|
||||
}))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
||||
Map<String, Object> map = decode(message, Map.class);
|
||||
String id = (String) map.get("id");
|
||||
MessageType messageType = MessageType.resolve((String) map.get("type"));
|
||||
if (messageType == null) {
|
||||
GraphQLStatus.closeSession(session, GraphQLStatus.INVALID_MESSAGE_STATUS);
|
||||
return;
|
||||
}
|
||||
SessionState sessionState = getSessionInfo(session);
|
||||
switch (messageType) {
|
||||
case SUBSCRIBE:
|
||||
if (!sessionState.isConnectionInitProcessed()) {
|
||||
GraphQLStatus.closeSession(session, GraphQLStatus.UNAUTHORIZED_STATUS);
|
||||
return;
|
||||
}
|
||||
if (id == null) {
|
||||
GraphQLStatus.closeSession(session, GraphQLStatus.INVALID_MESSAGE_STATUS);
|
||||
return;
|
||||
}
|
||||
URI uri = session.getUri();
|
||||
HttpHeaders headers = session.getHandshakeHeaders();
|
||||
WebSocketMessageInput input = new WebSocketMessageInput(uri, headers, id, getPayload(map));
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Executing: " + input);
|
||||
}
|
||||
this.executionChain.execute(input)
|
||||
.flatMapMany(output -> handleWebOutput(session, input.requestId(), 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(session, null, MessageType.CONNECTION_ACK, null);
|
||||
session.sendMessage(outputMessage);
|
||||
return;
|
||||
default:
|
||||
GraphQLStatus.closeSession(session, GraphQLStatus.INVALID_MESSAGE_STATUS);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T decode(TextMessage message, Class<T> targetClass) throws IOException {
|
||||
return ((HttpMessageConverter<T>) this.converter).read(targetClass, new HttpInputMessageAdapter(message));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> getPayload(Map<String, Object> message) {
|
||||
Map<String, Object> payload = (Map<String, Object>) message.get("payload");
|
||||
Assert.notNull(payload, "No \"payload\" in message: " + message);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private SessionState getSessionInfo(WebSocketSession session) {
|
||||
SessionState info = this.sessionInfoMap.get(session.getId());
|
||||
Assert.notNull(info, "No SessionInfo for " + session);
|
||||
return info;
|
||||
}
|
||||
|
||||
@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() : "") + ".");
|
||||
}
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Query
|
||||
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");
|
||||
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(session, id, MessageType.ERROR, errorMap));
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> TextMessage encode(
|
||||
WebSocketSession session, @Nullable String id, MessageType messageType, @Nullable Object payload) {
|
||||
|
||||
Map<String, Object> payloadMap = new HashMap<>(3);
|
||||
payloadMap.put("type", messageType.getType());
|
||||
if (id != null) {
|
||||
payloadMap.put("id", id);
|
||||
}
|
||||
if (payload != null) {
|
||||
payloadMap.put("payload", payload);
|
||||
}
|
||||
try {
|
||||
HttpOutputMessageAdapter outputMessage = new HttpOutputMessageAdapter();
|
||||
((HttpMessageConverter<T>) this.converter).write((T) payloadMap, null, outputMessage);
|
||||
return new TextMessage(outputMessage.toByteArray());
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new IllegalStateException("Failed to write " + payloadMap + " as JSON", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleTransportError(WebSocketSession session, Throwable exception) {
|
||||
SessionState info = this.sessionInfoMap.remove(session.getId());
|
||||
if (info != null) {
|
||||
info.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) {
|
||||
SessionState info = this.sessionInfoMap.remove(session.getId());
|
||||
if (info != null) {
|
||||
info.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsPartialMessages() {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
private enum MessageType {
|
||||
|
||||
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);
|
||||
|
||||
static {
|
||||
for (MessageType messageType : MessageType.values()) {
|
||||
messageTypes.put(messageType.getType(), messageType);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private final String type;
|
||||
|
||||
MessageType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return this.type;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static MessageType resolve(@Nullable String type) {
|
||||
return (type != null ? messageTypes.get(type) : null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static class GraphQLStatus {
|
||||
|
||||
private static final CloseStatus INVALID_MESSAGE_STATUS = new CloseStatus(4400, "Invalid message");
|
||||
|
||||
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 TOO_MANY_INIT_REQUESTS_STATUS = new CloseStatus(4429, "Too many initialisation requests");
|
||||
|
||||
|
||||
public static void closeSession(WebSocketSession session, CloseStatus status) {
|
||||
try {
|
||||
session.close(status);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Error while closing session with status: " + status, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static class HttpInputMessageAdapter extends ByteArrayInputStream implements HttpInputMessage {
|
||||
|
||||
HttpInputMessageAdapter(TextMessage message) {
|
||||
super(message.asBytes());
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getBody() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders getHeaders() {
|
||||
return HttpHeaders.EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static class HttpOutputMessageAdapter extends ByteArrayOutputStream implements HttpOutputMessage {
|
||||
|
||||
private static final HttpHeaders noOpHeaders = new HttpHeaders();
|
||||
|
||||
@Override
|
||||
public OutputStream getBody() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders getHeaders() {
|
||||
return noOpHeaders;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static class SessionState {
|
||||
|
||||
private boolean connectionInitProcessed;
|
||||
|
||||
private final Map<String, Subscription> subscriptions = new ConcurrentHashMap<>();
|
||||
|
||||
private final Scheduler scheduler;
|
||||
|
||||
public SessionState(String sessionId) {
|
||||
this.scheduler = Schedulers.newSingle("GraphQL-WsSession-" + sessionId);
|
||||
}
|
||||
|
||||
public boolean isConnectionInitProcessed() {
|
||||
return this.connectionInitProcessed;
|
||||
}
|
||||
|
||||
public synchronized boolean setConnectionInitProcessed() {
|
||||
boolean previousValue = this.connectionInitProcessed;
|
||||
this.connectionInitProcessed = true;
|
||||
return previousValue;
|
||||
}
|
||||
|
||||
public Map<String, Subscription> getSubscriptions() {
|
||||
return this.subscriptions;
|
||||
}
|
||||
|
||||
public void dispose() {
|
||||
for (Map.Entry<String, Subscription> entry : this.subscriptions.entrySet()) {
|
||||
try {
|
||||
entry.getValue().cancel();
|
||||
}
|
||||
catch (Throwable ex) {
|
||||
// Ignore and keep on
|
||||
}
|
||||
}
|
||||
this.subscriptions.clear();
|
||||
this.scheduler.dispose();
|
||||
}
|
||||
|
||||
public Scheduler getScheduler() {
|
||||
return this.scheduler;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private class SendMessageSubscriber extends BaseSubscriber<TextMessage> {
|
||||
|
||||
private final String subscriptionId;
|
||||
|
||||
private final WebSocketSession session;
|
||||
|
||||
private final SessionState sessionState;
|
||||
|
||||
public SendMessageSubscriber(String subscriptionId, WebSocketSession session, SessionState sessionState) {
|
||||
this.subscriptionId = subscriptionId;
|
||||
this.session = session;
|
||||
this.sessionState = sessionState;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnSubscribe(Subscription subscription) {
|
||||
subscription.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void hookOnNext(TextMessage nextMessage) {
|
||||
try {
|
||||
this.session.sendMessage(nextMessage);
|
||||
request(1);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.session, ex, logger);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hookOnError(Throwable ex) {
|
||||
ExceptionWebSocketHandlerDecorator.tryCloseWithError(this.session, ex, logger);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hookOnComplete() {
|
||||
this.sessionState.getSubscriptions().remove(this.subscriptionId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static class SubscriptionExistsException extends RuntimeException {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
/*
|
||||
* Copyright 2002-2021 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.graphql.webmvc;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import graphql.GraphQL;
|
||||
import graphql.schema.GraphQLSchema;
|
||||
import graphql.schema.idl.RuntimeWiring;
|
||||
import graphql.schema.idl.SchemaGenerator;
|
||||
import graphql.schema.idl.SchemaParser;
|
||||
import graphql.schema.idl.TypeDefinitionRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import org.springframework.graphql.ConsumeOneAndNeverCompleteInterceptor;
|
||||
import org.springframework.graphql.GraphQLDataFetchers;
|
||||
import org.springframework.graphql.WebInterceptor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpInputMessage;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.ResourceUtils;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketMessage;
|
||||
|
||||
import static graphql.schema.idl.TypeRuntimeWiring.newTypeWiring;
|
||||
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}.
|
||||
*/
|
||||
public class GraphQLWebSocketHandlerTests {
|
||||
|
||||
private static final String SUBSCRIPTION_ID = "123";
|
||||
|
||||
private static final String BOOK_SEARCH_QUERY = "{" +
|
||||
"\"id\":\"" + SUBSCRIPTION_ID + "\"," +
|
||||
"\"type\":\"subscribe\"," +
|
||||
"\"payload\":{\"query\": \"" +
|
||||
" subscription TestSubscription {" +
|
||||
" bookSearch(minPages: 200) {" +
|
||||
" id" +
|
||||
" name" +
|
||||
" pageCount" +
|
||||
" author" +
|
||||
" }}\"}" +
|
||||
"}";
|
||||
|
||||
private static final HttpMessageConverter<?> converter = new MappingJackson2HttpMessageConverter();
|
||||
|
||||
|
||||
private final TestWebSocketSession session = new TestWebSocketSession();
|
||||
|
||||
private final GraphQLWebSocketHandler handler =
|
||||
initWebSocketHandler(Collections.emptyList(), Duration.ofSeconds(60));
|
||||
|
||||
|
||||
@Test
|
||||
void query() throws Exception {
|
||||
String bookQuery = "{" +
|
||||
"\"id\":\"" + SUBSCRIPTION_ID + "\"," +
|
||||
"\"type\":\"subscribe\"," +
|
||||
"\"payload\":{\"query\": \"" +
|
||||
" query TestQuery {" +
|
||||
" bookById(id: \\\"book-1\\\"){ " +
|
||||
" id" +
|
||||
" name" +
|
||||
" pageCount" +
|
||||
" author" +
|
||||
" }}\"}" +
|
||||
"}";
|
||||
|
||||
this.handler.afterConnectionEstablished(session);
|
||||
this.handler.handleTextMessage(session, new TextMessage("{\"type\":\"connection_init\"}"));
|
||||
this.handler.handleTextMessage(session, new TextMessage(bookQuery));
|
||||
|
||||
StepVerifier.create(session.getOutput())
|
||||
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
|
||||
.consumeNextWith(message ->
|
||||
assertThat(decode(message))
|
||||
.hasSize(3)
|
||||
.containsEntry("id", 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", "GraphQL for beginners"))
|
||||
.consumeNextWith(message -> assertMessageType(message, "complete"))
|
||||
.then(session::close) // Complete output Flux
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscription() throws Exception {
|
||||
this.handler.afterConnectionEstablished(session);
|
||||
this.handler.handleTextMessage(session, new TextMessage("{\"type\":\"connection_init\"}"));
|
||||
this.handler.handleTextMessage(session, new TextMessage(BOOK_SEARCH_QUERY));
|
||||
|
||||
BiConsumer<WebSocketMessage<?>, String> bookPayloadAssertion = (message, bookId) ->
|
||||
assertThat(decode(message))
|
||||
.hasSize(3)
|
||||
.containsEntry("id", 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);
|
||||
|
||||
StepVerifier.create(session.getOutput())
|
||||
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
|
||||
.consumeNextWith(message -> bookPayloadAssertion.accept(message, "book-2"))
|
||||
.consumeNextWith(message -> bookPayloadAssertion.accept(message, "book-3"))
|
||||
.consumeNextWith(message -> bookPayloadAssertion.accept(message, "book-3"))
|
||||
.consumeNextWith(message -> assertMessageType(message, "complete"))
|
||||
.then(session::close) // Complete output Flux
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unauthorizedWithoutMessageType() throws Exception {
|
||||
this.handler.afterConnectionEstablished(session);
|
||||
this.handler.handleTextMessage(session, new TextMessage("{\"type\":\"connection_init\"}"));
|
||||
this.handler.handleTextMessage(session, new TextMessage("{\"id\":\"" + SUBSCRIPTION_ID + "\"}")); // No message type
|
||||
|
||||
StepVerifier.create(session.getOutput())
|
||||
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
|
||||
.verifyComplete();
|
||||
|
||||
assertThat(session.getCloseStatus()).isEqualTo(new CloseStatus(4400, "Invalid message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidMessageWithoutId() throws Exception {
|
||||
this.handler.afterConnectionEstablished(session);
|
||||
this.handler.handleTextMessage(session, new TextMessage("{\"type\":\"connection_init\"}"));
|
||||
this.handler.handleTextMessage(session, new TextMessage("{\"type\":\"subscribe\"}")); // No message id
|
||||
|
||||
StepVerifier.create(session.getOutput())
|
||||
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
|
||||
.verifyComplete();
|
||||
|
||||
assertThat(session.getCloseStatus()).isEqualTo(new CloseStatus(4400, "Invalid message"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unauthorizedWithoutConnectionInit() throws Exception {
|
||||
this.handler.afterConnectionEstablished(session);
|
||||
this.handler.handleTextMessage(session, new TextMessage(BOOK_SEARCH_QUERY));
|
||||
|
||||
StepVerifier.create(session.getOutput()).verifyComplete();
|
||||
assertThat(session.getCloseStatus()).isEqualTo(new CloseStatus(4401, "Unauthorized"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tooManyConnectionInitRequests() throws Exception {
|
||||
this.handler.afterConnectionEstablished(session);
|
||||
this.handler.handleTextMessage(session, new TextMessage("{\"type\":\"connection_init\"}"));
|
||||
this.handler.handleTextMessage(session, new TextMessage("{\"type\":\"connection_init\"}"));
|
||||
|
||||
StepVerifier.create(session.getOutput())
|
||||
.consumeNextWith(message -> assertMessageType(message, "connection_ack"))
|
||||
.verifyComplete();
|
||||
|
||||
assertThat(session.getCloseStatus())
|
||||
.isEqualTo(new CloseStatus(4429, "Too many initialisation requests"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void connectionInitTimeout() {
|
||||
GraphQLWebSocketHandler handler = initWebSocketHandler(Collections.emptyList(), Duration.ofMillis(50));
|
||||
handler.afterConnectionEstablished(session);
|
||||
|
||||
StepVerifier.create(session.closeStatus())
|
||||
.expectNext(new CloseStatus(4408, "Connection initialisation timeout"))
|
||||
.verifyComplete();
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscriptionExists() throws Exception {
|
||||
GraphQLWebSocketHandler handler = initWebSocketHandler(
|
||||
Collections.singletonList(new ConsumeOneAndNeverCompleteInterceptor()), null);
|
||||
|
||||
handler.afterConnectionEstablished(session);
|
||||
handler.handleTextMessage(session, new TextMessage("{\"type\":\"connection_init\"}"));
|
||||
handler.handleTextMessage(session, new TextMessage(BOOK_SEARCH_QUERY));
|
||||
handler.handleTextMessage(session, new TextMessage(BOOK_SEARCH_QUERY));
|
||||
|
||||
// Collect messages until session closed
|
||||
List<Map<String, Object>> messages = new ArrayList<>();
|
||||
session.getOutput().subscribe(message -> messages.add(decode(message)));
|
||||
|
||||
StepVerifier.create(session.closeStatus())
|
||||
.expectNext(new CloseStatus(4409, "Subscriber for " + SUBSCRIPTION_ID + " already exists"))
|
||||
.verifyComplete();
|
||||
|
||||
assertThat(messages.size()).isEqualTo(2);
|
||||
assertThat(messages.get(0).get("type")).isEqualTo("connection_ack");
|
||||
assertThat(messages.get(1).get("type")).isEqualTo("next");
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientCompletion() throws Exception {
|
||||
GraphQLWebSocketHandler handler = initWebSocketHandler(
|
||||
Collections.singletonList(new ConsumeOneAndNeverCompleteInterceptor()), null);
|
||||
|
||||
handler.afterConnectionEstablished(session);
|
||||
handler.handleTextMessage(session, new TextMessage("{\"type\":\"connection_init\"}"));
|
||||
handler.handleTextMessage(session, new TextMessage(BOOK_SEARCH_QUERY));
|
||||
|
||||
String completeMessage = "{\"id\":\"" + SUBSCRIPTION_ID + "\",\"type\":\"complete\"}";
|
||||
Consumer<String> messageSender = body -> {
|
||||
try {
|
||||
handler.handleTextMessage(session, new TextMessage(body));
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
};
|
||||
|
||||
StepVerifier.create(session.getOutput())
|
||||
.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(BOOK_SEARCH_QUERY))
|
||||
.consumeNextWith(message -> assertMessageType(message, "next"))
|
||||
.then(() -> messageSender.accept(completeMessage))
|
||||
.verifyTimeout(Duration.ofMillis(500));
|
||||
}
|
||||
|
||||
|
||||
private GraphQLWebSocketHandler initWebSocketHandler(
|
||||
@Nullable List<WebInterceptor> interceptors, @Nullable Duration initTimeoutDuration) {
|
||||
|
||||
try {
|
||||
return new GraphQLWebSocketHandler(initGraphQL(),
|
||||
(interceptors != null ? interceptors : Collections.emptyList()), converter,
|
||||
(initTimeoutDuration != null ? initTimeoutDuration : Duration.ofSeconds(60)));
|
||||
}
|
||||
catch (Exception ex) {
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static GraphQL initGraphQL() throws Exception {
|
||||
File schemaFile = ResourceUtils.getFile("classpath:books/schema.graphqls");
|
||||
TypeDefinitionRegistry typeDefinitionRegistry = new SchemaParser().parse(schemaFile);
|
||||
|
||||
RuntimeWiring.Builder builder = RuntimeWiring.newRuntimeWiring();
|
||||
builder.type(newTypeWiring("Query").dataFetcher("bookById", GraphQLDataFetchers.getBookByIdDataFetcher()));
|
||||
builder.type(newTypeWiring("Subscription").dataFetcher("bookSearch", GraphQLDataFetchers.getBooksOnSale()));
|
||||
RuntimeWiring runtimeWiring = builder.build();
|
||||
|
||||
GraphQLSchema schema = new SchemaGenerator().makeExecutableSchema(typeDefinitionRegistry, runtimeWiring);
|
||||
return GraphQL.newGraphQL(schema).build();
|
||||
}
|
||||
|
||||
private void assertMessageType(WebSocketMessage<?> message, String messageType) {
|
||||
Map<String, Object> map = decode(message, Map.class);
|
||||
assertThat(map).containsEntry("type", messageType);
|
||||
if (!messageType.equals("connection_ack")) {
|
||||
assertThat(map).containsEntry("id", SUBSCRIPTION_ID);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> decode(WebSocketMessage<?> message) {
|
||||
return decode(message, Map.class);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T decode(WebSocketMessage<?> message, Class<T> targetClass) {
|
||||
try {
|
||||
HttpInputMessageAdapter inputMessage = new HttpInputMessageAdapter((TextMessage) message);
|
||||
return ((HttpMessageConverter<T>) converter).read(targetClass, inputMessage);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static class HttpInputMessageAdapter extends ByteArrayInputStream implements HttpInputMessage {
|
||||
|
||||
HttpInputMessageAdapter(TextMessage message) {
|
||||
super(message.asBytes());
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getBody() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders getHeaders() {
|
||||
return HttpHeaders.EMPTY;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2002-2021 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.springframework.graphql.webmvc;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.security.Principal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.publisher.Sinks;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.WebSocketExtension;
|
||||
import org.springframework.web.socket.WebSocketMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public class TestWebSocketSession implements WebSocketSession {
|
||||
|
||||
private final URI uri = URI.create("https://example.org/graphql");
|
||||
|
||||
private final HttpHeaders headers = new HttpHeaders();
|
||||
|
||||
private final Map<String, Object> attributes = new ConcurrentHashMap<>();
|
||||
|
||||
private final Sinks.Many<WebSocketMessage<?>> messagesSink = Sinks.many().unicast().onBackpressureBuffer();
|
||||
|
||||
private Sinks.One<CloseStatus> statusSink = Sinks.one();
|
||||
|
||||
private boolean closed;
|
||||
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return "1";
|
||||
}
|
||||
|
||||
@Override
|
||||
public URI getUri() {
|
||||
return this.uri;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HttpHeaders getHandshakeHeaders() {
|
||||
return this.headers;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getAttributes() {
|
||||
return this.attributes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Principal getPrincipal() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InetSocketAddress getLocalAddress() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InetSocketAddress getRemoteAddress() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAcceptedProtocol() {
|
||||
return "graphql-transport-ws";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setTextMessageSizeLimit(int messageSizeLimit) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTextMessageSizeLimit() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setBinaryMessageSizeLimit(int messageSizeLimit) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getBinaryMessageSizeLimit() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<WebSocketExtension> getExtensions() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendMessage(WebSocketMessage<?> message) {
|
||||
emitMessagesSignal(this.messagesSink.tryEmitNext(message));
|
||||
}
|
||||
|
||||
private void emitMessagesSignal(Sinks.EmitResult result) {
|
||||
Assert.state(result == Sinks.EmitResult.OK, "Emit failed: " + result);
|
||||
}
|
||||
|
||||
public Flux<WebSocketMessage<?>> getOutput() {
|
||||
return this.messagesSink.asFlux();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOpen() {
|
||||
return !this.closed;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
this.closed = true;
|
||||
emitMessagesSignal(this.messagesSink.tryEmitComplete());
|
||||
this.statusSink.tryEmitEmpty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close(CloseStatus status) {
|
||||
this.closed = true;
|
||||
emitMessagesSignal(this.messagesSink.tryEmitComplete());
|
||||
this.statusSink.tryEmitValue(status);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public CloseStatus getCloseStatus() {
|
||||
return (this.closed ? this.statusSink.asMono().block() : null);
|
||||
}
|
||||
|
||||
public Mono<CloseStatus> closeStatus() {
|
||||
return this.statusSink.asMono();
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user