Skip to content

Commit 20aac6d

Browse files
committed
Add Gson codecs for WebFlux
This commit adds new `GsonEncoder` and `GsonDecoder` for serializing and deserializing JSON in a reactive fashion. Because `Gson` itslef does not support decoding JSON in a non-blocking way, the `GsonDecoder` does not support decoding to `Flux<*>` types. Closes gh-27131
1 parent d84bf18 commit 20aac6d

File tree

8 files changed

+574
-0
lines changed

8 files changed

+574
-0
lines changed

framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,20 @@ The `ProtobufJsonDecoder` and `ProtobufJsonEncoder` variants support reading and
545545
They require the "com.google.protobuf:protobuf-java-util" dependency. Note, the JSON variants do not support reading stream of messages,
546546
see the {spring-framework-api}/http/codec/protobuf/ProtobufJsonDecoder.html[javadoc of `ProtobufJsonDecoder`] for more details.
547547

548+
[[webflux-codecs-gson]]
549+
=== Google Gson
550+
551+
Applications can use the `GsonEncoder` and `GsonDecoder` to serialize and deserialize JSON documents thanks to the https://google.github.io/gson/[Google Gson] library .
552+
This codec supports both JSON media types and the NDJSON format for streaming.
553+
554+
[NOTE]
555+
====
556+
`Gson` does not support non-blocking parsing, so the `GsonDecoder` does not support deserializing
557+
to `Flux<*>` types. For example, if this decoder is used for deserializing a JSON stream or even a list of elements
558+
as a `Flux<*>`, an `UnsupportedOperationException` will be thrown at runtime.
559+
Applications should instead focus on deserializing bounded collections and use `Mono<List<*>>` as target types.
560+
====
561+
548562
[[webflux-codecs-limits]]
549563
=== Limits
550564

spring-web/src/main/java/org/springframework/http/codec/CodecConfigurer.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,20 @@ interface DefaultCodecs {
142142
*/
143143
void jackson2JsonEncoder(Encoder<?> encoder);
144144

145+
/**
146+
* Override the default Gson {@code Decoder}.
147+
* @param decoder the decoder instance to use
148+
* @see org.springframework.http.codec.json.GsonDecoder
149+
*/
150+
void gsonDecoder(Decoder<?> decoder);
151+
152+
/**
153+
* Override the default Gson {@code Encoder}.
154+
* @param encoder the encoder instance to use
155+
* @see org.springframework.http.codec.json.GsonEncoder
156+
*/
157+
void gsonEncoder(Encoder<?> encoder);
158+
145159
/**
146160
* Override the default Jackson 3.x Smile {@code Decoder}.
147161
* <p>Note that {@link #maxInMemorySize(int)}, if configured, will be
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* Copyright 2002-present 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.http.codec.json;
18+
19+
import java.io.InputStreamReader;
20+
import java.util.Map;
21+
22+
import com.google.gson.Gson;
23+
import org.jspecify.annotations.Nullable;
24+
import org.reactivestreams.Publisher;
25+
import reactor.core.publisher.Flux;
26+
27+
import org.springframework.core.ResolvableType;
28+
import org.springframework.core.codec.AbstractDataBufferDecoder;
29+
import org.springframework.core.codec.Decoder;
30+
import org.springframework.core.codec.DecodingException;
31+
import org.springframework.core.io.buffer.DataBuffer;
32+
import org.springframework.core.io.buffer.DataBufferUtils;
33+
import org.springframework.http.MediaType;
34+
import org.springframework.util.Assert;
35+
import org.springframework.util.MimeType;
36+
37+
/**
38+
* {@link Decoder} that reads a byte stream into JSON and converts it to Objects with
39+
* <a href="https://google.github.io/gson/">Google Gson</a>.
40+
* <p>{@code Flux<*>} target types are not available because non-blocking parsing is not supported,
41+
* so this decoder targets only {@code Mono<*>} types. Attempting to decode to a {@code Flux<*>} will
42+
* result in a {@link UnsupportedOperationException} being thrown at runtime.
43+
*
44+
* @author Brian Clozel
45+
* @since 7.0
46+
*/
47+
public class GsonDecoder extends AbstractDataBufferDecoder<Object> {
48+
49+
private static final MimeType[] DEFAULT_JSON_MIME_TYPES = new MimeType[] {
50+
MediaType.APPLICATION_JSON,
51+
new MediaType("application", "*+json"),
52+
};
53+
54+
private final Gson gson;
55+
56+
/**
57+
* Construct a new decoder using a default {@link Gson} instance
58+
* and the {@code "application/json"} and {@code "application/*+json"}
59+
* MIME types.
60+
*/
61+
public GsonDecoder() {
62+
this(new Gson(), DEFAULT_JSON_MIME_TYPES);
63+
}
64+
65+
/**
66+
* Construct a new decoder using the given {@link Gson} instance
67+
* and the provided MIME types.
68+
* @param gson the gson instance to use
69+
* @param mimeTypes the mime types the decoder should support
70+
*/
71+
public GsonDecoder(Gson gson, MimeType... mimeTypes) {
72+
super(mimeTypes);
73+
Assert.notNull(gson, "A Gson instance is required");
74+
this.gson = gson;
75+
}
76+
77+
78+
@Override
79+
public boolean canDecode(ResolvableType elementType, @Nullable MimeType mimeType) {
80+
if (!super.canDecode(elementType, mimeType)) {
81+
return false;
82+
}
83+
return !CharSequence.class.isAssignableFrom(elementType.toClass());
84+
}
85+
86+
@Override
87+
public Flux<Object> decode(Publisher<DataBuffer> inputStream, ResolvableType elementType, @Nullable MimeType mimeType, @Nullable Map<String, Object> hints) {
88+
throw new UnsupportedOperationException("Stream decoding is currently not supported");
89+
}
90+
91+
@Override
92+
public @Nullable Object decode(DataBuffer buffer, ResolvableType targetType, @Nullable MimeType mimeType, @Nullable Map<String, Object> hints) throws DecodingException {
93+
try {
94+
return this.gson.fromJson(new InputStreamReader(buffer.asInputStream()), targetType.getType());
95+
}
96+
finally {
97+
DataBufferUtils.release(buffer);
98+
}
99+
}
100+
101+
}
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/*
2+
* Copyright 2002-present 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.http.codec.json;
18+
19+
import java.io.IOException;
20+
import java.io.OutputStreamWriter;
21+
import java.nio.charset.StandardCharsets;
22+
import java.util.ArrayList;
23+
import java.util.Collections;
24+
import java.util.List;
25+
import java.util.Map;
26+
27+
import com.google.gson.Gson;
28+
import org.jspecify.annotations.Nullable;
29+
import org.reactivestreams.Publisher;
30+
import reactor.core.publisher.Flux;
31+
import reactor.core.publisher.Mono;
32+
33+
import org.springframework.core.ResolvableType;
34+
import org.springframework.core.codec.AbstractEncoder;
35+
import org.springframework.core.codec.EncodingException;
36+
import org.springframework.core.io.buffer.DataBuffer;
37+
import org.springframework.core.io.buffer.DataBufferFactory;
38+
import org.springframework.http.MediaType;
39+
import org.springframework.http.codec.HttpMessageEncoder;
40+
import org.springframework.util.Assert;
41+
import org.springframework.util.FastByteArrayOutputStream;
42+
import org.springframework.util.MimeType;
43+
44+
/**
45+
* Encode from an {@code Object} stream to a byte stream of JSON objects using
46+
* <a href="https://google.github.io/gson/">Google Gson</a>.
47+
*
48+
* @author Brian Clozel
49+
* @since 7.0
50+
*/
51+
public class GsonEncoder extends AbstractEncoder<Object> implements HttpMessageEncoder<Object> {
52+
53+
private static final byte[] NEWLINE_SEPARATOR = {'\n'};
54+
55+
private static final byte[] EMPTY_BYTES = new byte[0];
56+
57+
private static final MimeType[] DEFAULT_JSON_MIME_TYPES = new MimeType[] {
58+
MediaType.APPLICATION_JSON,
59+
new MediaType("application", "*+json"),
60+
MediaType.APPLICATION_NDJSON
61+
};
62+
63+
private final Gson gson;
64+
65+
private final List<MediaType> streamingMediaTypes = new ArrayList<>(1);
66+
67+
/**
68+
* Construct a new encoder using a default {@link Gson} instance
69+
* and the {@code "application/json"} and {@code "application/*+json"}
70+
* MIME types. The {@code "application/x-ndjson"} is configured for streaming.
71+
*/
72+
public GsonEncoder() {
73+
this(new Gson(), DEFAULT_JSON_MIME_TYPES);
74+
setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON));
75+
}
76+
77+
/**
78+
* Construct a new encoder using the given {@link Gson} instance
79+
* and the provided MIME types. Use {@link #setStreamingMediaTypes(List)}
80+
* for configuring streaming media types.
81+
* @param gson the gson instance to use
82+
* @param mimeTypes the mime types the decoder should support
83+
*/
84+
public GsonEncoder(Gson gson, MimeType... mimeTypes) {
85+
super(mimeTypes);
86+
Assert.notNull(gson, "A Gson instance is required");
87+
this.gson = gson;
88+
}
89+
90+
/**
91+
* Configure "streaming" media types for which flushing should be performed
92+
* automatically vs at the end of the stream.
93+
*/
94+
public void setStreamingMediaTypes(List<MediaType> mediaTypes) {
95+
this.streamingMediaTypes.clear();
96+
this.streamingMediaTypes.addAll(mediaTypes);
97+
}
98+
99+
@Override
100+
public List<MediaType> getStreamingMediaTypes() {
101+
return Collections.unmodifiableList(this.streamingMediaTypes);
102+
}
103+
104+
@Override
105+
public boolean canEncode(ResolvableType elementType, @Nullable MimeType mimeType) {
106+
if (!super.canEncode(elementType, mimeType)) {
107+
return false;
108+
}
109+
Class<?> clazz = elementType.toClass();
110+
return !String.class.isAssignableFrom(elementType.resolve(clazz));
111+
}
112+
113+
@Override
114+
public Flux<DataBuffer> encode(Publisher<?> inputStream, DataBufferFactory bufferFactory, ResolvableType elementType,
115+
@Nullable MimeType mimeType, @Nullable Map<String, Object> hints) {
116+
117+
boolean isStreaming = isStreamingMediaType(mimeType);
118+
if (isStreaming) {
119+
return Flux.from(inputStream).map(message -> encodeValue(message, bufferFactory, EMPTY_BYTES, NEWLINE_SEPARATOR));
120+
}
121+
else {
122+
JsonArrayJoinHelper helper = new JsonArrayJoinHelper();
123+
// Do not prepend JSON array prefix until first signal is known, onNext vs onError
124+
// Keeps response not committed for error handling
125+
return Flux.from(inputStream)
126+
.map(value -> {
127+
byte[] prefix = helper.getPrefix();
128+
byte[] delimiter = helper.getDelimiter();
129+
DataBuffer dataBuffer = encodeValue(value, bufferFactory, delimiter, EMPTY_BYTES);
130+
return (prefix.length > 0 ?
131+
bufferFactory.join(List.of(bufferFactory.wrap(prefix), dataBuffer)) :
132+
dataBuffer);
133+
})
134+
.switchIfEmpty(Mono.fromCallable(() -> bufferFactory.wrap(helper.getPrefix())))
135+
.concatWith(Mono.fromCallable(() -> bufferFactory.wrap(helper.getSuffix())));
136+
}
137+
}
138+
139+
@Override
140+
public DataBuffer encodeValue(Object value, DataBufferFactory bufferFactory, ResolvableType valueType,
141+
@Nullable MimeType mimeType, @Nullable Map<String, Object> hints) {
142+
return encodeValue(value, bufferFactory, EMPTY_BYTES, EMPTY_BYTES);
143+
}
144+
145+
private DataBuffer encodeValue(Object value, DataBufferFactory bufferFactory,
146+
byte[] prefix, byte[] suffix) {
147+
try {
148+
FastByteArrayOutputStream bos = new FastByteArrayOutputStream();
149+
OutputStreamWriter writer = new OutputStreamWriter(bos, StandardCharsets.UTF_8);
150+
bos.write(prefix);
151+
this.gson.toJson(value, writer);
152+
writer.flush();
153+
bos.write(suffix);
154+
byte[] bytes = bos.toByteArrayUnsafe();
155+
return bufferFactory.wrap(bytes);
156+
}
157+
catch (IOException ex) {
158+
throw new EncodingException("JSON encoding error: " + ex.getMessage(), ex);
159+
}
160+
}
161+
162+
/**
163+
* Return the separator to use for the given mime type.
164+
* <p>By default, this method returns new line {@code "\n"} if the given
165+
* mime type is one of the configured {@link #setStreamingMediaTypes(List)
166+
* streaming} mime types.
167+
*/
168+
protected boolean isStreamingMediaType(@Nullable MimeType mimeType) {
169+
for (MediaType streamingMediaType : this.streamingMediaTypes) {
170+
if (streamingMediaType.isCompatibleWith(mimeType)) {
171+
return true;
172+
}
173+
}
174+
return false;
175+
}
176+
177+
178+
private static class JsonArrayJoinHelper {
179+
180+
private static final byte[] COMMA_SEPARATOR = {','};
181+
182+
private static final byte[] OPEN_BRACKET = {'['};
183+
184+
private static final byte[] CLOSE_BRACKET = {']'};
185+
186+
private boolean firstItemEmitted;
187+
188+
public byte[] getDelimiter() {
189+
if (this.firstItemEmitted) {
190+
return COMMA_SEPARATOR;
191+
}
192+
this.firstItemEmitted = true;
193+
return EMPTY_BYTES;
194+
}
195+
196+
public byte[] getPrefix() {
197+
return (this.firstItemEmitted ? EMPTY_BYTES : OPEN_BRACKET);
198+
}
199+
200+
public byte[] getSuffix() {
201+
return CLOSE_BRACKET;
202+
}
203+
}
204+
205+
}

0 commit comments

Comments
 (0)