From 82cf15c60fb0276cc863f5642bf237b3c1a4710e Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 20 Aug 2026 14:47:52 +0100 Subject: [PATCH 1/3] ProtobufJsonEncoder actually supports streaming Closes gh-37158 --- .../codec/protobuf/ProtobufJsonEncoder.java | 59 ++++++++++++++----- .../protobuf/ProtobufJsonEncoderTests.java | 14 ++++- 2 files changed, 56 insertions(+), 17 deletions(-) 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 4acc85b67f6..524f536562f 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 @@ -54,6 +54,8 @@ import org.springframework.util.MimeType; */ public class ProtobufJsonEncoder implements HttpMessageEncoder { + private static final byte[] NEWLINE_SEPARATOR = {'\n'}; + private static final byte[] EMPTY_BYTES = new byte[0]; private static final ResolvableType MESSAGE_TYPE = ResolvableType.forClass(Message.class); @@ -117,22 +119,33 @@ public class ProtobufJsonEncoder implements HttpMessageEncoder { .map(value -> encodeValue(value, bufferFactory, elementType, mimeType, hints)) .flux(); } - JsonArrayJoinHelper helper = new JsonArrayJoinHelper(); - // Do not prepend JSON array prefix until first signal is known, onNext vs onError - // Keeps response not committed for error handling - return Flux.from(inputStream) - .map(value -> { - byte[] prefix = helper.getPrefix(); - byte[] delimiter = helper.getDelimiter(); - DataBuffer delimiterBuffer = bufferFactory.wrap(delimiter); - DataBuffer dataBuffer = encodeValue(value, bufferFactory, MESSAGE_TYPE, mimeType, hints); - return (prefix.length > 0 ? - bufferFactory.join(List.of(bufferFactory.wrap(prefix), delimiterBuffer, dataBuffer)) : - bufferFactory.join(List.of(delimiterBuffer, dataBuffer))); - }) - .switchIfEmpty(Mono.fromCallable(() -> bufferFactory.wrap(helper.getPrefix()))) - .concatWith(Mono.fromCallable(() -> bufferFactory.wrap(helper.getSuffix()))); + byte[] separator = getStreamingMediaTypeSeparator(mimeType); + if (separator != null) { + return Flux.from(inputStream) + .map(value -> { + DataBuffer dataBuffer = encodeValue(value, bufferFactory, MESSAGE_TYPE, mimeType, hints); + return bufferFactory.join(List.of(dataBuffer, bufferFactory.wrap(separator))); + }); + } + else { + JsonArrayJoinHelper helper = new JsonArrayJoinHelper(); + + // Do not prepend JSON array prefix until first signal is known, onNext vs onError + // Keeps response not committed for error handling + return Flux.from(inputStream) + .map(value -> { + byte[] prefix = helper.getPrefix(); + byte[] delimiter = helper.getDelimiter(); + DataBuffer delimiterBuffer = bufferFactory.wrap(delimiter); + DataBuffer dataBuffer = encodeValue(value, bufferFactory, MESSAGE_TYPE, mimeType, hints); + return (prefix.length > 0 ? + bufferFactory.join(List.of(bufferFactory.wrap(prefix), delimiterBuffer, dataBuffer)) : + bufferFactory.join(List.of(delimiterBuffer, dataBuffer))); + }) + .switchIfEmpty(Mono.fromCallable(() -> bufferFactory.wrap(helper.getPrefix()))) + .concatWith(Mono.fromCallable(() -> bufferFactory.wrap(helper.getSuffix()))); + } } @Override @@ -153,6 +166,22 @@ public class ProtobufJsonEncoder implements HttpMessageEncoder { } } + /** + * Return the separator to use for the given mime type. + *

By default, this method returns new line {@code "\n"} if the given + * mime type is one of the configured {@link #getStreamingMediaTypes + * streaming} mime types. + * @since 7.0.10 + */ + protected byte @Nullable [] getStreamingMediaTypeSeparator(@Nullable MimeType mimeType) { + for (MediaType streamingMediaType : getStreamingMediaTypes()) { + if (streamingMediaType.isCompatibleWith(mimeType)) { + return NEWLINE_SEPARATOR; + } + } + return null; + } + private static class JsonArrayJoinHelper { diff --git a/spring-web/src/test/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoderTests.java b/spring-web/src/test/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoderTests.java index 2924b0aa731..2b08b6cc2e0 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/protobuf/ProtobufJsonEncoderTests.java @@ -92,7 +92,7 @@ class ProtobufJsonEncoderTests extends AbstractEncoderTests } @Test - void encodeStream() { + void encodeNonStream() { Flux input = Flux.just(this.msg1, this.msg2); ResolvableType inputType = forClass(Msg.class); @@ -104,7 +104,7 @@ class ProtobufJsonEncoderTests extends AbstractEncoderTests } @Test - void encodeEmptyFlux() { + void encodeNonStreamEmpty() { Flux input = Flux.empty(); ResolvableType inputType = forClass(Msg.class); Flux result = this.encoder.encode(input, this.bufferFactory, inputType, @@ -115,6 +115,16 @@ class ProtobufJsonEncoderTests extends AbstractEncoderTests .verifyComplete(); } + @Test + void encodeStream() { + Flux input = Flux.just(this.msg1, this.msg2); + ResolvableType inputType = forClass(Msg.class); + + testEncode(input, inputType, MediaType.APPLICATION_NDJSON, null, step -> step + .assertNext(buffer -> assertBufferEqualsJson(buffer, "{\"foo\":\"Foo\",\"blah\":{\"blah\":123}}\n")) + .assertNext(buffer -> assertBufferEqualsJson(buffer, "{\"foo\":\"Bar\",\"blah\":{\"blah\":456}}\n")) + .verifyComplete()); + } private void assertBufferEqualsJson(DataBuffer actual, String expected) { byte[] bytes = DataBufferTestUtils.dumpBytes(actual); From 8d4208f0309ccf9f743765c5aa96ad5542c3d635 Mon Sep 17 00:00:00 2001 From: Garvit Joshi Date: Sat, 1 Aug 2026 04:07:59 +0530 Subject: [PATCH 2/3] Allow null contextPath in ServerHttpRequest.Builder The builder method required a non-null contextPath while the underlying field, MutatedServerHttpRequest constructor, and RequestPath.parse all accept null and treat it the same as an empty string. Relax the method parameter to @Nullable so callers can clear the context path directly. Closes gh-37099 Signed-off-by: Garvit Joshi --- .../reactive/DefaultServerHttpRequestBuilder.java | 2 +- .../http/server/reactive/ServerHttpRequest.java | 4 +++- .../http/server/reactive/ServerHttpRequestTests.java | 10 ++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilder.java b/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilder.java index a09bc19e2d2..f316bc51054 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilder.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/DefaultServerHttpRequestBuilder.java @@ -103,7 +103,7 @@ class DefaultServerHttpRequestBuilder implements ServerHttpRequest.Builder { } @Override - public ServerHttpRequest.Builder contextPath(String contextPath) { + public ServerHttpRequest.Builder contextPath(@Nullable String contextPath) { this.contextPath = contextPath; return this; } diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java index 420087a439f..5156c299f23 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java @@ -146,8 +146,10 @@ public interface ServerHttpRequest extends HttpRequest, ReactiveHttpInputMessage * contextPath} and it must match the start of the path of the URI of * the request. That means changing the contextPath, implies also * changing the path via {@link #path(String)}. + *

The given value may be {@code null} or empty to indicate there + * is no context path. */ - Builder contextPath(String contextPath); + Builder contextPath(@Nullable String contextPath); /** * Set or override the specified header values under the given name. diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java index bfa4817483e..73867f34eb3 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java @@ -194,6 +194,16 @@ class ServerHttpRequestTests { assertThat(mutated.getURI().getRawPath()).isEqualTo("/other/path"); } + @Test + void mutateContextPathToNull() throws Exception { + ServerHttpRequest request = createRequest("/context/path", "/context"); + + ServerHttpRequest mutated = request.mutate().contextPath(null).build(); + assertThat(mutated.getPath().contextPath().value()).isEmpty(); + assertThat(mutated.getPath().pathWithinApplication().value()).isEqualTo("/context/path"); + assertThat(mutated.getURI().getRawPath()).isEqualTo("/context/path"); + } + @Test void mutateContextPathWithoutUpdatingPathShouldFail() throws Exception { ServerHttpRequest request = createRequest("/context/path", "/context"); From 495fd6b3a537c862c5b889b56f95863abe5a266e Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Mon, 24 Aug 2026 17:17:42 +0100 Subject: [PATCH 3/3] Polishing contribution See gh-37099 --- .../http/server/reactive/ServerHttpRequest.java | 3 +-- .../http/server/reactive/ServerHttpRequestTests.java | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java b/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java index 5156c299f23..3fc11a2f440 100644 --- a/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java +++ b/spring-web/src/main/java/org/springframework/http/server/reactive/ServerHttpRequest.java @@ -146,8 +146,7 @@ public interface ServerHttpRequest extends HttpRequest, ReactiveHttpInputMessage * contextPath} and it must match the start of the path of the URI of * the request. That means changing the contextPath, implies also * changing the path via {@link #path(String)}. - *

The given value may be {@code null} or empty to indicate there - * is no context path. + *

Use {@code null} to clear the context path. */ Builder contextPath(@Nullable String contextPath); diff --git a/spring-web/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java b/spring-web/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java index 73867f34eb3..56e8cfedda0 100644 --- a/spring-web/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java +++ b/spring-web/src/test/java/org/springframework/http/server/reactive/ServerHttpRequestTests.java @@ -194,14 +194,14 @@ class ServerHttpRequestTests { assertThat(mutated.getURI().getRawPath()).isEqualTo("/other/path"); } - @Test + @Test // gh-37099 void mutateContextPathToNull() throws Exception { ServerHttpRequest request = createRequest("/context/path", "/context"); - ServerHttpRequest mutated = request.mutate().contextPath(null).build(); + ServerHttpRequest mutated = request.mutate().contextPath(null).path("/path").build(); assertThat(mutated.getPath().contextPath().value()).isEmpty(); - assertThat(mutated.getPath().pathWithinApplication().value()).isEqualTo("/context/path"); - assertThat(mutated.getURI().getRawPath()).isEqualTo("/context/path"); + assertThat(mutated.getPath().pathWithinApplication().value()).isEqualTo("/path"); + assertThat(mutated.getURI().getRawPath()).isEqualTo("/path"); } @Test