Repository navigation
Stream text/event-stream responses instead of buffering them #413
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,46 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| package com.getyourguide.openapi.validation.filter; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.servlet.ServletOutputStream; | ||||||||||||||||||||||||||||||||||||||||||||||
| import jakarta.servlet.http.HttpServletResponse; | ||||||||||||||||||||||||||||||||||||||||||||||
| import java.io.IOException; | ||||||||||||||||||||||||||||||||||||||||||||||
| import java.io.PrintWriter; | ||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.Locale; | ||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.http.MediaType; | ||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.web.util.ContentCachingResponseWrapper; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||
| * Caches the response body for validation, except for Server-Sent Events: a {@code text/event-stream} | ||||||||||||||||||||||||||||||||||||||||||||||
| * response is written and flushed straight through so each event reaches the client as it is sent. | ||||||||||||||||||||||||||||||||||||||||||||||
| * Its body is never validated anyway, as only JSON, XML and HTML responses are. | ||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||
| public class EventStreamAwareContentCachingResponseWrapper extends ContentCachingResponseWrapper { | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| public EventStreamAwareContentCachingResponseWrapper(HttpServletResponse response) { | ||||||||||||||||||||||||||||||||||||||||||||||
| super(response); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||||||||||||||||||||||
| public ServletOutputStream getOutputStream() throws IOException { | ||||||||||||||||||||||||||||||||||||||||||||||
| return isEventStream() ? getResponse().getOutputStream() : super.getOutputStream(); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||||||||||||||||||||||
| public PrintWriter getWriter() throws IOException { | ||||||||||||||||||||||||||||||||||||||||||||||
| return isEventStream() ? getResponse().getWriter() : super.getWriter(); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| @Override | ||||||||||||||||||||||||||||||||||||||||||||||
| public void flushBuffer() throws IOException { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (isEventStream()) { | ||||||||||||||||||||||||||||||||||||||||||||||
| getResponse().flushBuffer(); | ||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||
| super.flushBuffer(); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| private boolean isEventStream() { | ||||||||||||||||||||||||||||||||||||||||||||||
| var contentType = getContentType(); | ||||||||||||||||||||||||||||||||||||||||||||||
| return contentType != null | ||||||||||||||||||||||||||||||||||||||||||||||
| && contentType.toLowerCase(Locale.ROOT).startsWith(MediaType.TEXT_EVENT_STREAM_VALUE); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+38
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: nl -ba spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/EventStreamAwareContentCachingResponseWrapper.java
rg -n 'event-stream|copyBodyToResponse|isEventStream|ContentCachingWrapperFactory' spring-boot-starter/spring-boot-starter-web/src/main/javaRepository: getyourguide/openapi-validation-java Length of output: 5546 🏁 Script executed: set -o pipefail
printf '%s\n' '--- factory ---'
nl -ba spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/factory/ContentCachingWrapperFactory.java
printf '%s\n' '--- filter ---'
nl -ba spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationFilter.java
printf '%s\n' '--- interceptor ---'
nl -ba spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/filter/OpenApiValidationInterceptor.java
printf '%s\n' '--- relevant declarations/usages ---'
rg -n -F --glob '*.java' -e 'copyBodyToResponse' -e 'validateResponse' -e 'getContentType' -e 'ContentCachingWrapperFactory' -e 'EventStreamAwareContentCachingResponseWrapper' spring-boot-starter test || test "$?" -eq 1
printf '%s\n' '--- changed tests and media-type contracts ---'
rg -n -F --glob '*.java' -e 'text/event-stream' -e 'text/event-streaming' -e 'SseEmitter' -e 'ResponseBodyAdvice' spring-boot-starter test || test "$?" -eq 1Repository: getyourguide/openapi-validation-java Length of output: 28218 🏁 Script executed: set -o pipefail
printf '%s\n' '--- traffic selector implementations ---'
rg -n -F --glob '*.java' -e 'class .*TrafficSelector' -e 'interface TrafficSelector' -e 'canResponseBeValidated' -e 'shouldFailOnResponseViolation' spring-boot-starter core api test || test "$?" -eq 1
printf '%s\n' '--- response validator implementations and media handling ---'
rg -n -F --glob '*.java' -e 'validateResponseObject' -e 'ResponseMetaData' -e 'MediaType.APPLICATION_JSON_VALUE' -e 'TEXT_HTML_VALUE' -e 'APPLICATION_XML_VALUE' spring-boot-starter core api test || test "$?" -eq 1
printf '%s\n' '--- test controllers and registrations ---'
nl -ba spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/controller/EventStreamController.java
rg -n -F --glob '*.java' -e 'EventStreamController' -e 'MockMvc' -e 'addInterceptors' -e 'OpenApiValidationFilter' spring-boot-starter/spring-boot-starter-web/src/test spring-boot-starter/spring-boot-starter-web/src/main || test "$?" -eq 1
printf '%s\n' '--- wrapper tests ---'
nl -ba spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/filter/EventStreamAwareContentCachingResponseWrapperTest.javaRepository: getyourguide/openapi-validation-java Length of output: 32634 🏁 Script executed: set -o pipefail
printf '%s\n' '--- selector declarations and implementations ---'
rg -n -F --glob '*.java' -e 'interface TrafficSelector' -e 'class DefaultTrafficSelector' -e 'implements TrafficSelector' -e 'canResponseBeValidated' -e 'canRequestBeValidated' .
printf '%s\n' '--- response validation policy and supported media types ---'
rg -n -F --glob '*.java' -e 'text/event-stream' -e 'application/json' -e 'application/xml' -e 'text/html' -e 'MediaType.' -e 'canResponseBeValidated' .
printf '%s\n' '--- integration validation configuration ---'
rg -n -F --glob '*.java' -e 'OpenApiValidationApplicationProperties' -e 'TrafficSelector' -e 'shouldFailOnResponseViolation' spring-boot-starter/spring-boot-starter-web/src/test/java spring-boot-starter/spring-boot-starter-core/src/main/java
printf '%s\n' '--- integration test setup and response contracts ---'
nl -ba spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/EventStreamIntegrationTest.java
nl -ba spring-boot-starter/spring-boot-starter-web/src/test/java/com/getyourguide/openapi/validation/integration/OpenApiValidationIntegrationTest.javaRepository: getyourguide/openapi-validation-java Length of output: 32340 🏁 Script executed: set -o pipefail
nl -ba openapi-validation-api/src/main/java/com/getyourguide/openapi/validation/api/selector/DefaultTrafficSelector.java
nl -ba openapi-validation-api/src/main/java/com/getyourguide/openapi/validation/api/selector/TrafficSelector.javaRepository: getyourguide/openapi-validation-java Length of output: 6207 🏁 Script executed: set -o pipefail
printf '%s\n' '--- web auto-configuration ---'
nl -ba spring-boot-starter/spring-boot-starter-web/src/main/java/com/getyourguide/openapi/validation/autoconfigure/SpringWebLibraryAutoConfiguration.java
printf '%s\n' '--- core auto-configuration selector binding ---'
nl -ba spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/LibraryAutoConfiguration.java
nl -ba spring-boot-starter/spring-boot-starter-core/src/main/java/com/getyourguide/openapi/validation/autoconfigure/FallbackLibraryAutoConfiguration.java
printf '%s\n' '--- response validator declaration and implementation ---'
rg -n -F --glob '*.java' -e 'class .*OpenApi.*Validator' -e 'validateResponseObject(RequestMetaData' -e 'validateResponseObject(' openapi-validation-core openapi-validation-apiRepository: getyourguide/openapi-validation-java Length of output: 13128 🏁 Script executed: set -o pipefail
nl -ba openapi-validation-core/src/main/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidator.java | sed -n '110,180p'
nl -ba openapi-validation-core/src/test/java/com/getyourguide/openapi/validation/core/OpenApiRequestValidatorTest.java | sed -n '150,220p'Repository: getyourguide/openapi-validation-java Length of output: 6191 Match the complete SSE media type before bypassing the cache.
Restrict the check to the exact media type, with optional parameters: Suggested fix private boolean isEventStream() {
var contentType = getContentType();
- return contentType != null
- && contentType.toLowerCase(Locale.ROOT).startsWith(MediaType.TEXT_EVENT_STREAM_VALUE);
+ if (contentType == null) {
+ return false;
+ }
+ var normalizedContentType = contentType.toLowerCase(Locale.ROOT);
+ return normalizedContentType.equals(MediaType.TEXT_EVENT_STREAM_VALUE)
+ || normalizedContentType.startsWith(MediaType.TEXT_EVENT_STREAM_VALUE + ";");
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| package com.getyourguide.openapi.validation.filter; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertFalse; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
|
|
||
| import java.io.IOException; | ||
| import java.nio.charset.StandardCharsets; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.springframework.mock.web.MockHttpServletResponse; | ||
|
|
||
| class EventStreamAwareContentCachingResponseWrapperTest { | ||
|
|
||
| private final MockHttpServletResponse response = new MockHttpServletResponse(); | ||
| private final EventStreamAwareContentCachingResponseWrapper wrapper = | ||
| new EventStreamAwareContentCachingResponseWrapper(response); | ||
|
|
||
| @Test | ||
| public void whenJsonResponseThenBodyIsCachedUntilCopied() throws IOException { | ||
| wrapper.setContentType("application/json"); | ||
| wrapper.getOutputStream().write("{}".getBytes(StandardCharsets.UTF_8)); | ||
| wrapper.flushBuffer(); | ||
|
|
||
| assertEquals("", response.getContentAsString()); | ||
| assertFalse(response.isCommitted()); | ||
| assertEquals("{}", new String(wrapper.getContentAsByteArray(), StandardCharsets.UTF_8)); | ||
|
|
||
| wrapper.copyBodyToResponse(); | ||
|
|
||
| assertEquals("{}", response.getContentAsString()); | ||
| } | ||
|
|
||
| @Test | ||
| public void whenEventStreamThenEachWriteAndFlushReachesTheResponse() throws IOException { | ||
| wrapper.setContentType("text/event-stream;charset=UTF-8"); | ||
| wrapper.getOutputStream().write("data: first\n\n".getBytes(StandardCharsets.UTF_8)); | ||
| wrapper.flushBuffer(); | ||
|
|
||
| assertEquals("data: first\n\n", response.getContentAsString()); | ||
| assertTrue(response.isCommitted()); | ||
| assertEquals(0, wrapper.getContentSize()); | ||
| } | ||
|
|
||
| @Test | ||
| public void whenEventStreamWrittenThroughWriterThenItReachesTheResponse() throws IOException { | ||
| wrapper.setContentType("TEXT/EVENT-STREAM"); | ||
| wrapper.getWriter().write("data: first\n\n"); | ||
| wrapper.getWriter().flush(); | ||
|
|
||
| assertEquals("data: first\n\n", response.getContentAsString()); | ||
| assertEquals(0, wrapper.getContentSize()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| package com.getyourguide.openapi.validation.integration; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; | ||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | ||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; | ||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; | ||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | ||
|
|
||
| import com.getyourguide.openapi.validation.integration.controller.EventStreamController; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.api.extension.ExtendWith; | ||
| import org.springframework.beans.factory.annotation.Autowired; | ||
| import org.springframework.boot.test.context.SpringBootTest; | ||
| import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.test.context.junit.jupiter.SpringExtension; | ||
| import org.springframework.test.web.servlet.MockMvc; | ||
|
|
||
| @SpringBootTest | ||
| @AutoConfigureMockMvc | ||
| @ExtendWith(SpringExtension.class) | ||
| public class EventStreamIntegrationTest { | ||
|
|
||
| @Autowired | ||
| private MockMvc mockMvc; | ||
|
|
||
| @Test | ||
| public void whenEventStreamThenEventsReachTheClientBeforeTheStreamCompletes() throws Exception { | ||
| var result = mockMvc.perform(get("/sse").accept(MediaType.TEXT_EVENT_STREAM)) | ||
| .andExpect(request().asyncStarted()) | ||
| .andReturn(); | ||
|
|
||
| assertEquals("data:first\n\n", result.getResponse().getContentAsString()); | ||
|
|
||
| var emitter = EventStreamController.CURRENT.get(); | ||
| emitter.send("second"); | ||
| emitter.complete(); | ||
|
|
||
| mockMvc.perform(asyncDispatch(result)) | ||
| .andExpectAll( | ||
| status().isOk(), | ||
| content().string("data:first\n\ndata:second\n\n") | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| package com.getyourguide.openapi.validation.integration.controller; | ||
|
|
||
| import java.io.IOException; | ||
| import java.util.concurrent.atomic.AtomicReference; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
| import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; | ||
|
|
||
| @RestController | ||
| public class EventStreamController { | ||
| public static final AtomicReference<SseEmitter> CURRENT = new AtomicReference<>(); | ||
|
|
||
| @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) | ||
| public SseEmitter stream() throws IOException { | ||
| var emitter = new SseEmitter(10_000L); | ||
| emitter.send("first"); | ||
| CURRENT.set(emitter); | ||
| return emitter; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Preserve streaming when the content type is set after output acquisition.
If code obtains the output stream before setting
text/event-stream, this method returns the caching stream. Later writes through that stream remain cached, even after the content type changes. The asynchronous filter path does not copy those bytes to the response, so the SSE client does not receive them. The writer path has the same problem. Select a delegate that checks the content type when it writes, or otherwise handle this permitted call order.🤖 Prompt for AI Agents