From 22e4d84993105c1975d6afe16d34467772c7c676 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 17 Mar 2026 12:18:23 +0100 Subject: [PATCH] Add support for "application/jsonl" JSON lines Prior to this commit, Spring web frameworks were using the "application/x-ndjson" media type for streaming JSON payloads delimited with newlines. The "application/jsonl" media type seems to gain popularity in the broader ecosystem and could supersede NDJSON in the future. This commit adds support for JSON Lines as an alternative. Closes gh-36485 --- .../modules/ROOT/pages/testing/webtestclient.adoc | 4 ++-- .../ROOT/pages/web/webflux/reactive-spring.adoc | 6 +++--- .../ROOT/pages/web/webmvc/mvc-ann-async.adoc | 4 ++-- .../java/org/springframework/http/MediaType.java | 14 ++++++++++++++ .../http/codec/json/GsonEncoder.java | 8 +++++--- .../http/codec/json/Jackson2CodecSupport.java | 3 ++- .../http/codec/json/Jackson2JsonEncoder.java | 2 +- .../http/codec/json/JacksonJsonDecoder.java | 3 ++- .../http/codec/json/JacksonJsonEncoder.java | 7 ++++--- .../codec/json/KotlinSerializationJsonDecoder.java | 3 ++- .../codec/json/KotlinSerializationJsonEncoder.java | 9 +++++---- .../http/codec/protobuf/ProtobufJsonEncoder.java | 2 +- .../result/view/HttpMessageWriterViewTests.java | 3 ++- .../mvc/method/annotation/ReactiveTypeHandler.java | 13 ++++++++----- .../web/servlet/config/spring-mvc.xsd | 2 +- .../annotation/ReactiveTypeHandlerTests.java | 11 +++++++---- 16 files changed, 61 insertions(+), 33 deletions(-) diff --git a/framework-docs/modules/ROOT/pages/testing/webtestclient.adoc b/framework-docs/modules/ROOT/pages/testing/webtestclient.adoc index 1d86864e0d2..b2bd9807be2 100644 --- a/framework-docs/modules/ROOT/pages/testing/webtestclient.adoc +++ b/framework-docs/modules/ROOT/pages/testing/webtestclient.adoc @@ -580,8 +580,8 @@ Kotlin:: [[webtestclient-stream]] ==== Streaming Responses -To test potentially infinite streams such as `"text/event-stream"` or -`"application/x-ndjson"`, start by verifying the response status and headers, and then +To test potentially infinite streams such as `"text/event-stream"`, +`"application/jsonl"` or `"application/x-ndjson"`, start by verifying the response status and headers, and then obtain a `FluxExchangeResult`: [tabs] diff --git a/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc b/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc index 99e0d09913a..54e44b52822 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/reactive-spring.adoc @@ -485,8 +485,8 @@ The `JacksonJsonEncoder` works as follows: * For a multi-value publisher with `application/json`, by default collect the values with `Flux#collectToList()` and then serialize the resulting collection. * For a multi-value publisher with a streaming media type such as -`application/x-ndjson` or `application/stream+x-jackson-smile`, encode, write, and -flush each value individually using a +`application/jsonl`, `application/x-ndjson` or `application/stream+x-jackson-smile`, +encode, write, and flush each value individually using a https://en.wikipedia.org/wiki/JSON_streaming[line-delimited JSON] format. Other streaming media types may be registered with the encoder. * For SSE the `JacksonJsonEncoder` is invoked per event and the output is flushed to ensure @@ -598,7 +598,7 @@ To configure all three in WebFlux, you'll need to supply a pre-configured instan [.small]#xref:web/webmvc/mvc-ann-async.adoc#mvc-ann-async-http-streaming[See equivalent in the Servlet stack]# When streaming to the HTTP response (for example, `text/event-stream`, -`application/x-ndjson`), it is important to send data periodically, in order to +`application/jsonl`, `application/x-ndjson`), it is important to send data periodically, in order to reliably detect a disconnected client sooner rather than later. Such a send could be a comment-only, empty SSE event or any other "no-op" data that would effectively serve as a heartbeat. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-async.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-async.adoc index 17779b8aeb6..137da77f960 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-async.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-ann-async.adoc @@ -423,8 +423,8 @@ Reactive return values are handled as follows: * A single-value promise is adapted to, similar to using `DeferredResult`. Examples include `CompletionStage` (JDK), `Mono` (Reactor), and `Single` (RxJava). -* A multi-value stream with a streaming media type (such as `application/x-ndjson` -or `text/event-stream`) is adapted to, similar to using `ResponseBodyEmitter` or +* A multi-value stream with a streaming media type (such as `application/jsonl`, +`application/x-ndjson` or `text/event-stream`) is adapted to, similar to using `ResponseBodyEmitter` or `SseEmitter`. Examples include `Flux` (Reactor) or `Observable` (RxJava). Applications can also return `Flux` or `Observable`. * A multi-value stream with any other media type (such as `application/json`) is adapted diff --git a/spring-web/src/main/java/org/springframework/http/MediaType.java b/spring-web/src/main/java/org/springframework/http/MediaType.java index 1d7ecf7fff9..578b7882241 100644 --- a/spring-web/src/main/java/org/springframework/http/MediaType.java +++ b/spring-web/src/main/java/org/springframework/http/MediaType.java @@ -209,6 +209,19 @@ public class MediaType extends MimeType implements Serializable { */ public static final String APPLICATION_NDJSON_VALUE = "application/x-ndjson"; + /** + * Media type for {@code application/jsonl} (JSON Lines). + * @since 7.1 + * @see JSON Lines + */ + public static final MediaType APPLICATION_JSONL; + + /** + * A String equivalent of {@link MediaType#APPLICATION_JSONL}. + * @since 7.1 + */ + public static final String APPLICATION_JSONL_VALUE = "application/jsonl"; + /** * Media type for {@code application/xhtml+xml}. */ @@ -372,6 +385,7 @@ public class MediaType extends MimeType implements Serializable { APPLICATION_GRAPHQL_RESPONSE = new MediaType("application", "graphql-response+json"); APPLICATION_JSON = new MediaType("application", "json"); APPLICATION_NDJSON = new MediaType("application", "x-ndjson"); + APPLICATION_JSONL = new MediaType("application", "jsonl"); APPLICATION_OCTET_STREAM = new MediaType("application", "octet-stream"); APPLICATION_PDF = new MediaType("application", "pdf"); APPLICATION_PROBLEM_JSON = new MediaType("application", "problem+json"); diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/GsonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/GsonEncoder.java index 7ce4130ee31..1c80af93bc7 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/GsonEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/GsonEncoder.java @@ -58,7 +58,8 @@ public class GsonEncoder extends AbstractEncoder implements HttpMessageE private static final MimeType[] DEFAULT_JSON_MIME_TYPES = new MimeType[] { MediaType.APPLICATION_JSON, new MediaType("application", "*+json"), - MediaType.APPLICATION_NDJSON + MediaType.APPLICATION_NDJSON, + MediaType.APPLICATION_JSONL }; private final Gson gson; @@ -68,11 +69,12 @@ public class GsonEncoder extends AbstractEncoder implements HttpMessageE /** * Construct a new encoder using a default {@link Gson} instance * and the {@code "application/json"} and {@code "application/*+json"} - * MIME types. The {@code "application/x-ndjson"} is configured for streaming. + * MIME types. The {@code "application/jsonl"} and {@code "application/x-ndjson"} + * are configured for streaming. */ public GsonEncoder() { this(new Gson(), DEFAULT_JSON_MIME_TYPES); - setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON)); + setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON, MediaType.APPLICATION_JSONL)); } /** diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java b/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java index b9f0355cbbb..fbcebe25f23 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2CodecSupport.java @@ -81,7 +81,8 @@ public abstract class Jackson2CodecSupport { private static final List defaultMimeTypes = List.of( MediaType.APPLICATION_JSON, new MediaType("application", "*+json"), - MediaType.APPLICATION_NDJSON); + MediaType.APPLICATION_NDJSON, + MediaType.APPLICATION_JSONL); protected final Log logger = HttpLogging.forLogName(getClass()); diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2JsonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2JsonEncoder.java index b5db02e692e..274780fda31 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2JsonEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/Jackson2JsonEncoder.java @@ -63,7 +63,7 @@ public class Jackson2JsonEncoder extends AbstractJackson2Encoder { public Jackson2JsonEncoder(ObjectMapper mapper, MimeType... mimeTypes) { super(mapper, mimeTypes); - setStreamingMediaTypes(Arrays.asList(MediaType.APPLICATION_NDJSON)); + setStreamingMediaTypes(Arrays.asList(MediaType.APPLICATION_NDJSON, MediaType.APPLICATION_JSONL)); this.ssePrettyPrinter = initSsePrettyPrinter(); } diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java index 73cb908d6fe..5d0dd45e4f7 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonDecoder.java @@ -55,7 +55,8 @@ public class JacksonJsonDecoder extends AbstractJacksonDecoder { private static final MimeType[] DEFAULT_JSON_MIME_TYPES = new MimeType[] { MediaType.APPLICATION_JSON, new MediaType("application", "*+json"), - MediaType.APPLICATION_NDJSON + MediaType.APPLICATION_NDJSON, + MediaType.APPLICATION_JSONL }; diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java index e8a14d01b03..57b55106834 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/JacksonJsonEncoder.java @@ -55,7 +55,8 @@ public class JacksonJsonEncoder extends AbstractJacksonEncoder { private static final MimeType[] DEFAULT_JSON_MIME_TYPES = new MimeType[] { MediaType.APPLICATION_JSON, new MediaType("application", "*+json"), - MediaType.APPLICATION_NDJSON + MediaType.APPLICATION_NDJSON, + MediaType.APPLICATION_JSONL }; @@ -99,7 +100,7 @@ public class JacksonJsonEncoder extends AbstractJacksonEncoder { */ public JacksonJsonEncoder(JsonMapper.Builder builder, MimeType... mimeTypes) { super(builder.addMixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class), mimeTypes); - setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON)); + setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON, MediaType.APPLICATION_JSONL)); this.ssePrettyPrinter = initSsePrettyPrinter(); } @@ -110,7 +111,7 @@ public class JacksonJsonEncoder extends AbstractJacksonEncoder { */ public JacksonJsonEncoder(JsonMapper mapper, MimeType... mimeTypes) { super(mapper, mimeTypes); - setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON)); + setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON, MediaType.APPLICATION_JSONL)); this.ssePrettyPrinter = initSsePrettyPrinter(); } diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java index 227f320987c..dcce9489568 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonDecoder.java @@ -56,7 +56,8 @@ public class KotlinSerializationJsonDecoder extends KotlinSerializationStringDec private static final MimeType[] DEFAULT_JSON_MIME_TYPES = new MimeType[] { MediaType.APPLICATION_JSON, new MediaType("application", "*+json"), - MediaType.APPLICATION_NDJSON + MediaType.APPLICATION_NDJSON, + MediaType.APPLICATION_JSONL }; /** diff --git a/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonEncoder.java index 4c3e22ec054..0701615a6ea 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/json/KotlinSerializationJsonEncoder.java @@ -36,7 +36,7 @@ import org.springframework.util.MimeType; /** * Encode from an {@code Object} stream to a byte stream of JSON objects using * kotlinx.serialization. - * It supports {@code application/json}, {@code application/x-ndjson} and {@code application/*+json} with + * It supports {@code application/json}, {@code application/x-ndjson}, {@code application/jsonl} and {@code application/*+json} with * various character sets, {@code UTF-8} being the default. * *

As of Spring Framework 7.0, by default it only encodes types annotated with @@ -59,7 +59,8 @@ public class KotlinSerializationJsonEncoder extends KotlinSerializationStringEnc private static final MimeType[] DEFAULT_JSON_MIME_TYPES = new MimeType[] { MediaType.APPLICATION_JSON, new MediaType("application", "*+json"), - MediaType.APPLICATION_NDJSON + MediaType.APPLICATION_NDJSON, + MediaType.APPLICATION_JSONL }; /** @@ -87,7 +88,7 @@ public class KotlinSerializationJsonEncoder extends KotlinSerializationStringEnc */ public KotlinSerializationJsonEncoder(Json json) { super(json, DEFAULT_JSON_MIME_TYPES); - setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON)); + setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON, MediaType.APPLICATION_JSONL)); } /** @@ -97,7 +98,7 @@ public class KotlinSerializationJsonEncoder extends KotlinSerializationStringEnc */ public KotlinSerializationJsonEncoder(Json json, Predicate typePredicate) { super(json, typePredicate, DEFAULT_JSON_MIME_TYPES); - setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON)); + setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON, MediaType.APPLICATION_JSONL)); } @Override diff --git a/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoder.java b/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoder.java index 73ef356bd1a..ef42819cd3c 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoder.java +++ b/spring-web/src/main/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoder.java @@ -80,7 +80,7 @@ public class ProtobufJsonEncoder implements HttpMessageEncoder { @Override public List getStreamingMediaTypes() { - return List.of(MediaType.APPLICATION_NDJSON); + return List.of(MediaType.APPLICATION_NDJSON, MediaType.APPLICATION_JSONL); } @Override diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/HttpMessageWriterViewTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/HttpMessageWriterViewTests.java index c935e60c776..71a04a610eb 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/HttpMessageWriterViewTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/view/HttpMessageWriterViewTests.java @@ -55,7 +55,8 @@ class HttpMessageWriterViewTests { assertThat(this.view.getSupportedMediaTypes()).containsExactly( MediaType.APPLICATION_JSON, MediaType.parseMediaType("application/*+json"), - MediaType.APPLICATION_NDJSON); + MediaType.APPLICATION_NDJSON, + MediaType.APPLICATION_JSONL); } @Test diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandler.java index 1894fb0fe46..1833702d371 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandler.java @@ -194,11 +194,11 @@ class ReactiveTypeHandler { } /** - * Attempts to find a concrete {@code MediaType} that can be streamed (as json separated - * by newlines in the response body). This method considers two concrete types - * {@code APPLICATION_NDJSON} and {@code APPLICATION_STREAM_JSON}) as well as any - * subtype of application that has the {@code +x-ndjson} suffix. In the later case, - * the media type MUST be concrete for it to be considered. + * Attempts to find a concrete {@code MediaType} that can be streamed (as JSON payloads + * separated by newlines in the response body). This method considers {@code APPLICATION_JSONL}, + * {@code APPLICATION_NDJSON}, and any subtype of application + * that has the {@code +x-ndjson} suffix. In the latter case, the media type MUST be + * concrete for it to be considered. * *

For example {@code application/vnd.myapp+x-ndjson} is considered a streaming type * while {@code application/*+x-ndjson} isn't. @@ -223,6 +223,9 @@ class ReactiveTypeHandler { else if (MediaType.APPLICATION_NDJSON.includes(acceptedType)) { return MediaType.APPLICATION_NDJSON; } + else if (MediaType.APPLICATION_JSONL.includes(acceptedType)) { + return MediaType.APPLICATION_JSONL; + } } return null; // not a concrete streaming type } diff --git a/spring-webmvc/src/main/resources/org/springframework/web/servlet/config/spring-mvc.xsd b/spring-webmvc/src/main/resources/org/springframework/web/servlet/config/spring-mvc.xsd index 7b8da7ba4fc..596218fd4e6 100644 --- a/spring-webmvc/src/main/resources/org/springframework/web/servlet/config/spring-mvc.xsd +++ b/spring-webmvc/src/main/resources/org/springframework/web/servlet/config/spring-mvc.xsd @@ -213,7 +213,7 @@ By default, a SimpleAsyncTaskExecutor is used which does not re-use threads and is not recommended for production. As of 5.0 this executor is also used when a controller returns a reactive type that does streaming - (for example, "text/event-stream" or "application/x-ndjson") for the blocking writes to the + (for example, "text/event-stream", "application/x-ndjson" or "application/jsonl") for the blocking writes to the "jakarta.servlet.ServletOutputStream". ]]> diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandlerTests.java index f101e1c66d1..65e4fd2c1f3 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandlerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ReactiveTypeHandlerTests.java @@ -35,6 +35,8 @@ import jakarta.servlet.http.HttpServletRequest; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; @@ -287,10 +289,11 @@ class ReactiveTypeHandlerTests { assertThat(emitterHandler.getValuesAsText()).isEqualTo("id:1\ndata:foo\n\nid:2\ndata:bar\n\nid:3\ndata:baz\n\n"); } - @Test - void writeStreamJson() throws Exception { + @ParameterizedTest + @ValueSource(strings = {"application/jsonl", "application/x-ndjson"}) + void writeStreamJson(String mediaType) throws Exception { - this.servletRequest.addHeader("Accept", "application/x-ndjson"); + this.servletRequest.addHeader("Accept", mediaType); Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); ResponseBodyEmitter emitter = handleValue(sink.asFlux(), Flux.class, forClass(Bar.class)); @@ -308,7 +311,7 @@ class ReactiveTypeHandlerTests { sink.tryEmitNext(bar2); sink.tryEmitComplete(); - assertThat(message.getHeaders().getContentType()).hasToString("application/x-ndjson"); + assertThat(message.getHeaders().getContentType()).hasToString(mediaType); assertThat(emitterHandler.getValues()).isEqualTo(Arrays.asList(bar1, "\n", bar2, "\n")); }