From 55634f972ced9d6a50630bf92f14b8e7cf8ebe86 Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Mon, 30 Jun 2025 15:34:00 +0100 Subject: [PATCH] Use preset content-type for streaming response Closes gh-35130 --- .../annotation/ReactiveTypeHandler.java | 24 ++++++++++++++----- ...ResponseBodyEmitterReturnValueHandler.java | 4 +++- .../annotation/ReactiveTypeHandlerTests.java | 23 ++++++++++++------ ...nseBodyEmitterReturnValueHandlerTests.java | 20 ++++++++++++++++ 4 files changed, 57 insertions(+), 14 deletions(-) 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 9c18009b17..35d5f550f5 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 @@ -138,7 +138,8 @@ class ReactiveTypeHandler { * with a {@link DeferredResult} */ @Nullable - public ResponseBodyEmitter handleValue(Object returnValue, MethodParameter returnType, + public ResponseBodyEmitter handleValue( + Object returnValue, MethodParameter returnType, @Nullable MediaType presetContentType, ModelAndViewContainer mav, NativeWebRequest request) throws Exception { Assert.notNull(returnValue, "Expected return value"); @@ -157,7 +158,7 @@ class ReactiveTypeHandler { ResolvableType elementType = ResolvableType.forMethodParameter(returnType).getGeneric(); Class elementClass = elementType.toClass(); - Collection mediaTypes = getMediaTypes(request); + Collection mediaTypes = getMediaTypes(request, presetContentType); Optional mediaType = mediaTypes.stream().filter(MimeType::isConcrete).findFirst(); if (adapter.isMultiValue()) { @@ -228,14 +229,25 @@ class ReactiveTypeHandler { } @SuppressWarnings("unchecked") - private Collection getMediaTypes(NativeWebRequest request) + private Collection getMediaTypes(NativeWebRequest request, @Nullable MediaType contentType) throws HttpMediaTypeNotAcceptableException { - Collection mediaTypes = (Collection) request.getAttribute( + Collection producibleMediaTypes = (Collection) request.getAttribute( HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, RequestAttributes.SCOPE_REQUEST); - return CollectionUtils.isEmpty(mediaTypes) ? - this.contentNegotiationManager.resolveMediaTypes(request) : mediaTypes; + Collection mediaTypes = (CollectionUtils.isEmpty(producibleMediaTypes) ? + this.contentNegotiationManager.resolveMediaTypes(request) : producibleMediaTypes); + + if (contentType != null) { + for (MediaType mediaType : mediaTypes) { + if (mediaType.isConcrete()) { + return mediaTypes; + } + } + return List.of(contentType); + } + + return mediaTypes; } private ResponseBodyEmitter getEmitter(MediaType mediaType) { diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandler.java index 7dfde06f81..148a0cb5bc 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandler.java @@ -186,10 +186,12 @@ public class ResponseBodyEmitterReturnValueHandler implements HandlerMethodRetur HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class); Assert.state(response != null, "No HttpServletResponse"); ServerHttpResponse outputMessage = new ServletServerHttpResponse(response); + MediaType contentType = null; if (returnValue instanceof ResponseEntity responseEntity) { response.setStatus(responseEntity.getStatusCode().value()); outputMessage.getHeaders().putAll(responseEntity.getHeaders()); + contentType = responseEntity.getHeaders().getContentType(); returnValue = responseEntity.getBody(); returnType = returnType.nested(); if (returnValue == null) { @@ -207,7 +209,7 @@ public class ResponseBodyEmitterReturnValueHandler implements HandlerMethodRetur emitter = responseBodyEmitter; } else { - emitter = this.reactiveHandler.handleValue(returnValue, returnType, mavContainer, webRequest); + emitter = this.reactiveHandler.handleValue(returnValue, returnType, contentType, mavContainer, webRequest); if (emitter == null) { // We're not streaming; write headers without committing response outputMessage.getHeaders().forEach((headerName, headerValues) -> { 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 0477796734..3b907cfd0c 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 @@ -242,19 +242,22 @@ class ReactiveTypeHandlerTests { // Media type from request this.servletRequest.addHeader("Accept", "text/event-stream"); - testSseResponse(true); + testSseResponse(true, null); // Media type from "produces" attribute Set types = Collections.singleton(MediaType.TEXT_EVENT_STREAM); this.servletRequest.setAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, types); - testSseResponse(true); + testSseResponse(true, null); + + // Preset media type // gh-35130 + testSseResponse(true, MediaType.TEXT_EVENT_STREAM); // No media type preferences - testSseResponse(false); + testSseResponse(false, null); } - private void testSseResponse(boolean expectSseEmitter) throws Exception { - ResponseBodyEmitter emitter = handleValue(Flux.empty(), Flux.class, forClass(String.class)); + private void testSseResponse(boolean expectSseEmitter, @Nullable MediaType contentType) throws Exception { + ResponseBodyEmitter emitter = handleValue(Flux.empty(), Flux.class, forClass(String.class), contentType); Object actual = emitter instanceof SseEmitter; assertThat(actual).isEqualTo(expectSseEmitter); resetRequest(); @@ -450,7 +453,7 @@ class ReactiveTypeHandlerTests { try { Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - ResponseBodyEmitter emitter = handler.handleValue(sink.asFlux(), returnType, mavContainer, this.webRequest); + ResponseBodyEmitter emitter = handler.handleValue(sink.asFlux(), returnType, null, mavContainer, this.webRequest); ContextEmitterHandler emitterHandler = new ContextEmitterHandler(); emitter.initialize(emitterHandler); @@ -497,9 +500,15 @@ class ReactiveTypeHandlerTests { private ResponseBodyEmitter handleValue(Object returnValue, Class asyncType, ResolvableType genericType) throws Exception { + return handleValue(returnValue, asyncType, genericType, null); + } + + private ResponseBodyEmitter handleValue(Object returnValue, Class asyncType, + ResolvableType genericType, @Nullable MediaType contentType) throws Exception { + ModelAndViewContainer mavContainer = new ModelAndViewContainer(); MethodParameter returnType = on(TestController.class).resolveReturnType(asyncType, genericType); - return this.handler.handleValue(returnValue, returnType, mavContainer, this.webRequest); + return this.handler.handleValue(returnValue, returnType, contentType, mavContainer, this.webRequest); } diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandlerTests.java index fd30d7eaca..539e163db8 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandlerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/ResponseBodyEmitterReturnValueHandlerTests.java @@ -28,12 +28,14 @@ import io.micrometer.context.ContextSnapshot; import io.micrometer.context.ContextSnapshot.Scope; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Sinks; import reactor.core.scheduler.Schedulers; import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.web.context.request.NativeWebRequest; @@ -342,6 +344,21 @@ class ResponseBodyEmitterReturnValueHandlerTests { assertThat(this.response.isCommitted()).isFalse(); } + @Test // gh-35130 + void responseEntityFluxSseWithPresetContentType() throws Exception { + + ResponseEntity> entity = + ResponseEntity.ok().contentType(MediaType.TEXT_EVENT_STREAM).body(Flux.just("foo", "bar")); + + MethodParameter type = on(TestController.class).resolveReturnType(ResponseEntity.class, Publisher.class); + this.handler.handleReturnValue(entity, type, this.mavContainer, this.webRequest); + + assertThat(this.request.isAsyncStarted()).isTrue(); + assertThat(this.response.getStatus()).isEqualTo(200); + assertThat(this.response.getContentType()).isEqualTo("text/event-stream"); + assertThat(this.response.getContentAsString()).isEqualTo("data:foo\n\ndata:bar\n\n"); + } + @SuppressWarnings({"unused", "ConstantConditions"}) private static class TestController { @@ -365,6 +382,9 @@ class ResponseBodyEmitterReturnValueHandlerTests { private ResponseEntity> h9() { return null; } private ResponseEntity> h10() { return null; } + + private ResponseEntity> h11() { return null; } + }