Skip to content

Commit 8e2b27e

Browse files
committed
WebFlux support for SSE with multiline fragments
See gh-33194
1 parent b734156 commit 8e2b27e

File tree

9 files changed

+137
-38
lines changed

9 files changed

+137
-38
lines changed

spring-webflux/src/main/java/org/springframework/web/reactive/result/view/ViewResolutionResultHandler.java

+13-8
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
4242
import org.springframework.core.io.buffer.DataBuffer;
4343
import org.springframework.core.io.buffer.DataBufferFactory;
44+
import org.springframework.core.io.buffer.DataBufferUtils;
4445
import org.springframework.http.HttpHeaders;
4546
import org.springframework.http.HttpStatusCode;
4647
import org.springframework.http.MediaType;
@@ -538,21 +539,25 @@ private Charset getCharset(ServerHttpRequest request) {
538539

539540
@Override
540541
public Flux<DataBuffer> format(
541-
Flux<DataBuffer> fragmentContent, Fragment fragment, ServerWebExchange exchange) {
542+
Flux<DataBuffer> fragmentFlux, Fragment fragment, ServerWebExchange exchange) {
542543

543-
Charset charset = StandardCharsets.UTF_8;
544-
MediaType contentType = exchange.getResponse().getHeaders().getContentType();
545-
if (contentType != null && contentType.getCharset() != null) {
546-
charset = contentType.getCharset();
547-
}
544+
MediaType mediaType = exchange.getResponse().getHeaders().getContentType();
545+
Charset charset = (mediaType != null && mediaType.getCharset() != null ?
546+
mediaType.getCharset() : StandardCharsets.UTF_8);
548547

549548
DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory();
550549

551-
String eventLine = fragment.viewName() != null ? "event:" + fragment.viewName() + "\n" : "";
550+
String eventLine = (fragment.viewName() != null ? "event:" + fragment.viewName() + "\n" : "");
552551
DataBuffer prefix = encodeText(eventLine + "data:", charset, bufferFactory);
553552
DataBuffer suffix = encodeText("\n\n", charset, bufferFactory);
554553

555-
return Flux.concat(Flux.just(prefix), fragmentContent, Flux.just(suffix));
554+
Mono<DataBuffer> content = DataBufferUtils.join(fragmentFlux)
555+
.map(dataBuffer -> {
556+
String s = dataBuffer.toString(charset).replace("\n", "\ndata:");
557+
return bufferFactory.wrap(s.getBytes(charset));
558+
});
559+
560+
return Flux.concat(Flux.just(prefix), content, Flux.just(suffix));
556561
}
557562

558563
private DataBuffer encodeText(String text, Charset charset, DataBufferFactory bufferFactory) {

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

+18-4
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import org.springframework.web.reactive.result.view.script.ScriptTemplateConfigurer;
4444
import org.springframework.web.reactive.result.view.script.ScriptTemplateViewResolver;
4545
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest;
46+
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpResponse;
4647
import org.springframework.web.testfixture.server.MockServerWebExchange;
4748

4849
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
@@ -87,7 +88,14 @@ void render(Object returnValue, MethodParameter parameter) {
8788
.then(Mono.defer(() -> exchange.getResponse().getBodyAsString()))
8889
.block(Duration.ofSeconds(60));
8990

90-
assertThat(body).isEqualTo("<p>Hello Foo</p><p>Hello Bar</p>");
91+
assertThat(exchange.getResponse().getHeaders().getContentType()).isEqualTo(MediaType.TEXT_HTML);
92+
assertThat(body).isEqualTo("""
93+
<p>
94+
Hello Foo
95+
</p>\
96+
<p>
97+
Hello Bar
98+
</p>""");
9199
}
92100

93101
@Test
@@ -98,6 +106,7 @@ void renderSse() {
98106
.build();
99107

100108
MockServerWebExchange exchange = MockServerWebExchange.from(request);
109+
MockServerHttpResponse response = exchange.getResponse();
101110

102111
HandlerResult result = new HandlerResult(
103112
new Handler(),
@@ -106,15 +115,20 @@ void renderSse() {
106115
new BindingContext());
107116

108117
String body = initHandler().handleResult(exchange, result)
109-
.then(Mono.defer(() -> exchange.getResponse().getBodyAsString()))
118+
.then(Mono.defer(response::getBodyAsString))
110119
.block(Duration.ofSeconds(60));
111120

121+
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM);
112122
assertThat(body).isEqualTo("""
113123
event:fragment1
114-
data:<p>Hello Foo</p>
124+
data:<p>
125+
data: Hello Foo
126+
data:</p>
115127
116128
event:fragment2
117-
data:<p>Hello Bar</p>
129+
data:<p>
130+
data: Hello Bar
131+
data:</p>
118132
119133
""");
120134
}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
import org.springframework.web.reactive.result.view.script.*
22

3-
"""<p>${i18n("hello")} $foo</p>"""
3+
"""
4+
|<p>
5+
| ${i18n("hello")} $foo
6+
|</p>""".trimMargin()
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
import org.springframework.web.reactive.result.view.script.*
22

3-
"""<p>${i18n("hello")} $bar</p>"""
3+
"""
4+
|<p>
5+
| ${i18n("hello")} $bar
6+
|</p>""".trimMargin()

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

+7
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.springframework.lang.Nullable;
3131
import org.springframework.util.ObjectUtils;
3232
import org.springframework.util.StringUtils;
33+
import org.springframework.web.servlet.ModelAndView;
3334

3435
/**
3536
* A specialization of {@link ResponseBodyEmitter} for sending
@@ -203,6 +204,8 @@ private static class SseEventBuilderImpl implements SseEventBuilder {
203204
@Nullable
204205
private StringBuilder sb;
205206

207+
private boolean hasName;
208+
206209
@Override
207210
public SseEventBuilder id(String id) {
208211
append("id:").append(id).append('\n');
@@ -211,6 +214,7 @@ public SseEventBuilder id(String id) {
211214

212215
@Override
213216
public SseEventBuilder name(String name) {
217+
this.hasName = true;
214218
append("event:").append(name).append('\n');
215219
return this;
216220
}
@@ -234,6 +238,9 @@ public SseEventBuilder data(Object object) {
234238

235239
@Override
236240
public SseEventBuilder data(Object object, @Nullable MediaType mediaType) {
241+
if (object instanceof ModelAndView mav && !this.hasName && mav.getViewName() != null) {
242+
name(mav.getViewName());
243+
}
237244
append("data:");
238245
saveAppendedText();
239246
if (object instanceof String text) {

spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/FragmentRenderingStreamTests.java

+76-21
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
import java.util.List;
2020
import java.util.Map;
2121

22+
import org.junit.jupiter.api.BeforeEach;
2223
import org.junit.jupiter.api.Test;
24+
import reactor.core.publisher.Flux;
2325

2426
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
2527
import org.springframework.context.annotation.Bean;
@@ -28,7 +30,8 @@
2830
import org.springframework.core.MethodParameter;
2931
import org.springframework.core.ReactiveAdapterRegistry;
3032
import org.springframework.core.task.SyncTaskExecutor;
31-
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
33+
import org.springframework.http.HttpHeaders;
34+
import org.springframework.http.converter.StringHttpMessageConverter;
3235
import org.springframework.web.accept.ContentNegotiationManager;
3336
import org.springframework.web.context.request.NativeWebRequest;
3437
import org.springframework.web.context.request.ServletWebRequest;
@@ -51,8 +54,20 @@
5154
*/
5255
public class FragmentRenderingStreamTests {
5356

54-
@Test
55-
void streamFragments() throws Exception {
57+
private final MockHttpServletRequest request = new MockHttpServletRequest();
58+
59+
private final MockHttpServletResponse response = new MockHttpServletResponse();
60+
61+
private final NativeWebRequest webRequest = new ServletWebRequest(request, response);
62+
63+
private ResponseBodyEmitterReturnValueHandler handler;
64+
65+
66+
@BeforeEach
67+
void setUp() {
68+
AsyncWebRequest asyncWebRequest = new StandardServletAsyncWebRequest(this.request, this.response);
69+
WebAsyncUtils.getAsyncManager(this.webRequest).setAsyncWebRequest(asyncWebRequest);
70+
this.request.setAsyncSupported(true);
5671

5772
AnnotationConfigApplicationContext context =
5873
new AnnotationConfigApplicationContext(ScriptTemplatingConfiguration.class);
@@ -61,44 +76,84 @@ void streamFragments() throws Exception {
6176
ScriptTemplateViewResolver viewResolver = new ScriptTemplateViewResolver(prefix, ".kts");
6277
viewResolver.setApplicationContext(context);
6378

64-
ResponseBodyEmitterReturnValueHandler handler = new ResponseBodyEmitterReturnValueHandler(
65-
List.of(new MappingJackson2HttpMessageConverter()),
79+
this.handler = new ResponseBodyEmitterReturnValueHandler(
80+
List.of(new StringHttpMessageConverter()),
6681
ReactiveAdapterRegistry.getSharedInstance(), new SyncTaskExecutor(),
6782
new ContentNegotiationManager(),
6883
List.of(viewResolver), null);
84+
}
6985

70-
MockHttpServletRequest request = new MockHttpServletRequest();
71-
MockHttpServletResponse response = new MockHttpServletResponse();
72-
NativeWebRequest webRequest = new ServletWebRequest(request, response);
73-
74-
AsyncWebRequest asyncWebRequest = new StandardServletAsyncWebRequest(request, response);
75-
WebAsyncUtils.getAsyncManager(webRequest).setAsyncWebRequest(asyncWebRequest);
76-
request.setAsyncSupported(true);
7786

87+
@Test
88+
void streamWithSseEmitter() throws Exception {
7889
MethodParameter type = on(TestController.class).resolveReturnType(SseEmitter.class);
90+
7991
SseEmitter emitter = new SseEmitter();
80-
handler.handleReturnValue(emitter, type, new ModelAndViewContainer(), webRequest);
92+
this.handler.handleReturnValue(emitter, type, new ModelAndViewContainer(), webRequest);
8193

82-
assertThat(request.isAsyncStarted()).isTrue();
83-
assertThat(response.getStatus()).isEqualTo(200);
94+
assertThat(this.request.isAsyncStarted()).isTrue();
95+
assertThat(this.response.getStatus()).isEqualTo(200);
8496

8597
ModelAndView mav1 = new ModelAndView("fragment1", Map.of("foo", "Foo"));
8698
ModelAndView mav2 = new ModelAndView("fragment2", Map.of("bar", "Bar"));
8799

88-
emitter.send(SseEmitter.event().data(mav1).data(mav2));
100+
emitter.send(SseEmitter.event().data(mav1));
101+
emitter.send(SseEmitter.event().data(mav2));
89102

90-
assertThat(response.getContentType()).isEqualTo("text/event-stream");
91-
assertThat(response.getContentAsString()).isEqualTo(("""
92-
data:<p>Hello Foo</p>
93-
data:<p>Hello Bar</p>
103+
assertThat(this.response.getContentType()).isEqualTo("text/event-stream");
104+
assertThat(this.response.getContentAsString()).isEqualTo(("""
105+
event:fragment1
106+
data:<p>
107+
data: Hello Foo
108+
data:</p>
109+
110+
event:fragment2
111+
data:<p>
112+
data: Hello Bar
113+
data:</p>
94114
95115
"""));
96116
}
97117

118+
@Test
119+
void streamWithFlux() throws Exception {
120+
MethodParameter type = on(TestController.class).resolveReturnType(Flux.class, ModelAndView.class);
121+
122+
this.request.addHeader(HttpHeaders.ACCEPT, "text/event-stream");
98123

124+
Flux<ModelAndView> flux = Flux.just(
125+
new ModelAndView("fragment1", Map.of("foo", "Foo")),
126+
new ModelAndView("fragment2", Map.of("bar", "Bar")));
127+
128+
this.handler.handleReturnValue(flux, type, new ModelAndViewContainer(), webRequest);
129+
130+
assertThat(this.request.isAsyncStarted()).isTrue();
131+
assertThat(this.response.getStatus()).isEqualTo(200);
132+
133+
assertThat(this.response.getContentType()).isEqualTo("text/event-stream");
134+
assertThat(this.response.getContentAsString()).isEqualTo(("""
135+
event:fragment1
136+
data:<p>
137+
data: Hello Foo
138+
data:</p>
139+
140+
event:fragment2
141+
data:<p>
142+
data: Hello Bar
143+
data:</p>
144+
145+
"""));
146+
}
147+
148+
149+
@SuppressWarnings({"unused", "DataFlowIssue"})
99150
private static class TestController {
100151

101-
SseEmitter handle() {
152+
SseEmitter handleWithSseEmitter() {
153+
return null;
154+
}
155+
156+
Flux<ModelAndView> handleWithFlux() {
102157
return null;
103158
}
104159
}

spring-webmvc/src/test/java/org/springframework/web/servlet/view/DefaultFragmentsRenderingTests.java

+7-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,13 @@ void render() throws Exception {
6464
view.resolveNestedViews(viewResolver, Locale.ENGLISH);
6565
view.render(Collections.emptyMap(), request, response);
6666

67-
assertThat(response.getContentAsString()).isEqualTo("<p>Hello Foo</p><p>Hello Bar</p>");
67+
assertThat(response.getContentAsString()).isEqualTo("""
68+
<p>
69+
Hello Foo
70+
</p>\
71+
<p>
72+
Hello Bar
73+
</p>""");
6874
}
6975

7076

Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
import org.springframework.web.servlet.view.script.*
22

3-
"""<p>${i18n("hello")} $foo</p>"""
3+
"""
4+
|<p>
5+
| ${i18n("hello")} $foo
6+
|</p>""".trimMargin()
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
import org.springframework.web.servlet.view.script.*
22

3-
"""<p>${i18n("hello")} $bar</p>"""
3+
"""
4+
|<p>
5+
| ${i18n("hello")} $bar
6+
|</p>""".trimMargin()

0 commit comments

Comments
 (0)