From 6a52a48979c270f790bea815867b20ee084a6241 Mon Sep 17 00:00:00 2001 From: Lei Li Date: Wed, 21 Oct 2026 09:40:28 +0000 Subject: [PATCH 1/2] Add SSE annotation support for MVC controllers --- docs/asciidoc/server-sent-event.adoc | 26 +++++ .../main/java/io/jooby/annotation/SSE.java | 38 +++++++ .../io/jooby/internal/apt/HttpMethod.java | 1 + .../java/io/jooby/internal/apt/RestRoute.java | 36 ++++++- .../test/java/tests/sse/SseController.java | 28 ++++++ .../src/test/java/tests/sse/SseTest.java | 99 +++++++++++++++++++ 6 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 jooby/src/main/java/io/jooby/annotation/SSE.java create mode 100644 modules/jooby-apt/src/test/java/tests/sse/SseController.java create mode 100644 modules/jooby-apt/src/test/java/tests/sse/SseTest.java diff --git a/docs/asciidoc/server-sent-event.adoc b/docs/asciidoc/server-sent-event.adoc index d9ec23a4fb..a9a2cd605c 100644 --- a/docs/asciidoc/server-sent-event.adoc +++ b/docs/asciidoc/server-sent-event.adoc @@ -25,6 +25,32 @@ https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events[Server-Sent <1> Connection established. <2> Send a message to the client. +==== MVC Controllers + +Use `@SSE` to register an SSE route from a controller: + +[source,java] +---- +import io.jooby.ServerSentEmitter; +import io.jooby.annotation.SSE; + +public class Events { + @SSE("/sse") + public void sse(ServerSentEmitter emitter) { + emitter.send("Welcome"); + emitter.onClose(() -> { + // Clean up resources. + }); + emitter.close(); + } +} +---- + +Install the controller with `mvc(new Events())`. The annotation processor generates an +`app.sse("/sse", this::sse)` registration. The controller method must return `void`. +The emitter is injected directly; regular MVC parameters and class-level `@Path` prefixes +are supported. Use `@SSE(path = "/sse")` or multiple path values as with other MVC routes. + ==== Message Options Additional message properties (like custom events, IDs, and retry timeouts) are available via the javadoc:ServerSentMessage[] class: diff --git a/jooby/src/main/java/io/jooby/annotation/SSE.java b/jooby/src/main/java/io/jooby/annotation/SSE.java new file mode 100644 index 0000000000..6dd5161d01 --- /dev/null +++ b/jooby/src/main/java/io/jooby/annotation/SSE.java @@ -0,0 +1,38 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Server-sent event route for MVC controllers. The method must return {@code void} and may receive + * a {@link io.jooby.ServerSentEmitter} alongside regular MVC parameters. + * + *
{@code
+ * @SSE("/sse")
+ * public void sse(ServerSentEmitter emitter) {
+ *   emitter.send("Welcome");
+ *   emitter.onClose(() -> {});
+ *   emitter.close();
+ * }
+ * }
+ */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface SSE { + /** + * @return Path patterns. Shortcut for {@link #path()}. + */ + String[] value() default {}; + + /** + * @return Path patterns. + */ + String[] path() default {}; +} diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java index 6cefe3a664..f8e83304c0 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java @@ -23,6 +23,7 @@ public enum HttpMethod implements AnnotationSupport { TRACE("io.jooby.annotation"), DELETE, GET, + SSE("io.jooby.annotation"), HEAD, OPTIONS, PATCH, diff --git a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java index 83d7a9b019..59aadf09b5 100644 --- a/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java +++ b/modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java @@ -32,6 +32,13 @@ public RestRoute(RestRouter router, ExecutableElement method, TypeElement httpMe super(router, method); this.httpMethodAnnotation = httpMethodAnnotation; this.generatedName = method.getSimpleName().toString(); + if (isSse() && (!returnType.isVoid() || isSuspendFun())) { + context.error("@SSE method must return void and must not be suspend: %s", method); + } + } + + private boolean isSse() { + return httpMethodAnnotation.getQualifiedName().contentEquals("io.jooby.annotation.SSE"); } public String getGeneratedName() { @@ -196,7 +203,10 @@ public List generateHandlerCall(boolean kt) { int controllerIndent = 2; for (var parameter : getParameters(true)) { - var generatedParameter = parameter.generateMapping(kt); + var generatedParameter = + isSse() && parameter.getType().is("io.jooby.ServerSentEmitter") + ? "emitter" + : parameter.generateMapping(kt); if (parameter.isRequireBeanValidation()) { generatedParameter = CodeBlock.of( @@ -208,7 +218,13 @@ public List generateHandlerCall(boolean kt) { buffer.add( statement(indent(controllerIndent), var(kt), "c = this.factory.apply(ctx)", semicolon(kt))); - if (returnType.isVoid()) { + if (isSse()) { + buffer.add( + statement( + indent(controllerIndent), + makeCall(kt, paramList.toString(), false, false), + semicolon(kt))); + } else if (returnType.isVoid()) { String statusCode = httpMethodAnnotation.getSimpleName().toString().equals("DELETE") ? "NO_CONTENT" : "OK"; buffer.add( @@ -274,6 +290,22 @@ private void methodCallHeader( String returnTypeGenerics, String returnTypeString, boolean throwsException) { + if (isSse()) { + if (kt) { + if (throwsException) buffer.add(statement("@Throws(Exception::class)")); + buffer.add(statement("fun ", methodName, "(emitter: io.jooby.ServerSentEmitter) {")); + buffer.add(statement(indent(2), "val ctx = emitter.getContext()")); + } else { + buffer.add( + statement( + "public void ", + methodName, + "(io.jooby.ServerSentEmitter emitter)", + throwsException ? " throws Exception {" : " {")); + buffer.add(statement(indent(2), "var ctx = emitter.getContext();")); + } + return; + } if (kt) { if (throwsException) buffer.add(statement("@Throws(Exception::class)")); diff --git a/modules/jooby-apt/src/test/java/tests/sse/SseController.java b/modules/jooby-apt/src/test/java/tests/sse/SseController.java new file mode 100644 index 0000000000..4c76375482 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/sse/SseController.java @@ -0,0 +1,28 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.sse; + +import io.jooby.Context; +import io.jooby.ServerSentEmitter; +import io.jooby.annotation.Path; +import io.jooby.annotation.PathParam; +import io.jooby.annotation.SSE; + +@Path("/events") +public class SseController { + @SSE({"/sse", "/alias"}) + public void sse(ServerSentEmitter emitter) { + emitter.send("Welcome"); + emitter.onClose(() -> {}); + emitter.close(); + } + + @SSE(path = "/{name}") + public void named(Context ctx, @PathParam String name, ServerSentEmitter emitter) + throws Exception { + emitter.send(name + ":" + ctx.getRequestPath()); + } +} diff --git a/modules/jooby-apt/src/test/java/tests/sse/SseTest.java b/modules/jooby-apt/src/test/java/tests/sse/SseTest.java new file mode 100644 index 0000000000..4932e7fe33 --- /dev/null +++ b/modules/jooby-apt/src/test/java/tests/sse/SseTest.java @@ -0,0 +1,99 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package tests.sse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.google.testing.compile.CompilationSubject; +import com.google.testing.compile.Compiler; +import com.google.testing.compile.JavaFileObjects; +import io.jooby.Context; +import io.jooby.ServerSentEmitter; +import io.jooby.StatusCode; +import io.jooby.apt.JoobyProcessor; +import io.jooby.apt.ProcessorRunner; +import io.jooby.value.Value; +import io.jooby.value.ValueFactory; +import org.junit.jupiter.api.Test; + +public class SseTest { + @Test + public void shouldGenerateSseRoutesAndInvokeEmitter() throws Exception { + new ProcessorRunner(new SseController()) + .withSourceCode( + source -> { + assertThat(source).contains("app.sse(\"/events/sse\", this::sse)"); + assertThat(source).contains("app.sse(\"/events/alias\", this::sse)"); + assertThat(source).contains("app.sse(\"/events/{name}\", this::named)"); + assertThat(source).contains("public void sse(io.jooby.ServerSentEmitter emitter)"); + assertThat(source).doesNotContain("return ctx.getResponseCode()"); + }) + .withRouter( + app -> { + assertThat(app.getRoutes()).hasSize(3); + var route = app.getRoutes().getFirst(); + assertThat(route.getMethod()).isEqualTo("SSE"); + var ctx = mock(Context.class, RETURNS_SELF); + var emitter = mock(ServerSentEmitter.class); + when(emitter.getContext()).thenReturn(ctx); + doAnswer( + call -> { + call.getArgument(0, ServerSentEmitter.Handler.class).handle(emitter); + return ctx; + }) + .when(ctx) + .upgrade(any(ServerSentEmitter.Handler.class)); + + route.getHandler().apply(ctx); + + verify(ctx).setResponseType("text/event-stream; charset=utf-8"); + verify(emitter).send("Welcome"); + verify(emitter).onClose(any()); + verify(emitter).close(); + verify(ctx).setResponseCode(StatusCode.OK); + + when(ctx.path("name")).thenReturn(Value.value(new ValueFactory(), "name", "Ada")); + when(ctx.getRequestPath()).thenReturn("/events/Ada"); + app.getRoutes().get(2).getHandler().apply(ctx); + verify(emitter).send("Ada:/events/Ada"); + }); + } + + @Test + public void shouldGenerateKotlinEmitterHandler() throws Exception { + new ProcessorRunner(new SseController()) + .withSourceCode( + true, + source -> { + assertThat(source).contains("app.sse(\"/events/sse\", this::sse)"); + assertThat(source).contains("fun sse(emitter: io.jooby.ServerSentEmitter)"); + assertThat(source).contains("val ctx = emitter.getContext()"); + }); + } + + @Test + public void shouldRejectNonVoidSseMethods() { + var compilation = + Compiler.javac() + .withProcessors(new JoobyProcessor()) + .compile( + JavaFileObjects.forSourceString( + "InvalidSse", + """ + import io.jooby.annotation.SSE; + public class InvalidSse { + @SSE("/sse") + public String sse(io.jooby.ServerSentEmitter emitter) { + return "invalid"; + } + } + """)); + CompilationSubject.assertThat(compilation).failed(); + CompilationSubject.assertThat(compilation).hadErrorContaining("@SSE method must return void"); + } +} From f1f33684418ab03a7e4cce1e4696247a6decc1fe Mon Sep 17 00:00:00 2001 From: lilei Date: Thu, 22 Oct 2026 10:36:59 +0800 Subject: [PATCH 2/2] SSE support for Kotlin test --- docs/asciidoc/server-sent-event.adoc | 32 +++++++++++-- .../src/test/kotlin/io/jooby/KotlinSseTest.kt | 46 +++++++++++++++++++ .../jooby/internal/mvc/KotlinSseController.kt | 29 ++++++++++++ 3 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 tests/src/test/kotlin/io/jooby/KotlinSseTest.kt create mode 100644 tests/src/test/kotlin/io/jooby/internal/mvc/KotlinSseController.kt diff --git a/docs/asciidoc/server-sent-event.adoc b/docs/asciidoc/server-sent-event.adoc index a9a2cd605c..be98802874 100644 --- a/docs/asciidoc/server-sent-event.adoc +++ b/docs/asciidoc/server-sent-event.adoc @@ -7,7 +7,7 @@ https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events[Server-Sent ---- { sse("/sse", sse -> { // <1> - sse.send("Welcome"); // <2> + sse.send("Welcome jooby"); // <2> }); } ---- @@ -17,7 +17,7 @@ https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events[Server-Sent ---- { sse("/sse") { // <1> - sse.send("Welcome") // <2> + sse.send("Welcome jooby") // <2> } } ---- @@ -29,7 +29,8 @@ https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events[Server-Sent Use `@SSE` to register an SSE route from a controller: -[source,java] +.Java +[source,java,role="primary"] ---- import io.jooby.ServerSentEmitter; import io.jooby.annotation.SSE; @@ -46,10 +47,31 @@ public class Events { } ---- +.Kotlin +[source,kotlin,role="secondary"] +---- +import io.jooby.ServerSentEmitter +import io.jooby.annotation.SSE + +class Events { + @SSE("/sse") + fun sse(emitter: ServerSentEmitter) { + emitter.send("Welcome") + emitter.onClose { + // Clean up resources. + } + emitter.close() + } +} +---- + Install the controller with `mvc(new Events())`. The annotation processor generates an -`app.sse("/sse", this::sse)` registration. The controller method must return `void`. +`app.sse("/sse", this::sse)` registration. In Kotlin, use `mvc(Events())` with KAPT enabled. +The controller method must return `void` in Java or `Unit` in Kotlin. Kotlin `suspend` +functions are not supported for `@SSE` methods. The emitter is injected directly; regular MVC parameters and class-level `@Path` prefixes -are supported. Use `@SSE(path = "/sse")` or multiple path values as with other MVC routes. +are supported. Use `@SSE(path = "/sse")` (`@SSE(path = ["/sse"])` in Kotlin) or multiple +path values as with other MVC routes. ==== Message Options diff --git a/tests/src/test/kotlin/io/jooby/KotlinSseTest.kt b/tests/src/test/kotlin/io/jooby/KotlinSseTest.kt new file mode 100644 index 0000000000..2746393064 --- /dev/null +++ b/tests/src/test/kotlin/io/jooby/KotlinSseTest.kt @@ -0,0 +1,46 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby + +import io.jooby.internal.mvc.KotlinSseController_ +import io.jooby.value.Value +import io.jooby.value.ValueFactory +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.* + +class KotlinSseTest { + @Test + fun shouldSupportKotlinSseControllers() { + + val app = Jooby().apply { mvc(KotlinSseController_()) } + + assertEquals( + listOf("/kotlin-sse/events", "/kotlin-sse/alias", "/kotlin-sse/{name}"), + app.routes.map { it.pattern }) + + val ctx = mock(Context::class.java, RETURNS_SELF) + val emitter = mock(ServerSentEmitter::class.java) + `when`(emitter.context).thenReturn(ctx) + `when`(ctx.path("name")).thenReturn(Value.value(ValueFactory(), "name", "Ada")) + `when`(ctx.requestPath).thenReturn("/kotlin-sse/Ada") + doAnswer { call -> + call.getArgument(0).handle(emitter) + ctx + }.`when`(ctx).upgrade(any(ServerSentEmitter.Handler::class.java)) + + app.routes.forEach { route -> + assertEquals("SSE", route.method) + route.handler.apply(ctx) + } + + verify(emitter, times(2)).send("Welcome") + verify(emitter).send("Ada:/kotlin-sse/Ada") + verify(emitter, times(3)).close() + verify(ctx, times(3)).setResponseType("text/event-stream; charset=utf-8") + } +} diff --git a/tests/src/test/kotlin/io/jooby/internal/mvc/KotlinSseController.kt b/tests/src/test/kotlin/io/jooby/internal/mvc/KotlinSseController.kt new file mode 100644 index 0000000000..299b958fe2 --- /dev/null +++ b/tests/src/test/kotlin/io/jooby/internal/mvc/KotlinSseController.kt @@ -0,0 +1,29 @@ +/* + * Jooby https://jooby.io + * Apache License Version 2.0 https://jooby.io/LICENSE.txt + * Copyright 2014 Edgar Espina + */ +package io.jooby.internal.mvc + +import io.jooby.Context +import io.jooby.ServerSentEmitter +import io.jooby.annotation.Path +import io.jooby.annotation.PathParam +import io.jooby.annotation.SSE + +@Path("/kotlin-sse") +class KotlinSseController { + + @SSE("/events", "/alias") + fun events(emitter: ServerSentEmitter) { + emitter.send("Welcome") + emitter.close() + } + + @SSE(path = ["/{name}"]) + fun named(ctx: Context, @PathParam name: String, emitter: ServerSentEmitter): Unit { + emitter.send("$name:${ctx.requestPath}") + emitter.close() + } + +}