Skip to content

Commit 2735cba

Browse files
committed
Append "data:" after line breaks for SSE JSON data fields
Issue: SPR-14899
1 parent 8315a40 commit 2735cba

File tree

5 files changed

+94
-8
lines changed

5 files changed

+94
-8
lines changed

spring-web/src/main/java/org/springframework/http/codec/ServerSentEventHttpMessageWriter.java

+14-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import java.nio.charset.StandardCharsets;
2020
import java.util.ArrayList;
2121
import java.util.Collections;
22+
import java.util.HashMap;
2223
import java.util.List;
2324
import java.util.Map;
2425
import java.util.Optional;
@@ -48,6 +49,16 @@
4849
*/
4950
public class ServerSentEventHttpMessageWriter implements HttpMessageWriter<Object> {
5051

52+
/**
53+
* Server-Sent Events hint expecting a {@link Boolean} value which when set to true
54+
* will adapt the content in order to comply with Server-Sent Events recommendation.
55+
* For example, it will append "data:" after each line break with data encoders
56+
* supporting it.
57+
* @see <a href="https://www.w3.org/TR/eventsource/">Server-Sent Events W3C recommendation</a>
58+
*/
59+
public static final String SSE_CONTENT_HINT = ServerSentEventHttpMessageWriter.class.getName() + ".sseContent";
60+
61+
5162
private final List<Encoder<?>> dataEncoders;
5263

5364

@@ -87,6 +98,8 @@ public Mono<Void> write(Publisher<?> inputStream, ResolvableType elementType, Me
8798
private Flux<Publisher<DataBuffer>> encode(Publisher<?> inputStream, DataBufferFactory bufferFactory,
8899
ResolvableType type, Map<String, Object> hints) {
89100

101+
Map<String, Object> hintsWithSse = new HashMap<>(hints);
102+
hintsWithSse.put(SSE_CONTENT_HINT, true);
90103
return Flux.from(inputStream)
91104
.map(o -> toSseEvent(o, type))
92105
.map(sse -> {
@@ -107,7 +120,7 @@ private Flux<Publisher<DataBuffer>> encode(Publisher<?> inputStream, DataBufferF
107120
return Flux.empty();
108121
}
109122
else {
110-
return applyEncoder(data, bufferFactory, hints);
123+
return applyEncoder(data, bufferFactory, hintsWithSse);
111124
}
112125
}).orElse(Flux.empty());
113126

spring-web/src/main/java/org/springframework/http/codec/json/Jackson2JsonEncoder.java

+19-1
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,14 @@
2222
import java.util.List;
2323
import java.util.Map;
2424

25+
import com.fasterxml.jackson.core.PrettyPrinter;
26+
import com.fasterxml.jackson.core.util.DefaultIndenter;
27+
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
2528
import com.fasterxml.jackson.databind.JavaType;
2629
import com.fasterxml.jackson.databind.ObjectMapper;
2730
import com.fasterxml.jackson.databind.ObjectWriter;
31+
import com.fasterxml.jackson.databind.SerializationConfig;
32+
import com.fasterxml.jackson.databind.SerializationFeature;
2833
import com.fasterxml.jackson.databind.type.TypeFactory;
2934
import org.reactivestreams.Publisher;
3035
import reactor.core.publisher.Flux;
@@ -35,6 +40,7 @@
3540
import org.springframework.core.codec.Encoder;
3641
import org.springframework.core.io.buffer.DataBuffer;
3742
import org.springframework.core.io.buffer.DataBufferFactory;
43+
import org.springframework.http.codec.ServerSentEventHttpMessageWriter;
3844
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
3945
import org.springframework.util.Assert;
4046
import org.springframework.util.MimeType;
@@ -57,12 +63,18 @@ public class Jackson2JsonEncoder extends AbstractJackson2Codec implements Encode
5763
private static final ByteBuffer END_ARRAY_BUFFER = ByteBuffer.wrap(new byte[]{']'});
5864

5965

66+
private final PrettyPrinter ssePrettyPrinter;
67+
68+
6069
public Jackson2JsonEncoder() {
61-
super(Jackson2ObjectMapperBuilder.json().build());
70+
this(Jackson2ObjectMapperBuilder.json().build());
6271
}
6372

6473
public Jackson2JsonEncoder(ObjectMapper mapper) {
6574
super(mapper);
75+
DefaultPrettyPrinter prettyPrinter = new DefaultPrettyPrinter();
76+
prettyPrinter.indentObjectsWith(new DefaultIndenter(" ", "\ndata:"));
77+
this.ssePrettyPrinter = prettyPrinter;
6678
}
6779

6880

@@ -123,6 +135,12 @@ private DataBuffer encodeValue(Object value, DataBufferFactory bufferFactory,
123135
writer = writer.forType(javaType);
124136
}
125137

138+
Boolean sse = (Boolean)hints.get(ServerSentEventHttpMessageWriter.SSE_CONTENT_HINT);
139+
SerializationConfig config = writer.getConfig();
140+
if (Boolean.TRUE.equals(sse) && config.isEnabled(SerializationFeature.INDENT_OUTPUT)) {
141+
writer = writer.with(this.ssePrettyPrinter);
142+
}
143+
126144
DataBuffer buffer = bufferFactory.allocateBuffer();
127145
OutputStream outputStream = buffer.asOutputStream();
128146
try {

spring-web/src/main/java/org/springframework/http/converter/json/AbstractJackson2HttpMessageConverter.java

+21-5
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@
2727
import com.fasterxml.jackson.core.JsonEncoding;
2828
import com.fasterxml.jackson.core.JsonGenerator;
2929
import com.fasterxml.jackson.core.JsonProcessingException;
30+
import com.fasterxml.jackson.core.PrettyPrinter;
31+
import com.fasterxml.jackson.core.util.DefaultIndenter;
3032
import com.fasterxml.jackson.core.util.DefaultPrettyPrinter;
3133
import com.fasterxml.jackson.databind.JavaType;
3234
import com.fasterxml.jackson.databind.JsonMappingException;
3335
import com.fasterxml.jackson.databind.ObjectMapper;
3436
import com.fasterxml.jackson.databind.ObjectWriter;
37+
import com.fasterxml.jackson.databind.SerializationConfig;
3538
import com.fasterxml.jackson.databind.SerializationFeature;
3639
import com.fasterxml.jackson.databind.ser.FilterProvider;
3740
import com.fasterxml.jackson.databind.type.TypeFactory;
@@ -69,22 +72,29 @@ public abstract class AbstractJackson2HttpMessageConverter extends AbstractGener
6972

7073
private Boolean prettyPrint;
7174

75+
private PrettyPrinter ssePrettyPrinter;
76+
7277

7378
protected AbstractJackson2HttpMessageConverter(ObjectMapper objectMapper) {
74-
this.objectMapper = objectMapper;
75-
setDefaultCharset(DEFAULT_CHARSET);
79+
init(objectMapper);
7680
}
7781

7882
protected AbstractJackson2HttpMessageConverter(ObjectMapper objectMapper, MediaType supportedMediaType) {
7983
super(supportedMediaType);
80-
this.objectMapper = objectMapper;
81-
setDefaultCharset(DEFAULT_CHARSET);
84+
init(objectMapper);
8285
}
8386

8487
protected AbstractJackson2HttpMessageConverter(ObjectMapper objectMapper, MediaType... supportedMediaTypes) {
8588
super(supportedMediaTypes);
89+
init(objectMapper);
90+
}
91+
92+
protected void init(ObjectMapper objectMapper) {
8693
this.objectMapper = objectMapper;
8794
setDefaultCharset(DEFAULT_CHARSET);
95+
DefaultPrettyPrinter prettyPrinter = new DefaultPrettyPrinter();
96+
prettyPrinter.indentObjectsWith(new DefaultIndenter(" ", "\ndata:"));
97+
this.ssePrettyPrinter = prettyPrinter;
8898
}
8999

90100

@@ -234,7 +244,8 @@ private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) {
234244
protected void writeInternal(Object object, Type type, HttpOutputMessage outputMessage)
235245
throws IOException, HttpMessageNotWritableException {
236246

237-
JsonEncoding encoding = getJsonEncoding(outputMessage.getHeaders().getContentType());
247+
MediaType contentType = outputMessage.getHeaders().getContentType();
248+
JsonEncoding encoding = getJsonEncoding(contentType);
238249
JsonGenerator generator = this.objectMapper.getFactory().createGenerator(outputMessage.getBody(), encoding);
239250
try {
240251
writePrefix(generator, object);
@@ -265,6 +276,11 @@ else if (filters != null) {
265276
if (javaType != null && javaType.isContainerType()) {
266277
objectWriter = objectWriter.forType(javaType);
267278
}
279+
SerializationConfig config = objectWriter.getConfig();
280+
if (contentType != null && contentType.isCompatibleWith(MediaType.TEXT_EVENT_STREAM) &&
281+
config.isEnabled(SerializationFeature.INDENT_OUTPUT)) {
282+
objectWriter = objectWriter.with(this.ssePrettyPrinter);
283+
}
268284
objectWriter.writeValue(generator, value);
269285

270286
writeSuffix(generator, object);

spring-web/src/test/java/org/springframework/http/codec/ServerSentEventHttpMessageWriterTests.java

+26-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.util.Collections;
2121
import java.util.function.Consumer;
2222

23+
import com.fasterxml.jackson.databind.ObjectMapper;
2324
import org.junit.Test;
2425
import org.reactivestreams.Publisher;
2526
import reactor.core.publisher.Flux;
@@ -31,6 +32,7 @@
3132
import org.springframework.core.io.buffer.DataBuffer;
3233
import org.springframework.http.MediaType;
3334
import org.springframework.http.codec.json.Jackson2JsonEncoder;
35+
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
3436
import org.springframework.mock.http.server.reactive.test.MockServerHttpResponse;
3537

3638
import static org.junit.Assert.*;
@@ -116,7 +118,7 @@ public void encodePojo() {
116118
new Pojo("foofoofoo", "barbarbar"));
117119
MockServerHttpResponse outputMessage = new MockServerHttpResponse();
118120
messageWriter.write(source, ResolvableType.forClass(Pojo.class),
119-
new MediaType("text", "event-stream"), outputMessage, Collections.emptyMap());
121+
MediaType.TEXT_EVENT_STREAM, outputMessage, Collections.emptyMap());
120122

121123
Publisher<? extends Publisher<? extends DataBuffer>> result = outputMessage.getBodyWithFlush();
122124
StepVerifier.create(result)
@@ -126,6 +128,29 @@ public void encodePojo() {
126128
.verify();
127129
}
128130

131+
@Test // SPR-14899
132+
public void encodePojoWithPrettyPrint() {
133+
ObjectMapper mapper = Jackson2ObjectMapperBuilder.json().indentOutput(true).build();
134+
this.messageWriter = new ServerSentEventHttpMessageWriter(Collections.singletonList(new Jackson2JsonEncoder(mapper)));
135+
136+
Flux<Pojo> source = Flux.just(new Pojo("foofoo", "barbar"),
137+
new Pojo("foofoofoo", "barbarbar"));
138+
MockServerHttpResponse outputMessage = new MockServerHttpResponse();
139+
messageWriter.write(source, ResolvableType.forClass(Pojo.class),
140+
MediaType.TEXT_EVENT_STREAM, outputMessage, Collections.emptyMap());
141+
142+
Publisher<? extends Publisher<? extends DataBuffer>> result = outputMessage.getBodyWithFlush();
143+
StepVerifier.create(result)
144+
.consumeNextWith(sseConsumer("data:", "{\n" +
145+
"data: \"foo\" : \"foofoo\",\n" +
146+
"data: \"bar\" : \"barbar\"\n" + "data:}", "\n"))
147+
.consumeNextWith(sseConsumer("data:", "{\n" +
148+
"data: \"foo\" : \"foofoofoo\",\n" +
149+
"data: \"bar\" : \"barbarbar\"\n" + "data:}", "\n"))
150+
.expectComplete()
151+
.verify();
152+
}
153+
129154

130155
private Consumer<Publisher<? extends DataBuffer>> sseConsumer(String... expected) {
131156
return publisher -> {

spring-web/src/test/java/org/springframework/http/converter/json/MappingJackson2HttpMessageConverterTests.java

+14
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,20 @@ public void prettyPrint() throws Exception {
223223
assertEquals("{" + NEWLINE_SYSTEM_PROPERTY + " \"name\" : \"Jason\"" + NEWLINE_SYSTEM_PROPERTY + "}", result);
224224
}
225225

226+
@Test
227+
public void prettyPrintWithSse() throws Exception {
228+
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
229+
outputMessage.getHeaders().setContentType(MediaType.TEXT_EVENT_STREAM);
230+
PrettyPrintBean bean = new PrettyPrintBean();
231+
bean.setName("Jason");
232+
233+
this.converter.setPrettyPrint(true);
234+
this.converter.writeInternal(bean, null, outputMessage);
235+
String result = outputMessage.getBodyAsString(StandardCharsets.UTF_8);
236+
237+
assertEquals("{\ndata: \"name\" : \"Jason\"\ndata:}", result);
238+
}
239+
226240
@Test
227241
public void prefixJson() throws Exception {
228242
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();

0 commit comments

Comments
 (0)