mirror of
https://github.com/spring-projects/spring-framework
synced 2026-06-08 17:33:33 +00:00
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
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ServerSentEvent>` or `Observable<ServerSentEvent>`.
|
||||
* A multi-value stream with any other media type (such as `application/json`) is adapted
|
||||
|
||||
@@ -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 <a href="https://jsonlines.org/">JSON Lines</a>
|
||||
*/
|
||||
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");
|
||||
|
||||
@@ -58,7 +58,8 @@ public class GsonEncoder extends AbstractEncoder<Object> 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<Object> 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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+2
-1
@@ -81,7 +81,8 @@ public abstract class Jackson2CodecSupport {
|
||||
private static final List<MimeType> 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());
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -55,7 +55,8 @@ public class JacksonJsonDecoder extends AbstractJacksonDecoder<JsonMapper> {
|
||||
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
|
||||
};
|
||||
|
||||
|
||||
|
||||
+4
-3
@@ -55,7 +55,8 @@ public class JacksonJsonEncoder extends AbstractJacksonEncoder<JsonMapper> {
|
||||
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<JsonMapper> {
|
||||
*/
|
||||
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<JsonMapper> {
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
+5
-4
@@ -36,7 +36,7 @@ import org.springframework.util.MimeType;
|
||||
/**
|
||||
* Encode from an {@code Object} stream to a byte stream of JSON objects using
|
||||
* <a href="https://github.com/Kotlin/kotlinx.serialization">kotlinx.serialization</a>.
|
||||
* 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.
|
||||
*
|
||||
* <p>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<ResolvableType> typePredicate) {
|
||||
super(json, typePredicate, DEFAULT_JSON_MIME_TYPES);
|
||||
setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON));
|
||||
setStreamingMediaTypes(List.of(MediaType.APPLICATION_NDJSON, MediaType.APPLICATION_JSONL));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ public class ProtobufJsonEncoder implements HttpMessageEncoder<Message> {
|
||||
|
||||
@Override
|
||||
public List<MediaType> getStreamingMediaTypes() {
|
||||
return List.of(MediaType.APPLICATION_NDJSON);
|
||||
return List.of(MediaType.APPLICATION_NDJSON, MediaType.APPLICATION_JSONL);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+8
-5
@@ -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.
|
||||
*
|
||||
* <p>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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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".
|
||||
|
||||
]]></xsd:documentation>
|
||||
|
||||
+7
-4
@@ -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<Bar> 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"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user