mirror of
https://github.com/spring-projects/spring-framework.git
synced 2026-09-17 16:39:29 +00:00
Centralize Server Sent Event utility methods
Prior to this commit, many classes would support writing Server Sent Events in some way to the response output stream. This has lead to some code duplication. This commit refactors the duplicated code in a shared `SseUtils` class. Closes gh-37065
This commit is contained in:
+14
-14
@@ -42,6 +42,7 @@ import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.context.request.async.DeferredResult;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.util.SseUtils;
|
||||
|
||||
/**
|
||||
* Implementation of {@link ServerResponse} for sending
|
||||
@@ -149,33 +150,35 @@ final class SseServerResponse extends AbstractServerResponse {
|
||||
@Override
|
||||
public SseBuilder id(String id) {
|
||||
Assert.hasLength(id, "Id must not be empty");
|
||||
return field("id", id);
|
||||
SseUtils.assertNoLineSeparator(id);
|
||||
this.builder.append("id:").append(id).append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SseBuilder event(String eventName) {
|
||||
Assert.hasLength(eventName, "Name must not be empty");
|
||||
return field("event", eventName);
|
||||
SseUtils.assertNoLineSeparator(eventName);
|
||||
this.builder.append("event:").append(eventName).append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SseBuilder retry(Duration duration) {
|
||||
Assert.notNull(duration, "Duration must not be null");
|
||||
String millis = Long.toString(duration.toMillis());
|
||||
return field("retry", millis);
|
||||
this.builder.append("retry:").append(duration.toMillis()).append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SseBuilder comment(String comment) {
|
||||
String[] lines = comment.split("\n");
|
||||
for (String line : lines) {
|
||||
field("", line);
|
||||
}
|
||||
return this;
|
||||
return field("", comment);
|
||||
}
|
||||
|
||||
private SseBuilder field(String name, String value) {
|
||||
this.builder.append(name).append(':').append(value).append('\n');
|
||||
this.builder.append(name).append(':');
|
||||
SseUtils.appendFieldValue(name, value, this.builder);
|
||||
this.builder.append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -191,10 +194,7 @@ final class SseServerResponse extends AbstractServerResponse {
|
||||
}
|
||||
|
||||
private void writeString(String string) throws IOException {
|
||||
String[] lines = string.split("\n");
|
||||
for (String line : lines) {
|
||||
field("data", line);
|
||||
}
|
||||
field("data", string);
|
||||
this.send();
|
||||
}
|
||||
|
||||
|
||||
+2
-19
@@ -66,6 +66,7 @@ import org.springframework.web.servlet.View;
|
||||
import org.springframework.web.servlet.ViewResolver;
|
||||
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
|
||||
import org.springframework.web.servlet.view.FragmentsRendering;
|
||||
import org.springframework.web.util.SseUtils;
|
||||
|
||||
/**
|
||||
* Handler for return values of type:
|
||||
@@ -476,26 +477,8 @@ public class ResponseBodyEmitterReturnValueHandler implements HandlerMethodRetur
|
||||
public byte[] getFragmentContent() {
|
||||
this.writer.flush();
|
||||
String content = this.outputStream.toString(this.charset);
|
||||
if (content.indexOf('\n') == -1 && content.indexOf('\r') == -1) {
|
||||
return content.getBytes(this.charset);
|
||||
}
|
||||
StringBuilder fragment = new StringBuilder();
|
||||
int length = content.length();
|
||||
for (int i = 0; i < length; i++) {
|
||||
char c = content.charAt(i);
|
||||
if (c == '\r') {
|
||||
if (i + 1 < length && content.charAt(i + 1) == '\n') {
|
||||
i++;
|
||||
}
|
||||
fragment.append("\ndata:");
|
||||
}
|
||||
else if (c == '\n') {
|
||||
fragment.append("\ndata:");
|
||||
}
|
||||
else {
|
||||
fragment.append(c);
|
||||
}
|
||||
}
|
||||
SseUtils.appendFieldValue("data", content, fragment);
|
||||
return fragment.toString().getBytes(this.charset);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-34
@@ -27,10 +27,10 @@ import org.jspecify.annotations.Nullable;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.server.ServerHttpResponse;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.util.SseUtils;
|
||||
|
||||
/**
|
||||
* A specialization of {@link ResponseBodyEmitter} for sending
|
||||
@@ -203,14 +203,14 @@ public class SseEmitter extends ResponseBodyEmitter {
|
||||
|
||||
@Override
|
||||
public SseEventBuilder id(String id) {
|
||||
checkEvent(id);
|
||||
SseUtils.assertNoLineSeparator(id);
|
||||
append("id:").append(id).append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SseEventBuilder name(String name) {
|
||||
checkEvent(name);
|
||||
SseUtils.assertNoLineSeparator(name);
|
||||
this.hasName = true;
|
||||
append("event:").append(name).append('\n');
|
||||
return this;
|
||||
@@ -225,7 +225,7 @@ public class SseEmitter extends ResponseBodyEmitter {
|
||||
@Override
|
||||
public SseEventBuilder comment(String comment) {
|
||||
append(':');
|
||||
appendEscaped(comment, "\n:");
|
||||
SseUtils.appendFieldValue("", comment, this.sb);
|
||||
append('\n');
|
||||
return this;
|
||||
}
|
||||
@@ -252,45 +252,16 @@ public class SseEmitter extends ResponseBodyEmitter {
|
||||
return this;
|
||||
}
|
||||
|
||||
private static void checkEvent(String content) {
|
||||
Assert.isTrue(content.indexOf('\n') == -1 && content.indexOf('\r') == -1,
|
||||
"illegal character '\\n' or '\\r' in event content");
|
||||
}
|
||||
|
||||
private void writeStringData(String input, @Nullable MediaType mediaType) {
|
||||
if (input.indexOf('\n') == -1 && input.indexOf('\r') == -1) {
|
||||
this.dataToSend.add(new DataWithMediaType(input, mediaType));
|
||||
}
|
||||
else {
|
||||
appendEscaped(input, "\ndata:");
|
||||
SseUtils.appendFieldValue("data", input, this.sb);
|
||||
saveAppendedText(mediaType);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendEscaped(String input, String replacement) {
|
||||
if (input.indexOf('\n') == -1 && input.indexOf('\r') == -1) {
|
||||
append(input);
|
||||
}
|
||||
else {
|
||||
int length = input.length();
|
||||
for (int i = 0; i < length; i++) {
|
||||
char c = input.charAt(i);
|
||||
if (c == '\r') {
|
||||
if (i + 1 < length && input.charAt(i + 1) == '\n') {
|
||||
i++;
|
||||
}
|
||||
append(replacement);
|
||||
}
|
||||
else if (c == '\n') {
|
||||
append(replacement);
|
||||
}
|
||||
else {
|
||||
append(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SseEventBuilderImpl append(String text) {
|
||||
this.sb.append(text);
|
||||
return this;
|
||||
|
||||
+41
@@ -212,6 +212,47 @@ class SseServerResponseTests {
|
||||
assertThat(this.mockResponse.getContentAsString()).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendStringWithCarriageReturn() throws Exception {
|
||||
String body = "line1\rline2\r\nline3";
|
||||
ServerResponse response = ServerResponse.sse(sse -> {
|
||||
try {
|
||||
sse.send(body);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new UncheckedIOException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
ServerResponse.Context context = Collections::emptyList;
|
||||
|
||||
ModelAndView mav = response.writeTo(this.mockRequest, this.mockResponse, context);
|
||||
assertThat(mav).isNull();
|
||||
|
||||
String expected = "data:line1\ndata:line2\ndata:line3\n\n";
|
||||
assertThat(this.mockResponse.getContentAsString()).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void commentWithCarriageReturn() throws Exception {
|
||||
ServerResponse response = ServerResponse.sse(sse -> {
|
||||
try {
|
||||
sse.comment("line1\rline2").send();
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new UncheckedIOException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
ServerResponse.Context context = Collections::emptyList;
|
||||
|
||||
ModelAndView mav = response.writeTo(this.mockRequest, this.mockResponse, context);
|
||||
assertThat(mav).isNull();
|
||||
|
||||
String expected = ":line1\n:line2\n\n";
|
||||
assertThat(this.mockResponse.getContentAsString()).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test // gh-34608
|
||||
void sendHeartbeat() throws Exception {
|
||||
ServerResponse response = ServerResponse.sse(sse -> {
|
||||
|
||||
Reference in New Issue
Block a user