Skip to content

Commit 683cc2e

Browse files
committed
Add NDJSON and deprecate application/stream+json
Closes gh-21283
1 parent 354635e commit 683cc2e

File tree

17 files changed

+144
-46
lines changed

17 files changed

+144
-46
lines changed

spring-web/src/jmh/java/org/springframework/http/MediaTypeBenchmark.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ public void fillCache() {
8888
"application/problem+json",
8989
"application/xhtml+xml",
9090
"application/rss+xml",
91-
"application/stream+json",
91+
"application/x-ndjson",
9292
"application/xml;q=0.9",
9393
"application/atom+xml",
9494
"application/cbor",

spring-web/src/main/java/org/springframework/http/MediaType.java

+21
Original file line numberDiff line numberDiff line change
@@ -216,16 +216,36 @@ public class MediaType extends MimeType implements Serializable {
216216
*/
217217
public static final String APPLICATION_RSS_XML_VALUE = "application/rss+xml";
218218

219+
/**
220+
* Public constant media type for {@code application/x-ndjson}.
221+
* @since 5.3
222+
*/
223+
public static final MediaType APPLICATION_NDJSON;
224+
225+
/**
226+
* A String equivalent of {@link MediaType#APPLICATION_NDJSON}.
227+
* @since 5.3
228+
*/
229+
public static final String APPLICATION_NDJSON_VALUE = "application/x-ndjson";
230+
219231
/**
220232
* Public constant media type for {@code application/stream+json}.
233+
* @deprecated as of 5.3, see notice on {@link #APPLICATION_STREAM_JSON_VALUE}.
221234
* @since 5.0
222235
*/
236+
@Deprecated
223237
public static final MediaType APPLICATION_STREAM_JSON;
224238

225239
/**
226240
* A String equivalent of {@link MediaType#APPLICATION_STREAM_JSON}.
241+
* @deprecated as of 5.3 since it originates from the W3C Activity Streams
242+
* specification which has a more specific purpose and has been since
243+
* replaced with a different mime type. Use {@link #APPLICATION_NDJSON} as
244+
* a replacement or any other line-delimited JSON format (e.g. JSON Lines,
245+
* JSON Text Sequences).
227246
* @since 5.0
228247
*/
248+
@Deprecated
229249
public static final String APPLICATION_STREAM_JSON_VALUE = "application/stream+json";
230250

231251
/**
@@ -378,6 +398,7 @@ public class MediaType extends MimeType implements Serializable {
378398
APPLICATION_FORM_URLENCODED = new MediaType("application", "x-www-form-urlencoded");
379399
APPLICATION_JSON = new MediaType("application", "json");
380400
APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
401+
APPLICATION_NDJSON = new MediaType("application", "x-ndjson");
381402
APPLICATION_OCTET_STREAM = new MediaType("application", "octet-stream");
382403
APPLICATION_PDF = new MediaType("application", "pdf");
383404
APPLICATION_PROBLEM_JSON = new MediaType("application", "problem+json");

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

+10-12
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,9 @@ public abstract class AbstractJackson2Encoder extends Jackson2CodecSupport imple
6767

6868
private static final byte[] NEWLINE_SEPARATOR = {'\n'};
6969

70-
private static final Map<MediaType, byte[]> STREAM_SEPARATORS;
71-
7270
private static final Map<String, JsonEncoding> ENCODINGS;
7371

7472
static {
75-
STREAM_SEPARATORS = new HashMap<>(4);
76-
STREAM_SEPARATORS.put(MediaType.APPLICATION_STREAM_JSON, NEWLINE_SEPARATOR);
77-
STREAM_SEPARATORS.put(MediaType.parseMediaType("application/stream+x-jackson-smile"), new byte[0]);
78-
7973
ENCODINGS = new HashMap<>(JsonEncoding.values().length + 1);
8074
for (JsonEncoding encoding : JsonEncoding.values()) {
8175
ENCODINGS.put(encoding.getJavaName(), encoding);
@@ -98,9 +92,6 @@ protected AbstractJackson2Encoder(ObjectMapper mapper, MimeType... mimeTypes) {
9892
/**
9993
* Configure "streaming" media types for which flushing should be performed
10094
* automatically vs at the end of the stream.
101-
* <p>By default this is set to {@link MediaType#APPLICATION_STREAM_JSON}.
102-
* @param mediaTypes one or more media types to add to the list
103-
* @see HttpMessageEncoder#getStreamingMediaTypes()
10495
*/
10596
public void setStreamingMediaTypes(List<MediaType> mediaTypes) {
10697
this.streamingMediaTypes.clear();
@@ -138,7 +129,7 @@ public Flux<DataBuffer> encode(Publisher<?> inputStream, DataBufferFactory buffe
138129
.flux();
139130
}
140131
else {
141-
byte[] separator = streamSeparator(mimeType);
132+
byte[] separator = getStreamingMediaTypeSeparator(mimeType);
142133
if (separator != null) { // streaming
143134
try {
144135
ObjectWriter writer = createObjectWriter(elementType, mimeType, hints);
@@ -268,11 +259,18 @@ protected ObjectWriter customizeWriter(ObjectWriter writer, @Nullable MimeType m
268259
return writer;
269260
}
270261

262+
/**
263+
* Return the separator to use for the given mime type.
264+
* <p>By default, this method returns new line {@code "\n"} if the given
265+
* mime type is one of the configured {@link #setStreamingMediaTypes(List)
266+
* streaming} mime types.
267+
* @since 5.3
268+
*/
271269
@Nullable
272-
private byte[] streamSeparator(@Nullable MimeType mimeType) {
270+
protected byte[] getStreamingMediaTypeSeparator(@Nullable MimeType mimeType) {
273271
for (MediaType streamingMediaType : this.streamingMediaTypes) {
274272
if (streamingMediaType.isCompatibleWith(mimeType)) {
275-
return STREAM_SEPARATORS.getOrDefault(streamingMediaType, NEWLINE_SEPARATOR);
273+
return NEWLINE_SEPARATOR;
276274
}
277275
}
278276
return null;

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

+4-2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.springframework.core.ResolvableType;
3636
import org.springframework.core.codec.Hints;
3737
import org.springframework.http.HttpLogging;
38+
import org.springframework.http.MediaType;
3839
import org.springframework.http.server.reactive.ServerHttpRequest;
3940
import org.springframework.http.server.reactive.ServerHttpResponse;
4041
import org.springframework.lang.Nullable;
@@ -72,8 +73,9 @@ public abstract class Jackson2CodecSupport {
7273

7374
private static final List<MimeType> DEFAULT_MIME_TYPES = Collections.unmodifiableList(
7475
Arrays.asList(
75-
new MimeType("application", "json"),
76-
new MimeType("application", "*+json")));
76+
MediaType.APPLICATION_JSON,
77+
new MediaType("application", "*+json"),
78+
MediaType.APPLICATION_NDJSON));
7779

7880

7981
protected final Log logger = HttpLogging.forLogName(getClass());

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

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2018 the original author or authors.
2+
* Copyright 2002-2020 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.
@@ -16,7 +16,7 @@
1616

1717
package org.springframework.http.codec.json;
1818

19-
import java.util.Collections;
19+
import java.util.Arrays;
2020
import java.util.List;
2121
import java.util.Map;
2222

@@ -54,9 +54,10 @@ public Jackson2JsonEncoder() {
5454
this(Jackson2ObjectMapperBuilder.json().build());
5555
}
5656

57+
@SuppressWarnings("deprecation")
5758
public Jackson2JsonEncoder(ObjectMapper mapper, MimeType... mimeTypes) {
5859
super(mapper, mimeTypes);
59-
setStreamingMediaTypes(Collections.singletonList(MediaType.APPLICATION_STREAM_JSON));
60+
setStreamingMediaTypes(Arrays.asList(MediaType.APPLICATION_NDJSON, MediaType.APPLICATION_STREAM_JSON));
6061
this.ssePrettyPrinter = initSsePrettyPrinter();
6162
}
6263

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

+25-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2019 the original author or authors.
2+
* Copyright 2002-2020 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.
@@ -25,6 +25,7 @@
2525

2626
import org.springframework.http.MediaType;
2727
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
28+
import org.springframework.lang.Nullable;
2829
import org.springframework.util.Assert;
2930
import org.springframework.util.MimeType;
3031

@@ -43,6 +44,11 @@ public class Jackson2SmileEncoder extends AbstractJackson2Encoder {
4344
new MimeType("application", "x-jackson-smile"),
4445
new MimeType("application", "*+x-jackson-smile")};
4546

47+
private static final MimeType STREAM_MIME_TYPE =
48+
MediaType.parseMediaType("application/stream+x-jackson-smile");
49+
50+
private static final byte[] STREAM_SEPARATOR = new byte[0];
51+
4652

4753
public Jackson2SmileEncoder() {
4854
this(Jackson2ObjectMapperBuilder.smile().build(), DEFAULT_SMILE_MIME_TYPES);
@@ -54,4 +60,22 @@ public Jackson2SmileEncoder(ObjectMapper mapper, MimeType... mimeTypes) {
5460
setStreamingMediaTypes(Collections.singletonList(new MediaType("application", "stream+x-jackson-smile")));
5561
}
5662

63+
64+
/**
65+
* Return the separator to use for the given mime type.
66+
* <p>By default, this method returns a single byte 0 if the given
67+
* mime type is one of the configured {@link #setStreamingMediaTypes(List)
68+
* streaming} mime types.
69+
* @since 5.3
70+
*/
71+
@Nullable
72+
@Override
73+
protected byte[] getStreamingMediaTypeSeparator(@Nullable MimeType mimeType) {
74+
for (MediaType streamingMediaType : getStreamingMediaTypes()) {
75+
if (streamingMediaType.isCompatibleWith(mimeType)) {
76+
return STREAM_SEPARATOR;
77+
}
78+
}
79+
return null;
80+
}
5781
}

spring-web/src/test/java/org/springframework/http/codec/json/Jackson2JsonDecoderTests.java

+2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import static org.assertj.core.api.Assertions.assertThat;
5252
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
5353
import static org.springframework.http.MediaType.APPLICATION_JSON;
54+
import static org.springframework.http.MediaType.APPLICATION_NDJSON;
5455
import static org.springframework.http.MediaType.APPLICATION_STREAM_JSON;
5556
import static org.springframework.http.MediaType.APPLICATION_XML;
5657
import static org.springframework.http.codec.json.Jackson2CodecSupport.JSON_VIEW_HINT;
@@ -77,6 +78,7 @@ public Jackson2JsonDecoderTests() {
7778
@Test
7879
public void canDecode() {
7980
assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), APPLICATION_JSON)).isTrue();
81+
assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), APPLICATION_NDJSON)).isTrue();
8082
assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), APPLICATION_STREAM_JSON)).isTrue();
8183
assertThat(decoder.canDecode(ResolvableType.forClass(Pojo.class), null)).isTrue();
8284

spring-web/src/test/java/org/springframework/http/codec/json/Jackson2JsonEncoderTests.java

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2019 the original author or authors.
2+
* Copyright 2002-2020 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.
@@ -47,6 +47,7 @@
4747
import static org.assertj.core.api.Assertions.assertThat;
4848
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
4949
import static org.springframework.http.MediaType.APPLICATION_JSON;
50+
import static org.springframework.http.MediaType.APPLICATION_NDJSON;
5051
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM;
5152
import static org.springframework.http.MediaType.APPLICATION_STREAM_JSON;
5253
import static org.springframework.http.MediaType.APPLICATION_XML;
@@ -66,6 +67,7 @@ public Jackson2JsonEncoderTests() {
6667
public void canEncode() {
6768
ResolvableType pojoType = ResolvableType.forClass(Pojo.class);
6869
assertThat(this.encoder.canEncode(pojoType, APPLICATION_JSON)).isTrue();
70+
assertThat(this.encoder.canEncode(pojoType, APPLICATION_NDJSON)).isTrue();
6971
assertThat(this.encoder.canEncode(pojoType, APPLICATION_STREAM_JSON)).isTrue();
7072
assertThat(this.encoder.canEncode(pojoType, null)).isTrue();
7173

spring-web/src/test/java/org/springframework/http/codec/json/Jackson2TokenizerTests.java

+37
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,43 @@ public void tokenizeArrayElements() {
197197
testTokenize(asList("[1", ",2,", "3]"), asList("1", "2", "3"), true);
198198
}
199199

200+
@Test
201+
void tokenizeStream() {
202+
203+
// NDJSON (Newline Delimited JSON), JSON Lines
204+
testTokenize(
205+
asList(
206+
"{\"id\":1,\"name\":\"Robert\"}",
207+
"\n",
208+
"{\"id\":2,\"name\":\"Raide\"}",
209+
"\n",
210+
"{\"id\":3,\"name\":\"Ford\"}"
211+
),
212+
asList(
213+
"{\"id\":1,\"name\":\"Robert\"}",
214+
"{\"id\":2,\"name\":\"Raide\"}",
215+
"{\"id\":3,\"name\":\"Ford\"}"
216+
),
217+
true);
218+
219+
// JSON Sequence with newline separator
220+
testTokenize(
221+
asList(
222+
"\n",
223+
"{\"id\":1,\"name\":\"Robert\"}",
224+
"\n",
225+
"{\"id\":2,\"name\":\"Raide\"}",
226+
"\n",
227+
"{\"id\":3,\"name\":\"Ford\"}"
228+
),
229+
asList(
230+
"{\"id\":1,\"name\":\"Robert\"}",
231+
"{\"id\":2,\"name\":\"Raide\"}",
232+
"{\"id\":3,\"name\":\"Ford\"}"
233+
),
234+
true);
235+
}
236+
200237
private void testTokenize(List<String> input, List<String> output, boolean tokenize) {
201238
StepVerifier.FirstStep<String> builder = StepVerifier.create(decode(input, tokenize, -1));
202239
output.forEach(expected -> builder.assertNext(actual -> {

spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/JacksonStreamingIntegrationTests.java

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2019 the original author or authors.
2+
* Copyright 2002-2020 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.
@@ -35,8 +35,8 @@
3535
import org.springframework.web.testfixture.http.server.reactive.bootstrap.AbstractHttpHandlerIntegrationTests;
3636
import org.springframework.web.testfixture.http.server.reactive.bootstrap.HttpServer;
3737

38-
import static org.springframework.http.MediaType.APPLICATION_STREAM_JSON;
39-
import static org.springframework.http.MediaType.APPLICATION_STREAM_JSON_VALUE;
38+
import static org.springframework.http.MediaType.APPLICATION_NDJSON;
39+
import static org.springframework.http.MediaType.APPLICATION_NDJSON_VALUE;
4040

4141
/**
4242
* @author Sebastien Deleuze
@@ -71,7 +71,7 @@ void jsonStreaming(HttpServer httpServer) throws Exception {
7171

7272
Flux<Person> result = this.webClient.get()
7373
.uri("/stream")
74-
.accept(APPLICATION_STREAM_JSON)
74+
.accept(APPLICATION_NDJSON)
7575
.retrieve()
7676
.bodyToFlux(Person.class);
7777

@@ -105,7 +105,7 @@ void smileStreaming(HttpServer httpServer) throws Exception {
105105
static class JacksonStreamingController {
106106

107107
@GetMapping(value = "/stream",
108-
produces = { APPLICATION_STREAM_JSON_VALUE, "application/stream+x-jackson-smile" })
108+
produces = { APPLICATION_NDJSON_VALUE, "application/stream+x-jackson-smile" })
109109
Flux<Person> person() {
110110
return testInterval(Duration.ofMillis(100), 50).map(l -> new Person("foo " + l));
111111
}

spring-webflux/src/test/java/org/springframework/web/reactive/result/view/HttpMessageWriterViewTests.java

+4-3
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,11 @@ public class HttpMessageWriterViewTests {
5353

5454

5555
@Test
56-
public void supportedMediaTypes() throws Exception {
57-
assertThat(this.view.getSupportedMediaTypes()).isEqualTo(Arrays.asList(
56+
public void supportedMediaTypes() {
57+
assertThat(this.view.getSupportedMediaTypes()).containsExactly(
5858
MediaType.APPLICATION_JSON,
59-
MediaType.parseMediaType("application/*+json")));
59+
MediaType.parseMediaType("application/*+json"),
60+
MediaType.APPLICATION_NDJSON);
6061
}
6162

6263
@Test

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandler.java

+16-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2019 the original author or authors.
2+
* Copyright 2002-2020 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.
@@ -19,6 +19,7 @@
1919
import java.io.IOException;
2020
import java.time.Duration;
2121
import java.util.ArrayList;
22+
import java.util.Arrays;
2223
import java.util.Collection;
2324
import java.util.List;
2425
import java.util.Optional;
@@ -73,8 +74,12 @@ class ReactiveTypeHandler {
7374

7475
private static final long STREAMING_TIMEOUT_VALUE = -1;
7576

77+
@SuppressWarnings("deprecation")
78+
private static final List<MediaType> JSON_STREAMING_MEDIA_TYPES =
79+
Arrays.asList(MediaType.APPLICATION_NDJSON, MediaType.APPLICATION_STREAM_JSON);
80+
81+
private static final Log logger = LogFactory.getLog(ReactiveTypeHandler.class);
7682

77-
private static Log logger = LogFactory.getLog(ReactiveTypeHandler.class);
7883

7984
private final ReactiveAdapterRegistry adapterRegistry;
8085

@@ -144,11 +149,15 @@ public ResponseBodyEmitter handleValue(Object returnValue, MethodParameter retur
144149
new TextEmitterSubscriber(emitter, this.taskExecutor).connect(adapter, returnValue);
145150
return emitter;
146151
}
147-
if (mediaTypes.stream().anyMatch(MediaType.APPLICATION_STREAM_JSON::includes)) {
148-
logExecutorWarning(returnType);
149-
ResponseBodyEmitter emitter = getEmitter(MediaType.APPLICATION_STREAM_JSON);
150-
new JsonEmitterSubscriber(emitter, this.taskExecutor).connect(adapter, returnValue);
151-
return emitter;
152+
for (MediaType type : mediaTypes) {
153+
for (MediaType streamingType : JSON_STREAMING_MEDIA_TYPES) {
154+
if (streamingType.includes(type)) {
155+
logExecutorWarning(returnType);
156+
ResponseBodyEmitter emitter = getEmitter(streamingType);
157+
new JsonEmitterSubscriber(emitter, this.taskExecutor).connect(adapter, returnValue);
158+
return emitter;
159+
}
160+
}
152161
}
153162
}
154163

0 commit comments

Comments
 (0)