From 9e4d77395a532502667d680b6db5ee9eb6f8131b Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 2 Apr 2024 18:11:35 +0200 Subject: [PATCH] Configure Jackson codec specifically for GraphQL HTTP endpoints Prior to this commit, the `GraphQlHttpHandler` implementations would use the JSON codecs configured in the web Framework (MVC or WebFlux) for reading and writing GraphQL payloads as JSON documents. This can cause issues in cases the application configures the JSON codec in a way that makes it incompatible with the expected GraphQL documents. For example, not serializing empty values and arrays. This commit adds new constructors in `GraphQlHttpHandler` implementations that can get a custom JSON codec for GraphQL payloads. Closes gh-860 --- .../modules/ROOT/pages/transports.adoc | 5 ++ .../webflux/AbstractGraphQlHttpHandler.java | 60 +++++++++++++ .../server/webflux/GraphQlHttpHandler.java | 28 ++++-- .../server/webflux/GraphQlSseHandler.java | 11 +-- .../webflux/GraphQlWebSocketHandler.java | 16 ++-- .../server/webflux/HttpCodecDelegate.java | 90 +++++++++++++++++++ ...egate.java => WebSocketCodecDelegate.java} | 8 +- .../webmvc/AbstractGraphQlHttpHandler.java | 26 +++++- .../server/webmvc/GraphQlHttpHandler.java | 42 ++++++++- .../server/webmvc/GraphQlSseHandler.java | 5 +- .../spring-graphql/reflect-config.json | 2 +- .../webflux/GraphQlHttpHandlerTests.java | 63 +++++++++++-- .../webmvc/GraphQlHttpHandlerTests.java | 20 +++++ 13 files changed, 333 insertions(+), 43 deletions(-) create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/server/webflux/AbstractGraphQlHttpHandler.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/server/webflux/HttpCodecDelegate.java rename spring-graphql/src/main/java/org/springframework/graphql/server/webflux/{CodecDelegate.java => WebSocketCodecDelegate.java} (94%) diff --git a/spring-graphql-docs/modules/ROOT/pages/transports.adoc b/spring-graphql-docs/modules/ROOT/pages/transports.adoc index ffb12a59..d48b1788 100644 --- a/spring-graphql-docs/modules/ROOT/pages/transports.adoc +++ b/spring-graphql-docs/modules/ROOT/pages/transports.adoc @@ -29,6 +29,11 @@ xref:boot-starter.adoc[Boot Starter] does this, see the details, or check `GraphQlWebMvcAutoConfiguration` or `GraphQlWebFluxAutoConfiguration` it contains, for the actual config. +By default, the `GraphQlHttpHandler` will serialize and deserialize JSON payloads using the `HttpMessageConverter` (Spring MVC) +and the `DecoderHttpMessageReader/EncoderHttpMessageWriter` (WebFlux) configured in the web framework. +In some cases, the application will configure the JSON codec for the HTTP endpoint in a way that is not compatible with the GraphQL payloads. +Applications can instantiate `GraphQlHttpHandler` with a custom JSON codec that will be used for GraphQL payloads. + The 1.0.x branch of this repository contains a Spring MVC {github-10x-branch}/samples/webmvc-http[HTTP sample] application. diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/AbstractGraphQlHttpHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/AbstractGraphQlHttpHandler.java new file mode 100644 index 00000000..a7df7d23 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/AbstractGraphQlHttpHandler.java @@ -0,0 +1,60 @@ +/* + * Copyright 2020-2024 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.server.webflux; + + +import reactor.core.publisher.Mono; + +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.graphql.server.WebGraphQlHandler; +import org.springframework.graphql.server.support.SerializableGraphQlRequest; +import org.springframework.http.MediaType; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.web.reactive.function.server.ServerRequest; + +/** + * Abstract class for GraphQL Handler implementations using the HTTP transport. + * + * @author Brian Clozel + * @since 1.3.0 + */ +class AbstractGraphQlHttpHandler { + + protected final WebGraphQlHandler graphQlHandler; + + @Nullable + protected final HttpCodecDelegate codecDelegate; + + public AbstractGraphQlHttpHandler(WebGraphQlHandler graphQlHandler, @Nullable HttpCodecDelegate codecDelegate) { + Assert.notNull(graphQlHandler, "WebGraphQlHandler is required"); + this.graphQlHandler = graphQlHandler; + this.codecDelegate = codecDelegate; + } + + protected Mono readRequest(ServerRequest serverRequest) { + if (this.codecDelegate != null) { + MediaType contentType = serverRequest.headers().contentType().orElse(MediaType.APPLICATION_JSON); + return this.codecDelegate.decode(serverRequest.bodyToFlux(DataBuffer.class), contentType); + } + else { + return serverRequest.bodyToMono(SerializableGraphQlRequest.class); + } + } + + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java index 36bb5ad8..ca533420 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java @@ -25,9 +25,8 @@ import reactor.core.publisher.Mono; import org.springframework.graphql.server.WebGraphQlHandler; import org.springframework.graphql.server.WebGraphQlRequest; -import org.springframework.graphql.server.support.SerializableGraphQlRequest; import org.springframework.http.MediaType; -import org.springframework.util.Assert; +import org.springframework.http.codec.CodecConfigurer; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; @@ -38,7 +37,7 @@ import org.springframework.web.reactive.function.server.ServerResponse; * @author Brian Clozel * @since 1.0.0 */ -public class GraphQlHttpHandler { +public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler { private static final Log logger = LogFactory.getLog(GraphQlHttpHandler.class); @@ -46,24 +45,32 @@ public class GraphQlHttpHandler { private static final List SUPPORTED_MEDIA_TYPES = Arrays.asList(MediaType.APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, MediaType.APPLICATION_GRAPHQL); - private final WebGraphQlHandler graphQlHandler; /** * Create a new instance. * @param graphQlHandler common handler for GraphQL over HTTP requests */ public GraphQlHttpHandler(WebGraphQlHandler graphQlHandler) { - Assert.notNull(graphQlHandler, "WebGraphQlHandler is required"); - this.graphQlHandler = graphQlHandler; + super(graphQlHandler, null); } + /** + * Create a new instance. + * @param graphQlHandler common handler for GraphQL over HTTP requests + * @param codecConfigurer codec configurer for JSON encoding and decoding + */ + public GraphQlHttpHandler(WebGraphQlHandler graphQlHandler, CodecConfigurer codecConfigurer) { + super(graphQlHandler, new HttpCodecDelegate(codecConfigurer)); + } + + /** * Handle GraphQL requests over HTTP. * @param serverRequest the incoming HTTP request * @return the HTTP response */ public Mono handleRequest(ServerRequest serverRequest) { - return serverRequest.bodyToMono(SerializableGraphQlRequest.class) + return readRequest(serverRequest) .flatMap(body -> { WebGraphQlRequest graphQlRequest = new WebGraphQlRequest( serverRequest.uri(), serverRequest.headers().asHttpHeaders(), @@ -82,7 +89,12 @@ public class GraphQlHttpHandler { ServerResponse.BodyBuilder builder = ServerResponse.ok(); builder.headers(headers -> headers.putAll(response.getResponseHeaders())); builder.contentType(selectResponseMediaType(serverRequest)); - return builder.bodyValue(response.toMap()); + if (this.codecDelegate != null) { + return builder.bodyValue(this.codecDelegate.encode(response)); + } + else { + return builder.bodyValue(response.toMap()); + } }); } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlSseHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlSseHandler.java index 334b31d9..83cae32b 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlSseHandler.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlSseHandler.java @@ -32,10 +32,8 @@ import reactor.core.publisher.Mono; import org.springframework.graphql.execution.SubscriptionPublisherException; import org.springframework.graphql.server.WebGraphQlHandler; import org.springframework.graphql.server.WebGraphQlRequest; -import org.springframework.graphql.server.support.SerializableGraphQlRequest; import org.springframework.http.MediaType; import org.springframework.http.codec.ServerSentEvent; -import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.server.ServerRequest; @@ -50,18 +48,15 @@ import org.springframework.web.reactive.function.server.ServerResponse; * @author Brian Clozel * @since 1.3.0 */ -public class GraphQlSseHandler { +public class GraphQlSseHandler extends AbstractGraphQlHttpHandler { private static final Log logger = LogFactory.getLog(GraphQlSseHandler.class); private static final Mono>> COMPLETE_EVENT = Mono.just(ServerSentEvent.>builder(Collections.emptyMap()).event("complete").build()); - private final WebGraphQlHandler graphQlHandler; - public GraphQlSseHandler(WebGraphQlHandler graphQlHandler) { - Assert.notNull(graphQlHandler, "WebGraphQlHandler is required"); - this.graphQlHandler = graphQlHandler; + super(graphQlHandler, null); } /** @@ -72,7 +67,7 @@ public class GraphQlSseHandler { */ @SuppressWarnings("unchecked") public Mono handleRequest(ServerRequest serverRequest) { - Flux>> data = serverRequest.bodyToMono(SerializableGraphQlRequest.class) + Flux>> data = readRequest(serverRequest) .flatMap(body -> { WebGraphQlRequest graphQlRequest = new WebGraphQlRequest( serverRequest.uri(), serverRequest.headers().asHttpHeaders(), diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlWebSocketHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlWebSocketHandler.java index 0f9a40ac..68c7778c 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlWebSocketHandler.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlWebSocketHandler.java @@ -72,7 +72,7 @@ public class GraphQlWebSocketHandler implements WebSocketHandler { private final WebSocketGraphQlInterceptor webSocketInterceptor; - private final CodecDelegate codecDelegate; + private final WebSocketCodecDelegate webSocketCodecDelegate; private final Duration initTimeoutDuration; @@ -91,7 +91,7 @@ public class GraphQlWebSocketHandler implements WebSocketHandler { this.graphQlHandler = graphQlHandler; this.webSocketInterceptor = this.graphQlHandler.getWebSocketInterceptor(); - this.codecDelegate = new CodecDelegate(codecConfigurer); + this.webSocketCodecDelegate = new WebSocketCodecDelegate(codecConfigurer); this.initTimeoutDuration = connectionInitTimeout; } @@ -137,7 +137,7 @@ public class GraphQlWebSocketHandler implements WebSocketHandler { .subscribe(); return session.send(session.receive().flatMap(webSocketMessage -> { - GraphQlWebSocketMessage message = this.codecDelegate.decode(webSocketMessage); + GraphQlWebSocketMessage message = this.webSocketCodecDelegate.decode(webSocketMessage); String id = message.getId(); Map payload = message.getPayload(); switch (message.resolvedType()) { @@ -159,7 +159,7 @@ public class GraphQlWebSocketHandler implements WebSocketHandler { .doOnTerminate(() -> subscriptions.remove(id)); } case PING -> { - return Flux.just(this.codecDelegate.encode(session, GraphQlWebSocketMessage.pong(null))); + return Flux.just(this.webSocketCodecDelegate.encode(session, GraphQlWebSocketMessage.pong(null))); } case COMPLETE -> { if (id != null) { @@ -178,7 +178,7 @@ public class GraphQlWebSocketHandler implements WebSocketHandler { } return this.webSocketInterceptor.handleConnectionInitialization(sessionInfo, payload) .defaultIfEmpty(Collections.emptyMap()) - .map(ackPayload -> this.codecDelegate.encodeConnectionAck(session, ackPayload)) + .map(ackPayload -> this.webSocketCodecDelegate.encodeConnectionAck(session, ackPayload)) .flux() .onErrorResume(ex -> GraphQlStatus.close(session, GraphQlStatus.UNAUTHORIZED_STATUS)); } @@ -218,14 +218,14 @@ public class GraphQlWebSocketHandler implements WebSocketHandler { } return responseFlux - .map(responseMap -> this.codecDelegate.encodeNext(session, id, responseMap)) - .concatWith(Mono.fromCallable(() -> this.codecDelegate.encodeComplete(session, id))) + .map(responseMap -> this.webSocketCodecDelegate.encodeNext(session, id, responseMap)) + .concatWith(Mono.fromCallable(() -> this.webSocketCodecDelegate.encodeComplete(session, id))) .onErrorResume(ex -> { if (ex instanceof SubscriptionExistsException) { CloseStatus status = new CloseStatus(4409, "Subscriber for " + id + " already exists"); return GraphQlStatus.close(session, status); } - return Mono.fromCallable(() -> this.codecDelegate.encodeError(session, id, ex)); + return Mono.fromCallable(() -> this.webSocketCodecDelegate.encodeError(session, id, ex)); }); } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/HttpCodecDelegate.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/HttpCodecDelegate.java new file mode 100644 index 00000000..4e4b975d --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/HttpCodecDelegate.java @@ -0,0 +1,90 @@ +/* + * Copyright 2002-2022 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.server.webflux; + +import java.util.Map; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.core.ResolvableType; +import org.springframework.core.codec.Decoder; +import org.springframework.core.codec.Encoder; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.graphql.GraphQlResponse; +import org.springframework.graphql.server.support.SerializableGraphQlRequest; +import org.springframework.http.MediaType; +import org.springframework.http.codec.CodecConfigurer; +import org.springframework.http.codec.DecoderHttpMessageReader; +import org.springframework.http.codec.EncoderHttpMessageWriter; +import org.springframework.util.Assert; +import org.springframework.util.MimeTypeUtils; + +/** + * Helper class for encoding and decoding GraphQL messages in HTTP transport. + * + * @author Rossen Stoyanchev + * @author Brian Clozel + * @since 1.3.0 + */ +final class HttpCodecDelegate { + + private static final ResolvableType REQUEST_TYPE = ResolvableType.forClass(SerializableGraphQlRequest.class); + + private static final ResolvableType RESPONSE_TYPE = ResolvableType.forClassWithGenerics(Map.class, String.class, Object.class); + + + private final Decoder decoder; + + private final Encoder encoder; + + + HttpCodecDelegate(CodecConfigurer codecConfigurer) { + Assert.notNull(codecConfigurer, "CodecConfigurer is required"); + this.decoder = findJsonDecoder(codecConfigurer); + this.encoder = findJsonEncoder(codecConfigurer); + } + + private static Decoder findJsonDecoder(CodecConfigurer configurer) { + return configurer.getReaders().stream() + .filter((reader) -> reader.canRead(REQUEST_TYPE, MediaType.APPLICATION_JSON)) + .map((reader) -> ((DecoderHttpMessageReader) reader).getDecoder()) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No JSON Decoder")); + } + + private static Encoder findJsonEncoder(CodecConfigurer configurer) { + return configurer.getWriters().stream() + .filter((writer) -> writer.canWrite(RESPONSE_TYPE, MediaType.APPLICATION_JSON)) + .map((writer) -> ((EncoderHttpMessageWriter) writer).getEncoder()) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No JSON Encoder")); + } + + + @SuppressWarnings("unchecked") + public DataBuffer encode(GraphQlResponse response) { + return ((Encoder>) this.encoder) + .encodeValue(response.toMap(), DefaultDataBufferFactory.sharedInstance, RESPONSE_TYPE, MimeTypeUtils.APPLICATION_JSON, null); + } + + @SuppressWarnings("unchecked") + public Mono decode(Publisher inputStream, MediaType contentType) { + return (Mono) this.decoder.decodeToMono(inputStream, REQUEST_TYPE, contentType, null); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/CodecDelegate.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/WebSocketCodecDelegate.java similarity index 94% rename from spring-graphql/src/main/java/org/springframework/graphql/server/webflux/CodecDelegate.java rename to spring-graphql/src/main/java/org/springframework/graphql/server/webflux/WebSocketCodecDelegate.java index bf860e1b..0643ac4b 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/CodecDelegate.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/WebSocketCodecDelegate.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -40,12 +40,12 @@ import org.springframework.web.reactive.socket.WebSocketMessage; import org.springframework.web.reactive.socket.WebSocketSession; /** - * Helper class for encoding and decoding GraphQL messages. + * Helper class for encoding and decoding GraphQL messages in WebSocket transport. * * @author Rossen Stoyanchev * @since 1.0.0 */ -final class CodecDelegate { +final class WebSocketCodecDelegate { private static final ResolvableType MESSAGE_TYPE = ResolvableType.forClass(GraphQlWebSocketMessage.class); @@ -55,7 +55,7 @@ final class CodecDelegate { private final Encoder encoder; - CodecDelegate(CodecConfigurer codecConfigurer) { + WebSocketCodecDelegate(CodecConfigurer codecConfigurer) { Assert.notNull(codecConfigurer, "CodecConfigurer is required"); this.decoder = findJsonDecoder(codecConfigurer); this.encoder = findJsonEncoder(codecConfigurer); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/AbstractGraphQlHttpHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/AbstractGraphQlHttpHandler.java index 4d0335fe..638573a3 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/AbstractGraphQlHttpHandler.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/AbstractGraphQlHttpHandler.java @@ -20,6 +20,7 @@ import java.io.IOException; import jakarta.servlet.ServletException; import jakarta.servlet.http.Cookie; + import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -27,11 +28,16 @@ import org.springframework.graphql.GraphQlRequest; import org.springframework.graphql.server.WebGraphQlHandler; import org.springframework.graphql.server.support.SerializableGraphQlRequest; import org.springframework.http.HttpCookie; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.lang.Nullable; import org.springframework.util.AlternativeJdkIdGenerator; import org.springframework.util.Assert; import org.springframework.util.IdGenerator; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.server.ServerWebInputException; import org.springframework.web.servlet.function.ServerRequest; @@ -49,10 +55,14 @@ abstract class AbstractGraphQlHttpHandler { protected final WebGraphQlHandler graphQlHandler; + @Nullable + protected final HttpMessageConverter messageConverter; - AbstractGraphQlHttpHandler(WebGraphQlHandler graphQlHandler) { + @SuppressWarnings("unchecked") + AbstractGraphQlHttpHandler(WebGraphQlHandler graphQlHandler, @Nullable HttpMessageConverter messageConverter) { Assert.notNull(graphQlHandler, "WebGraphQlHandler is required"); this.graphQlHandler = graphQlHandler; + this.messageConverter = (HttpMessageConverter) messageConverter; } protected static MultiValueMap initCookies(ServerRequest serverRequest) { @@ -65,9 +75,19 @@ abstract class AbstractGraphQlHttpHandler { return target; } - protected static GraphQlRequest readBody(ServerRequest request) throws ServletException { + protected GraphQlRequest readBody(ServerRequest request) throws ServletException { try { - return request.body(SerializableGraphQlRequest.class); + if (this.messageConverter != null) { + MediaType contentType = request.headers().contentType().orElse(MediaType.APPLICATION_JSON); + if (this.messageConverter.canRead(SerializableGraphQlRequest.class, contentType)) { + return (GraphQlRequest) this.messageConverter.read(SerializableGraphQlRequest.class, + new ServletServerHttpRequest(request.servletRequest())); + } + throw new HttpMediaTypeNotSupportedException(contentType, this.messageConverter.getSupportedMediaTypes(), request.method()); + } + else { + return request.body(SerializableGraphQlRequest.class); + } } catch (IOException ex) { throw new ServerWebInputException("I/O error while reading request body", null, ex); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandler.java index 54c796b5..9ba20349 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandler.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandler.java @@ -24,9 +24,14 @@ import java.util.concurrent.ExecutionException; import jakarta.servlet.ServletException; import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.graphql.GraphQlResponse; import org.springframework.graphql.server.WebGraphQlHandler; import org.springframework.graphql.server.WebGraphQlRequest; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.lang.Nullable; import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.servlet.function.ServerRequest; import org.springframework.web.servlet.function.ServerResponse; @@ -51,7 +56,18 @@ public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler{ * @param graphQlHandler common handler for GraphQL over HTTP requests */ public GraphQlHttpHandler(WebGraphQlHandler graphQlHandler) { - super(graphQlHandler); + super(graphQlHandler, null); + } + + /** + * Create a new instance with a custom message converter. + *

If no converter is provided, this will use + * {@link org.springframework.web.servlet.config.annotation.WebMvcConfigurer#configureMessageConverters(List) the one configured in the web framework}. + * @param graphQlHandler common handler for GraphQL over HTTP requests + * @param messageConverter custom {@link HttpMessageConverter} to be used for encoding and decoding GraphQL payloads + */ + public GraphQlHttpHandler(WebGraphQlHandler graphQlHandler, @Nullable HttpMessageConverter messageConverter) { + super(graphQlHandler, messageConverter); } /** @@ -77,10 +93,17 @@ public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler{ if (logger.isDebugEnabled()) { logger.debug("Execution complete"); } + MediaType contentType = selectResponseMediaType(serverRequest); ServerResponse.BodyBuilder builder = ServerResponse.ok(); builder.headers(headers -> headers.putAll(response.getResponseHeaders())); - builder.contentType(selectResponseMediaType(serverRequest)); - return builder.body(response.toMap()); + builder.contentType(contentType); + + if (this.messageConverter != null) { + return builder.build(writeFunction(contentType, response)); + } + else { + return builder.body(response.toMap()); + } }) .toFuture(); @@ -108,4 +131,17 @@ public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler{ return MediaType.APPLICATION_JSON; } + private ServerResponse.HeadersBuilder.WriteFunction writeFunction(MediaType contentType, GraphQlResponse response) { + return (servletRequest, servletResponse) -> { + if (messageConverter != null) { + ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(servletResponse); + messageConverter.write(response.toMap(), contentType, httpResponse); + return null; + } + else { + throw new HttpMediaTypeNotSupportedException(contentType, SUPPORTED_MEDIA_TYPES, HttpMethod.POST); + } + }; + } + } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlSseHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlSseHandler.java index 0f212707..63dc2cc4 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlSseHandler.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlSseHandler.java @@ -20,10 +20,11 @@ import java.io.IOException; import java.util.Collections; import java.util.Map; +import jakarta.servlet.ServletException; + import graphql.ErrorType; import graphql.ExecutionResult; import graphql.GraphQLError; -import jakarta.servlet.ServletException; import org.reactivestreams.Publisher; import reactor.core.publisher.BaseSubscriber; import reactor.core.publisher.Flux; @@ -55,7 +56,7 @@ public class GraphQlSseHandler extends AbstractGraphQlHttpHandler { public GraphQlSseHandler(WebGraphQlHandler graphQlHandler) { - super(graphQlHandler); + super(graphQlHandler, null); } /** diff --git a/spring-graphql/src/main/resources/META-INF/native-image/org.springframework.graphql/spring-graphql/reflect-config.json b/spring-graphql/src/main/resources/META-INF/native-image/org.springframework.graphql/spring-graphql/reflect-config.json index 6bdf25d5..e9758cb6 100644 --- a/spring-graphql/src/main/resources/META-INF/native-image/org.springframework.graphql/spring-graphql/reflect-config.json +++ b/spring-graphql/src/main/resources/META-INF/native-image/org.springframework.graphql/spring-graphql/reflect-config.json @@ -21,7 +21,7 @@ "allDeclaredMethods":true, "allDeclaredConstructors":true, "condition": { - "typeReachable": "org.springframework.graphql.server.webflux.CodecDelegate" + "typeReachable": "org.springframework.graphql.server.webflux.WebSocketCodecDelegate" } }, { diff --git a/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlHttpHandlerTests.java b/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlHttpHandlerTests.java index 8240a206..4d778748 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlHttpHandlerTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlHttpHandlerTests.java @@ -15,21 +15,22 @@ */ package org.springframework.graphql.server.webflux; -import java.util.Collections; -import java.util.List; -import java.util.Locale; - +import com.fasterxml.jackson.databind.ObjectMapper; import com.jayway.jsonpath.DocumentContext; import com.jayway.jsonpath.JsonPath; import org.junit.jupiter.api.Test; -import reactor.core.publisher.Mono; - +import org.springframework.core.codec.DataBufferEncoder; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.graphql.GraphQlRequest; import org.springframework.graphql.GraphQlSetup; +import org.springframework.graphql.server.WebGraphQlHandler; import org.springframework.graphql.server.support.SerializableGraphQlRequest; import org.springframework.http.MediaType; +import org.springframework.http.codec.CodecConfigurer; import org.springframework.http.codec.EncoderHttpMessageWriter; import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.http.codec.ServerCodecConfigurer; +import org.springframework.http.codec.json.Jackson2JsonDecoder; import org.springframework.http.codec.json.Jackson2JsonEncoder; import org.springframework.mock.http.server.reactive.MockServerHttpRequest; import org.springframework.mock.http.server.reactive.MockServerHttpResponse; @@ -38,6 +39,13 @@ import org.springframework.mock.web.server.MockServerWebExchange; import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.result.view.ViewResolver; import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Locale; import static org.assertj.core.api.Assertions.assertThat; @@ -119,6 +127,37 @@ public class GraphQlHttpHandlerTests { assertThat(id).isEqualTo(httpRequest.getId()); } + @Test + void shouldUseCustomCodec() { + WebGraphQlHandler webGraphQlHandler = GraphQlSetup.schemaContent("type Query { showId: String }") + .queryFetcher("showId", (env) -> env.getExecutionId().toString()) + .toWebGraphQlHandler(); + ObjectMapper mapper = new ObjectMapper(); + CodecConfigurer configurer = ServerCodecConfigurer.create(); + configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(mapper)); + configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(mapper)); + GraphQlHttpHandler httpHandler = new GraphQlHttpHandler(webGraphQlHandler, configurer); + + MockServerHttpRequest httpRequest = MockServerHttpRequest.post("/") + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_GRAPHQL_RESPONSE).build(); + + MockServerWebExchange exchange = MockServerWebExchange.from(httpRequest); + MockServerRequest serverRequest = MockServerRequest.builder() + .exchange(exchange) + .uri(((ServerWebExchange) exchange).getRequest().getURI()) + .method(((ServerWebExchange) exchange).getRequest().getMethod()) + .headers(((ServerWebExchange) exchange).getRequest().getHeaders()) + .body(Flux.just(DefaultDataBufferFactory.sharedInstance.wrap("{\"query\":\"{showId}\"}".getBytes(StandardCharsets.UTF_8)))); + + httpHandler.handleRequest(serverRequest) + .flatMap(response -> response.writeTo(exchange, new EmptyContext())) + .block(); + + DocumentContext document = JsonPath.parse(exchange.getResponse().getBodyAsString().block()); + String id = document.read("data.showId", String.class); + assertThat(id).isEqualTo(httpRequest.getId()); + } + private static SerializableGraphQlRequest initRequest(String document) { SerializableGraphQlRequest request = new SerializableGraphQlRequest(); request.setQuery(document); @@ -159,4 +198,16 @@ public class GraphQlHttpHandlerTests { } + private static class EmptyContext implements ServerResponse.Context { + @Override + public List> messageWriters() { + return List.of(new EncoderHttpMessageWriter<>(new DataBufferEncoder())); + } + + @Override + public List viewResolvers() { + return List.of(); + } + } + } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandlerTests.java b/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandlerTests.java index e98c4964..a46b653b 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandlerTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandlerTests.java @@ -31,6 +31,7 @@ import org.junit.jupiter.api.Test; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.graphql.GraphQlSetup; +import org.springframework.graphql.server.WebGraphQlHandler; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; @@ -110,6 +111,25 @@ public class GraphQlHttpHandlerTests { assertThatNoException().isThrownBy(() -> UUID.fromString(id)); } + @Test + void shouldUseCustomMessageConverter() throws Exception { + WebGraphQlHandler webGraphQlHandler = GraphQlSetup.schemaContent("type Query { greeting: String }") + .queryFetcher("greeting", (env) -> "Hello").toWebGraphQlHandler(); + GraphQlHttpHandler handler = new GraphQlHttpHandler(webGraphQlHandler, new MappingJackson2HttpMessageConverter()); + MockHttpServletRequest servletRequest = createServletRequest("{\"query\":\"{ greeting }\"}", MediaType.APPLICATION_GRAPHQL_RESPONSE_VALUE); + + ServerRequest request = ServerRequest.create(servletRequest, Collections.emptyList()); + ServerResponse response = handler.handleRequest(request); + if (response instanceof AsyncServerResponse asyncResponse) { + asyncResponse.block(); + } + MockHttpServletResponse servletResponse = new MockHttpServletResponse(); + response.writeTo(servletRequest, servletResponse, new DefaultContext()); + + assertThat(servletResponse.getContentAsString()) + .isEqualTo("{\"data\":{\"greeting\":\"Hello\"}}"); + } + @Test void persistedQuery() throws Exception {