Skip to content

Commit 9e4d773

Browse files
committed
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
1 parent 3e898f7 commit 9e4d773

File tree

13 files changed

+333
-43
lines changed

13 files changed

+333
-43
lines changed

spring-graphql-docs/modules/ROOT/pages/transports.adoc

+5
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ xref:boot-starter.adoc[Boot Starter] does this, see the
2929
details, or check `GraphQlWebMvcAutoConfiguration` or `GraphQlWebFluxAutoConfiguration`
3030
it contains, for the actual config.
3131

32+
By default, the `GraphQlHttpHandler` will serialize and deserialize JSON payloads using the `HttpMessageConverter` (Spring MVC)
33+
and the `DecoderHttpMessageReader/EncoderHttpMessageWriter` (WebFlux) configured in the web framework.
34+
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.
35+
Applications can instantiate `GraphQlHttpHandler` with a custom JSON codec that will be used for GraphQL payloads.
36+
3237
The 1.0.x branch of this repository contains a Spring MVC
3338
{github-10x-branch}/samples/webmvc-http[HTTP sample] application.
3439

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright 2020-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.server.webflux;
18+
19+
20+
import reactor.core.publisher.Mono;
21+
22+
import org.springframework.core.io.buffer.DataBuffer;
23+
import org.springframework.graphql.server.WebGraphQlHandler;
24+
import org.springframework.graphql.server.support.SerializableGraphQlRequest;
25+
import org.springframework.http.MediaType;
26+
import org.springframework.lang.Nullable;
27+
import org.springframework.util.Assert;
28+
import org.springframework.web.reactive.function.server.ServerRequest;
29+
30+
/**
31+
* Abstract class for GraphQL Handler implementations using the HTTP transport.
32+
*
33+
* @author Brian Clozel
34+
* @since 1.3.0
35+
*/
36+
class AbstractGraphQlHttpHandler {
37+
38+
protected final WebGraphQlHandler graphQlHandler;
39+
40+
@Nullable
41+
protected final HttpCodecDelegate codecDelegate;
42+
43+
public AbstractGraphQlHttpHandler(WebGraphQlHandler graphQlHandler, @Nullable HttpCodecDelegate codecDelegate) {
44+
Assert.notNull(graphQlHandler, "WebGraphQlHandler is required");
45+
this.graphQlHandler = graphQlHandler;
46+
this.codecDelegate = codecDelegate;
47+
}
48+
49+
protected Mono<SerializableGraphQlRequest> readRequest(ServerRequest serverRequest) {
50+
if (this.codecDelegate != null) {
51+
MediaType contentType = serverRequest.headers().contentType().orElse(MediaType.APPLICATION_JSON);
52+
return this.codecDelegate.decode(serverRequest.bodyToFlux(DataBuffer.class), contentType);
53+
}
54+
else {
55+
return serverRequest.bodyToMono(SerializableGraphQlRequest.class);
56+
}
57+
}
58+
59+
60+
}

spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java

+20-8
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,8 @@
2525

2626
import org.springframework.graphql.server.WebGraphQlHandler;
2727
import org.springframework.graphql.server.WebGraphQlRequest;
28-
import org.springframework.graphql.server.support.SerializableGraphQlRequest;
2928
import org.springframework.http.MediaType;
30-
import org.springframework.util.Assert;
29+
import org.springframework.http.codec.CodecConfigurer;
3130
import org.springframework.web.reactive.function.server.ServerRequest;
3231
import org.springframework.web.reactive.function.server.ServerResponse;
3332

@@ -38,32 +37,40 @@
3837
* @author Brian Clozel
3938
* @since 1.0.0
4039
*/
41-
public class GraphQlHttpHandler {
40+
public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler {
4241

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

4544
@SuppressWarnings("removal")
4645
private static final List<MediaType> SUPPORTED_MEDIA_TYPES =
4746
Arrays.asList(MediaType.APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, MediaType.APPLICATION_GRAPHQL);
4847

49-
private final WebGraphQlHandler graphQlHandler;
5048

5149
/**
5250
* Create a new instance.
5351
* @param graphQlHandler common handler for GraphQL over HTTP requests
5452
*/
5553
public GraphQlHttpHandler(WebGraphQlHandler graphQlHandler) {
56-
Assert.notNull(graphQlHandler, "WebGraphQlHandler is required");
57-
this.graphQlHandler = graphQlHandler;
54+
super(graphQlHandler, null);
5855
}
5956

57+
/**
58+
* Create a new instance.
59+
* @param graphQlHandler common handler for GraphQL over HTTP requests
60+
* @param codecConfigurer codec configurer for JSON encoding and decoding
61+
*/
62+
public GraphQlHttpHandler(WebGraphQlHandler graphQlHandler, CodecConfigurer codecConfigurer) {
63+
super(graphQlHandler, new HttpCodecDelegate(codecConfigurer));
64+
}
65+
66+
6067
/**
6168
* Handle GraphQL requests over HTTP.
6269
* @param serverRequest the incoming HTTP request
6370
* @return the HTTP response
6471
*/
6572
public Mono<ServerResponse> handleRequest(ServerRequest serverRequest) {
66-
return serverRequest.bodyToMono(SerializableGraphQlRequest.class)
73+
return readRequest(serverRequest)
6774
.flatMap(body -> {
6875
WebGraphQlRequest graphQlRequest = new WebGraphQlRequest(
6976
serverRequest.uri(), serverRequest.headers().asHttpHeaders(),
@@ -82,7 +89,12 @@ public Mono<ServerResponse> handleRequest(ServerRequest serverRequest) {
8289
ServerResponse.BodyBuilder builder = ServerResponse.ok();
8390
builder.headers(headers -> headers.putAll(response.getResponseHeaders()));
8491
builder.contentType(selectResponseMediaType(serverRequest));
85-
return builder.bodyValue(response.toMap());
92+
if (this.codecDelegate != null) {
93+
return builder.bodyValue(this.codecDelegate.encode(response));
94+
}
95+
else {
96+
return builder.bodyValue(response.toMap());
97+
}
8698
});
8799
}
88100

spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlSseHandler.java

+3-8
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,8 @@
3232
import org.springframework.graphql.execution.SubscriptionPublisherException;
3333
import org.springframework.graphql.server.WebGraphQlHandler;
3434
import org.springframework.graphql.server.WebGraphQlRequest;
35-
import org.springframework.graphql.server.support.SerializableGraphQlRequest;
3635
import org.springframework.http.MediaType;
3736
import org.springframework.http.codec.ServerSentEvent;
38-
import org.springframework.util.Assert;
3937
import org.springframework.util.CollectionUtils;
4038
import org.springframework.web.reactive.function.BodyInserters;
4139
import org.springframework.web.reactive.function.server.ServerRequest;
@@ -50,18 +48,15 @@
5048
* @author Brian Clozel
5149
* @since 1.3.0
5250
*/
53-
public class GraphQlSseHandler {
51+
public class GraphQlSseHandler extends AbstractGraphQlHttpHandler {
5452

5553
private static final Log logger = LogFactory.getLog(GraphQlSseHandler.class);
5654

5755
private static final Mono<ServerSentEvent<Map<String, Object>>> COMPLETE_EVENT = Mono.just(ServerSentEvent.<Map<String, Object>>builder(Collections.emptyMap()).event("complete").build());
5856

59-
private final WebGraphQlHandler graphQlHandler;
60-
6157

6258
public GraphQlSseHandler(WebGraphQlHandler graphQlHandler) {
63-
Assert.notNull(graphQlHandler, "WebGraphQlHandler is required");
64-
this.graphQlHandler = graphQlHandler;
59+
super(graphQlHandler, null);
6560
}
6661

6762
/**
@@ -72,7 +67,7 @@ public GraphQlSseHandler(WebGraphQlHandler graphQlHandler) {
7267
*/
7368
@SuppressWarnings("unchecked")
7469
public Mono<ServerResponse> handleRequest(ServerRequest serverRequest) {
75-
Flux<ServerSentEvent<Map<String, Object>>> data = serverRequest.bodyToMono(SerializableGraphQlRequest.class)
70+
Flux<ServerSentEvent<Map<String, Object>>> data = readRequest(serverRequest)
7671
.flatMap(body -> {
7772
WebGraphQlRequest graphQlRequest = new WebGraphQlRequest(
7873
serverRequest.uri(), serverRequest.headers().asHttpHeaders(),

spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlWebSocketHandler.java

+8-8
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ public class GraphQlWebSocketHandler implements WebSocketHandler {
7272

7373
private final WebSocketGraphQlInterceptor webSocketInterceptor;
7474

75-
private final CodecDelegate codecDelegate;
75+
private final WebSocketCodecDelegate webSocketCodecDelegate;
7676

7777
private final Duration initTimeoutDuration;
7878

@@ -91,7 +91,7 @@ public GraphQlWebSocketHandler(
9191

9292
this.graphQlHandler = graphQlHandler;
9393
this.webSocketInterceptor = this.graphQlHandler.getWebSocketInterceptor();
94-
this.codecDelegate = new CodecDelegate(codecConfigurer);
94+
this.webSocketCodecDelegate = new WebSocketCodecDelegate(codecConfigurer);
9595
this.initTimeoutDuration = connectionInitTimeout;
9696
}
9797

@@ -137,7 +137,7 @@ public Mono<Void> handle(WebSocketSession session) {
137137
.subscribe();
138138

139139
return session.send(session.receive().flatMap(webSocketMessage -> {
140-
GraphQlWebSocketMessage message = this.codecDelegate.decode(webSocketMessage);
140+
GraphQlWebSocketMessage message = this.webSocketCodecDelegate.decode(webSocketMessage);
141141
String id = message.getId();
142142
Map<String, Object> payload = message.getPayload();
143143
switch (message.resolvedType()) {
@@ -159,7 +159,7 @@ public Mono<Void> handle(WebSocketSession session) {
159159
.doOnTerminate(() -> subscriptions.remove(id));
160160
}
161161
case PING -> {
162-
return Flux.just(this.codecDelegate.encode(session, GraphQlWebSocketMessage.pong(null)));
162+
return Flux.just(this.webSocketCodecDelegate.encode(session, GraphQlWebSocketMessage.pong(null)));
163163
}
164164
case COMPLETE -> {
165165
if (id != null) {
@@ -178,7 +178,7 @@ public Mono<Void> handle(WebSocketSession session) {
178178
}
179179
return this.webSocketInterceptor.handleConnectionInitialization(sessionInfo, payload)
180180
.defaultIfEmpty(Collections.emptyMap())
181-
.map(ackPayload -> this.codecDelegate.encodeConnectionAck(session, ackPayload))
181+
.map(ackPayload -> this.webSocketCodecDelegate.encodeConnectionAck(session, ackPayload))
182182
.flux()
183183
.onErrorResume(ex -> GraphQlStatus.close(session, GraphQlStatus.UNAUTHORIZED_STATUS));
184184
}
@@ -218,14 +218,14 @@ private Flux<WebSocketMessage> handleResponse(WebSocketSession session, String i
218218
}
219219

220220
return responseFlux
221-
.map(responseMap -> this.codecDelegate.encodeNext(session, id, responseMap))
222-
.concatWith(Mono.fromCallable(() -> this.codecDelegate.encodeComplete(session, id)))
221+
.map(responseMap -> this.webSocketCodecDelegate.encodeNext(session, id, responseMap))
222+
.concatWith(Mono.fromCallable(() -> this.webSocketCodecDelegate.encodeComplete(session, id)))
223223
.onErrorResume(ex -> {
224224
if (ex instanceof SubscriptionExistsException) {
225225
CloseStatus status = new CloseStatus(4409, "Subscriber for " + id + " already exists");
226226
return GraphQlStatus.close(session, status);
227227
}
228-
return Mono.fromCallable(() -> this.codecDelegate.encodeError(session, id, ex));
228+
return Mono.fromCallable(() -> this.webSocketCodecDelegate.encodeError(session, id, ex));
229229
});
230230
}
231231

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright 2002-2022 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.springframework.graphql.server.webflux;
17+
18+
import java.util.Map;
19+
20+
import org.reactivestreams.Publisher;
21+
import reactor.core.publisher.Mono;
22+
23+
import org.springframework.core.ResolvableType;
24+
import org.springframework.core.codec.Decoder;
25+
import org.springframework.core.codec.Encoder;
26+
import org.springframework.core.io.buffer.DataBuffer;
27+
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
28+
import org.springframework.graphql.GraphQlResponse;
29+
import org.springframework.graphql.server.support.SerializableGraphQlRequest;
30+
import org.springframework.http.MediaType;
31+
import org.springframework.http.codec.CodecConfigurer;
32+
import org.springframework.http.codec.DecoderHttpMessageReader;
33+
import org.springframework.http.codec.EncoderHttpMessageWriter;
34+
import org.springframework.util.Assert;
35+
import org.springframework.util.MimeTypeUtils;
36+
37+
/**
38+
* Helper class for encoding and decoding GraphQL messages in HTTP transport.
39+
*
40+
* @author Rossen Stoyanchev
41+
* @author Brian Clozel
42+
* @since 1.3.0
43+
*/
44+
final class HttpCodecDelegate {
45+
46+
private static final ResolvableType REQUEST_TYPE = ResolvableType.forClass(SerializableGraphQlRequest.class);
47+
48+
private static final ResolvableType RESPONSE_TYPE = ResolvableType.forClassWithGenerics(Map.class, String.class, Object.class);
49+
50+
51+
private final Decoder<?> decoder;
52+
53+
private final Encoder<?> encoder;
54+
55+
56+
HttpCodecDelegate(CodecConfigurer codecConfigurer) {
57+
Assert.notNull(codecConfigurer, "CodecConfigurer is required");
58+
this.decoder = findJsonDecoder(codecConfigurer);
59+
this.encoder = findJsonEncoder(codecConfigurer);
60+
}
61+
62+
private static Decoder<?> findJsonDecoder(CodecConfigurer configurer) {
63+
return configurer.getReaders().stream()
64+
.filter((reader) -> reader.canRead(REQUEST_TYPE, MediaType.APPLICATION_JSON))
65+
.map((reader) -> ((DecoderHttpMessageReader<?>) reader).getDecoder())
66+
.findFirst()
67+
.orElseThrow(() -> new IllegalArgumentException("No JSON Decoder"));
68+
}
69+
70+
private static Encoder<?> findJsonEncoder(CodecConfigurer configurer) {
71+
return configurer.getWriters().stream()
72+
.filter((writer) -> writer.canWrite(RESPONSE_TYPE, MediaType.APPLICATION_JSON))
73+
.map((writer) -> ((EncoderHttpMessageWriter<?>) writer).getEncoder())
74+
.findFirst()
75+
.orElseThrow(() -> new IllegalArgumentException("No JSON Encoder"));
76+
}
77+
78+
79+
@SuppressWarnings("unchecked")
80+
public DataBuffer encode(GraphQlResponse response) {
81+
return ((Encoder<Map<String, Object>>) this.encoder)
82+
.encodeValue(response.toMap(), DefaultDataBufferFactory.sharedInstance, RESPONSE_TYPE, MimeTypeUtils.APPLICATION_JSON, null);
83+
}
84+
85+
@SuppressWarnings("unchecked")
86+
public Mono<SerializableGraphQlRequest> decode(Publisher<DataBuffer> inputStream, MediaType contentType) {
87+
return (Mono<SerializableGraphQlRequest>) this.decoder.decodeToMono(inputStream, REQUEST_TYPE, contentType, null);
88+
}
89+
90+
}

spring-graphql/src/main/java/org/springframework/graphql/server/webflux/CodecDelegate.java renamed to spring-graphql/src/main/java/org/springframework/graphql/server/webflux/WebSocketCodecDelegate.java

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2022 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -40,12 +40,12 @@
4040
import org.springframework.web.reactive.socket.WebSocketSession;
4141

4242
/**
43-
* Helper class for encoding and decoding GraphQL messages.
43+
* Helper class for encoding and decoding GraphQL messages in WebSocket transport.
4444
*
4545
* @author Rossen Stoyanchev
4646
* @since 1.0.0
4747
*/
48-
final class CodecDelegate {
48+
final class WebSocketCodecDelegate {
4949

5050
private static final ResolvableType MESSAGE_TYPE = ResolvableType.forClass(GraphQlWebSocketMessage.class);
5151

@@ -55,7 +55,7 @@ final class CodecDelegate {
5555
private final Encoder<?> encoder;
5656

5757

58-
CodecDelegate(CodecConfigurer codecConfigurer) {
58+
WebSocketCodecDelegate(CodecConfigurer codecConfigurer) {
5959
Assert.notNull(codecConfigurer, "CodecConfigurer is required");
6060
this.decoder = findJsonDecoder(codecConfigurer);
6161
this.encoder = findJsonEncoder(codecConfigurer);

0 commit comments

Comments
 (0)