Skip to content

Commit

Permalink
Configure Jackson codec specifically for GraphQL HTTP endpoints
Browse files Browse the repository at this point in the history
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
  • Loading branch information
bclozel committed Apr 2, 2024
1 parent 3e898f7 commit 9e4d773
Show file tree
Hide file tree
Showing 13 changed files with 333 additions and 43 deletions.
5 changes: 5 additions & 0 deletions spring-graphql-docs/modules/ROOT/pages/transports.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
@@ -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<SerializableGraphQlRequest> 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);
}
}


}
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@

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;

Expand All @@ -38,32 +37,40 @@
* @author Brian Clozel
* @since 1.0.0
*/
public class GraphQlHttpHandler {
public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler {

private static final Log logger = LogFactory.getLog(GraphQlHttpHandler.class);

@SuppressWarnings("removal")
private static final List<MediaType> 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<ServerResponse> handleRequest(ServerRequest serverRequest) {
return serverRequest.bodyToMono(SerializableGraphQlRequest.class)
return readRequest(serverRequest)
.flatMap(body -> {
WebGraphQlRequest graphQlRequest = new WebGraphQlRequest(
serverRequest.uri(), serverRequest.headers().asHttpHeaders(),
Expand All @@ -82,7 +89,12 @@ public Mono<ServerResponse> handleRequest(ServerRequest serverRequest) {
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());
}
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,8 @@
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;
Expand All @@ -50,18 +48,15 @@
* @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<ServerSentEvent<Map<String, Object>>> COMPLETE_EVENT = Mono.just(ServerSentEvent.<Map<String, Object>>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);
}

/**
Expand All @@ -72,7 +67,7 @@ public GraphQlSseHandler(WebGraphQlHandler graphQlHandler) {
*/
@SuppressWarnings("unchecked")
public Mono<ServerResponse> handleRequest(ServerRequest serverRequest) {
Flux<ServerSentEvent<Map<String, Object>>> data = serverRequest.bodyToMono(SerializableGraphQlRequest.class)
Flux<ServerSentEvent<Map<String, Object>>> data = readRequest(serverRequest)
.flatMap(body -> {
WebGraphQlRequest graphQlRequest = new WebGraphQlRequest(
serverRequest.uri(), serverRequest.headers().asHttpHeaders(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -91,7 +91,7 @@ public GraphQlWebSocketHandler(

this.graphQlHandler = graphQlHandler;
this.webSocketInterceptor = this.graphQlHandler.getWebSocketInterceptor();
this.codecDelegate = new CodecDelegate(codecConfigurer);
this.webSocketCodecDelegate = new WebSocketCodecDelegate(codecConfigurer);
this.initTimeoutDuration = connectionInitTimeout;
}

Expand Down Expand Up @@ -137,7 +137,7 @@ public Mono<Void> handle(WebSocketSession session) {
.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<String, Object> payload = message.getPayload();
switch (message.resolvedType()) {
Expand All @@ -159,7 +159,7 @@ public Mono<Void> handle(WebSocketSession session) {
.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) {
Expand All @@ -178,7 +178,7 @@ public Mono<Void> handle(WebSocketSession session) {
}
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));
}
Expand Down Expand Up @@ -218,14 +218,14 @@ private Flux<WebSocketMessage> handleResponse(WebSocketSession session, String i
}

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));
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Map<String, Object>>) this.encoder)
.encodeValue(response.toMap(), DefaultDataBufferFactory.sharedInstance, RESPONSE_TYPE, MimeTypeUtils.APPLICATION_JSON, null);
}

@SuppressWarnings("unchecked")
public Mono<SerializableGraphQlRequest> decode(Publisher<DataBuffer> inputStream, MediaType contentType) {
return (Mono<SerializableGraphQlRequest>) this.decoder.decodeToMono(inputStream, REQUEST_TYPE, contentType, null);
}

}
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -40,12 +40,12 @@
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);

Expand All @@ -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);
Expand Down
Loading

0 comments on commit 9e4d773

Please sign in to comment.