Skip to content

Commit 8dc48dd

Browse files
authored
Adds support for multi-line SSE event (helidon-io#10406)
* Adds support for multi-line SSE events. These are concatenated using '\n' on the client side and reported as a single event. New builder method to pass an array of strings for a multi-line event. Adds support to request data of an event as a String[].
1 parent 3649ea5 commit 8dc48dd

5 files changed

Lines changed: 140 additions & 10 deletions

File tree

http/sse/src/main/java/io/helidon/http/sse/SseEvent.java

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2023, 2024 Oracle and/or its affiliates.
2+
* Copyright (c) 2023, 2025 Oracle and/or its affiliates.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -125,6 +125,9 @@ public <T> T data(Class<T> clazz, MediaType mediaType) {
125125
if (clazz.equals(byte[].class)) {
126126
return (T) sdata.getBytes(StandardCharsets.UTF_8);
127127
}
128+
if (clazz.equals(String[].class)) {
129+
return (T) sdata.split("\n");
130+
}
128131
try {
129132
if (mediaContext == null) {
130133
throw new IllegalStateException("Media context has not been set on this event");
@@ -294,7 +297,40 @@ public Builder mediaType(MediaType mediaType) {
294297
*/
295298
public Builder data(Object data) {
296299
Objects.requireNonNull(data);
297-
this.data = data;
300+
// not set or an override?
301+
if (this.data == NO_DATA || !(this.data instanceof String)) {
302+
this.data = data;
303+
} else {
304+
// handle multi-line data
305+
if (!(data instanceof String)) {
306+
throw new IllegalArgumentException("Cannot concatenate non-string event data");
307+
}
308+
this.data += "\n" + data; // concatenate strings
309+
}
310+
return this;
311+
}
312+
313+
/**
314+
* Use an array of strings to set the value of a multi-line event.
315+
*
316+
* @param data array of strings
317+
* @return updated builder instance
318+
*/
319+
public Builder data(String... data) {
320+
StringBuilder builder = new StringBuilder();
321+
if (this.data != NO_DATA) {
322+
if (!(this.data instanceof String)) {
323+
throw new IllegalArgumentException("Cannot concatenate non-string event data");
324+
}
325+
builder.append(this.data).append("\n");
326+
}
327+
for (int i = 0; i < data.length; i++) {
328+
builder.append(data[i]);
329+
if (i != data.length - 1) {
330+
builder.append("\n");
331+
}
332+
}
333+
this.data = builder.toString();
298334
return this;
299335
}
300336

webclient/sse/src/main/java/io/helidon/webclient/sse/SseSourceHandlerProvider.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2023, 2024 Oracle and/or its affiliates.
2+
* Copyright (c) 2023, 2025 Oracle and/or its affiliates.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -79,6 +79,9 @@ public <X extends Source<SseEvent>> void handle(X source, HttpClientResponse res
7979
}
8080
emit = true;
8181
if (line.startsWith(DATA)) {
82+
if (!data.isEmpty()) {
83+
data.append("\n"); // multi-line string data
84+
}
8285
data.append(skipPrefix(line));
8386
} else if (line.startsWith(EVENT)) {
8487
sseBuilder.name(skipPrefix(line));

webserver/sse/src/main/java/io/helidon/webserver/sse/DataWriterSseSink.java

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2024 Oracle and/or its affiliates.
2+
* Copyright (c) 2024, 2025 Oracle and/or its affiliates.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -100,10 +100,23 @@ public DataWriterSseSink emit(SseEvent sseEvent) {
100100
}
101101
Object data = sseEvent.data();
102102
if (data != null) {
103-
bufferData.write(SSE_DATA);
104-
byte[] bytes = serializeData(data, sseEvent.mediaType().orElse(MediaTypes.TEXT_PLAIN));
105-
bufferData.write(bytes);
106-
bufferData.write(SSE_NL);
103+
MediaType mediaType = sseEvent.mediaType().orElse(MediaTypes.TEXT_PLAIN);
104+
105+
// is it multi-line string data?
106+
if (data instanceof String stringData && stringData.contains("\n")) {
107+
String[] lines = stringData.split("\n");
108+
for (String line : lines) {
109+
bufferData.write(SSE_DATA);
110+
byte[] bytes = serializeData(line, mediaType);
111+
bufferData.write(bytes);
112+
bufferData.write(SSE_NL);
113+
}
114+
} else {
115+
bufferData.write(SSE_DATA);
116+
byte[] bytes = serializeData(data, mediaType);
117+
bufferData.write(bytes);
118+
bufferData.write(SSE_NL);
119+
}
107120
}
108121
bufferData.write(SSE_NL);
109122

webserver/tests/sse/src/test/java/io/helidon/webserver/tests/sse/SseClientTest.java

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2023, 2024 Oracle and/or its affiliates.
2+
* Copyright (c) 2023, 2025 Oracle and/or its affiliates.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -53,6 +53,7 @@ class SseClientTest extends SseBaseTest {
5353
@SetUpRoute
5454
static void routing(HttpRules rules) {
5555
rules.get("/sseString1", SseClientTest::sseString1);
56+
rules.get("/sseString2", SseClientTest::sseString2);
5657
rules.get("/sseJson1", SseClientTest::sseJson1);
5758
rules.get("/sseJson2", SseServerTest::sseJson2);
5859
}
@@ -71,6 +72,20 @@ static void sseString1(ServerRequest req, ServerResponse res) {
7172
}
7273
}
7374

75+
static void sseString2(ServerRequest req, ServerResponse res) {
76+
try (SseSink sseSink = res.sink(SseSink.TYPE)) {
77+
sseSink.emit(SseEvent.builder()
78+
.data("first line")
79+
.data("second line")
80+
.data("third line")
81+
.build())
82+
.emit(SseEvent.builder()
83+
.name("second")
84+
.data("first line\nsecond line\nthird line")
85+
.build());
86+
}
87+
}
88+
7489
@Test
7590
void testSseString1() throws InterruptedException {
7691
try (Http1ClientResponse r = client.get("/sseString1").header(ACCEPT_EVENT_STREAM).request()) {
@@ -106,6 +121,26 @@ public void onClose() {
106121
}
107122
}
108123

124+
@Test
125+
void testSseString2() throws InterruptedException {
126+
try (Http1ClientResponse r = client.get("/sseString2").header(ACCEPT_EVENT_STREAM).request()) {
127+
CountDownLatch latch = new CountDownLatch(1);
128+
r.source(SseSource.TYPE, new SseSource() {
129+
130+
@Override
131+
public void onEvent(SseEvent event) {
132+
assertThat(event.data(), is("first line\nsecond line\nthird line"));
133+
}
134+
135+
@Override
136+
public void onClose() {
137+
latch.countDown();
138+
}
139+
});
140+
assertThat(latch.await(5, TimeUnit.SECONDS), is(true));
141+
}
142+
}
143+
109144
@Test
110145
void testSseJson1() throws InterruptedException {
111146
try (Http1ClientResponse r = client.get("/sseJson1").header(ACCEPT_EVENT_STREAM).request()) {

webserver/tests/sse/src/test/java/io/helidon/webserver/tests/sse/SseEventTest.java

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2023, 2024 Oracle and/or its affiliates.
2+
* Copyright (c) 2023, 2025 Oracle and/or its affiliates.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -106,4 +106,47 @@ void testConvertNoData() {
106106
assertThrows(IllegalStateException.class, () -> event.data(Object.class));
107107
assertThrows(IllegalStateException.class, () -> event.data(Object.class, MediaTypes.TEXT_PLAIN));
108108
}
109+
110+
@Test
111+
void testMultiLineStringData() {
112+
SseEvent event = SseEvent.builder()
113+
.mediaContext(mediaContext)
114+
.data("first")
115+
.data("second")
116+
.data("third")
117+
.build();
118+
assertThat(event.data(String.class), is("first\nsecond\nthird"));
119+
}
120+
121+
@Test
122+
void testBadMultiLineStringData() {
123+
assertThrows(IllegalArgumentException.class,
124+
() -> SseEvent.builder()
125+
.mediaContext(mediaContext)
126+
.data("first")
127+
.data(new Object()) // not a string!
128+
.data("third")
129+
.build());
130+
}
131+
132+
@Test
133+
void testMultiLineStringArrayData() {
134+
SseEvent event = SseEvent.builder()
135+
.mediaContext(mediaContext)
136+
.data("first", "second", "third")
137+
.build();
138+
assertThat(event.data(String.class), is("first\nsecond\nthird"));
139+
assertThat(event.data(String[].class), is(new String[]{"first", "second", "third"}));
140+
}
141+
142+
@Test
143+
void testBadMultiLineStringArrayData() {
144+
assertThrows(IllegalArgumentException.class,
145+
() -> SseEvent.builder()
146+
.mediaContext(mediaContext)
147+
.data(new Object())
148+
.data("first", "second", "third") // cannot concatenate
149+
.build());
150+
}
151+
109152
}

0 commit comments

Comments
 (0)