Skip to content

Commit bce9dd0

Browse files
authored
Merge pull request #4019 from mylyed/feat/sse-mvc
Add SSE annotation support for MVC controllers
2 parents ae5d163 + f1f3368 commit bce9dd0

8 files changed

Lines changed: 325 additions & 4 deletions

File tree

‎docs/asciidoc/server-sent-event.adoc‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events[Server-Sent
77
----
88
{
99
sse("/sse", sse -> { // <1>
10-
sse.send("Welcome"); // <2>
10+
sse.send("Welcome jooby"); // <2>
1111
});
1212
}
1313
----
@@ -17,14 +17,62 @@ https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events[Server-Sent
1717
----
1818
{
1919
sse("/sse") { // <1>
20-
sse.send("Welcome") // <2>
20+
sse.send("Welcome jooby") // <2>
2121
}
2222
}
2323
----
2424

2525
<1> Connection established.
2626
<2> Send a message to the client.
2727

28+
==== MVC Controllers
29+
30+
Use `@SSE` to register an SSE route from a controller:
31+
32+
.Java
33+
[source,java,role="primary"]
34+
----
35+
import io.jooby.ServerSentEmitter;
36+
import io.jooby.annotation.SSE;
37+
38+
public class Events {
39+
@SSE("/sse")
40+
public void sse(ServerSentEmitter emitter) {
41+
emitter.send("Welcome");
42+
emitter.onClose(() -> {
43+
// Clean up resources.
44+
});
45+
emitter.close();
46+
}
47+
}
48+
----
49+
50+
.Kotlin
51+
[source,kotlin,role="secondary"]
52+
----
53+
import io.jooby.ServerSentEmitter
54+
import io.jooby.annotation.SSE
55+
56+
class Events {
57+
@SSE("/sse")
58+
fun sse(emitter: ServerSentEmitter) {
59+
emitter.send("Welcome")
60+
emitter.onClose {
61+
// Clean up resources.
62+
}
63+
emitter.close()
64+
}
65+
}
66+
----
67+
68+
Install the controller with `mvc(new Events())`. The annotation processor generates an
69+
`app.sse("/sse", this::sse)` registration. In Kotlin, use `mvc(Events())` with KAPT enabled.
70+
The controller method must return `void` in Java or `Unit` in Kotlin. Kotlin `suspend`
71+
functions are not supported for `@SSE` methods.
72+
The emitter is injected directly; regular MVC parameters and class-level `@Path` prefixes
73+
are supported. Use `@SSE(path = "/sse")` (`@SSE(path = ["/sse"])` in Kotlin) or multiple
74+
path values as with other MVC routes.
75+
2876
==== Message Options
2977

3078
Additional message properties (like custom events, IDs, and retry timeouts) are available via the javadoc:ServerSentMessage[] class:
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.annotation;
7+
8+
import java.lang.annotation.ElementType;
9+
import java.lang.annotation.Retention;
10+
import java.lang.annotation.RetentionPolicy;
11+
import java.lang.annotation.Target;
12+
13+
/**
14+
* Server-sent event route for MVC controllers. The method must return {@code void} and may receive
15+
* a {@link io.jooby.ServerSentEmitter} alongside regular MVC parameters.
16+
*
17+
* <pre>{@code
18+
* @SSE("/sse")
19+
* public void sse(ServerSentEmitter emitter) {
20+
* emitter.send("Welcome");
21+
* emitter.onClose(() -> {});
22+
* emitter.close();
23+
* }
24+
* }</pre>
25+
*/
26+
@Retention(RetentionPolicy.RUNTIME)
27+
@Target(ElementType.METHOD)
28+
public @interface SSE {
29+
/**
30+
* @return Path patterns. Shortcut for {@link #path()}.
31+
*/
32+
String[] value() default {};
33+
34+
/**
35+
* @return Path patterns.
36+
*/
37+
String[] path() default {};
38+
}

‎modules/jooby-apt/src/main/java/io/jooby/internal/apt/HttpMethod.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public enum HttpMethod implements AnnotationSupport {
2323
TRACE("io.jooby.annotation"),
2424
DELETE,
2525
GET,
26+
SSE("io.jooby.annotation"),
2627
HEAD,
2728
OPTIONS,
2829
PATCH,

‎modules/jooby-apt/src/main/java/io/jooby/internal/apt/RestRoute.java‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ public RestRoute(RestRouter router, ExecutableElement method, TypeElement httpMe
3232
super(router, method);
3333
this.httpMethodAnnotation = httpMethodAnnotation;
3434
this.generatedName = method.getSimpleName().toString();
35+
if (isSse() && (!returnType.isVoid() || isSuspendFun())) {
36+
context.error("@SSE method must return void and must not be suspend: %s", method);
37+
}
38+
}
39+
40+
private boolean isSse() {
41+
return httpMethodAnnotation.getQualifiedName().contentEquals("io.jooby.annotation.SSE");
3542
}
3643

3744
public String getGeneratedName() {
@@ -196,7 +203,10 @@ public List<String> generateHandlerCall(boolean kt) {
196203
int controllerIndent = 2;
197204

198205
for (var parameter : getParameters(true)) {
199-
var generatedParameter = parameter.generateMapping(kt);
206+
var generatedParameter =
207+
isSse() && parameter.getType().is("io.jooby.ServerSentEmitter")
208+
? "emitter"
209+
: parameter.generateMapping(kt);
200210
if (parameter.isRequireBeanValidation()) {
201211
generatedParameter =
202212
CodeBlock.of(
@@ -208,7 +218,13 @@ public List<String> generateHandlerCall(boolean kt) {
208218
buffer.add(
209219
statement(indent(controllerIndent), var(kt), "c = this.factory.apply(ctx)", semicolon(kt)));
210220

211-
if (returnType.isVoid()) {
221+
if (isSse()) {
222+
buffer.add(
223+
statement(
224+
indent(controllerIndent),
225+
makeCall(kt, paramList.toString(), false, false),
226+
semicolon(kt)));
227+
} else if (returnType.isVoid()) {
212228
String statusCode =
213229
httpMethodAnnotation.getSimpleName().toString().equals("DELETE") ? "NO_CONTENT" : "OK";
214230
buffer.add(
@@ -274,6 +290,22 @@ private void methodCallHeader(
274290
String returnTypeGenerics,
275291
String returnTypeString,
276292
boolean throwsException) {
293+
if (isSse()) {
294+
if (kt) {
295+
if (throwsException) buffer.add(statement("@Throws(Exception::class)"));
296+
buffer.add(statement("fun ", methodName, "(emitter: io.jooby.ServerSentEmitter) {"));
297+
buffer.add(statement(indent(2), "val ctx = emitter.getContext()"));
298+
} else {
299+
buffer.add(
300+
statement(
301+
"public void ",
302+
methodName,
303+
"(io.jooby.ServerSentEmitter emitter)",
304+
throwsException ? " throws Exception {" : " {"));
305+
buffer.add(statement(indent(2), "var ctx = emitter.getContext();"));
306+
}
307+
return;
308+
}
277309
if (kt) {
278310
if (throwsException) buffer.add(statement("@Throws(Exception::class)"));
279311

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package tests.sse;
7+
8+
import io.jooby.Context;
9+
import io.jooby.ServerSentEmitter;
10+
import io.jooby.annotation.Path;
11+
import io.jooby.annotation.PathParam;
12+
import io.jooby.annotation.SSE;
13+
14+
@Path("/events")
15+
public class SseController {
16+
@SSE({"/sse", "/alias"})
17+
public void sse(ServerSentEmitter emitter) {
18+
emitter.send("Welcome");
19+
emitter.onClose(() -> {});
20+
emitter.close();
21+
}
22+
23+
@SSE(path = "/{name}")
24+
public void named(Context ctx, @PathParam String name, ServerSentEmitter emitter)
25+
throws Exception {
26+
emitter.send(name + ":" + ctx.getRequestPath());
27+
}
28+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package tests.sse;
7+
8+
import static org.assertj.core.api.Assertions.assertThat;
9+
import static org.mockito.ArgumentMatchers.any;
10+
import static org.mockito.Mockito.*;
11+
12+
import com.google.testing.compile.CompilationSubject;
13+
import com.google.testing.compile.Compiler;
14+
import com.google.testing.compile.JavaFileObjects;
15+
import io.jooby.Context;
16+
import io.jooby.ServerSentEmitter;
17+
import io.jooby.StatusCode;
18+
import io.jooby.apt.JoobyProcessor;
19+
import io.jooby.apt.ProcessorRunner;
20+
import io.jooby.value.Value;
21+
import io.jooby.value.ValueFactory;
22+
import org.junit.jupiter.api.Test;
23+
24+
public class SseTest {
25+
@Test
26+
public void shouldGenerateSseRoutesAndInvokeEmitter() throws Exception {
27+
new ProcessorRunner(new SseController())
28+
.withSourceCode(
29+
source -> {
30+
assertThat(source).contains("app.sse(\"/events/sse\", this::sse)");
31+
assertThat(source).contains("app.sse(\"/events/alias\", this::sse)");
32+
assertThat(source).contains("app.sse(\"/events/{name}\", this::named)");
33+
assertThat(source).contains("public void sse(io.jooby.ServerSentEmitter emitter)");
34+
assertThat(source).doesNotContain("return ctx.getResponseCode()");
35+
})
36+
.withRouter(
37+
app -> {
38+
assertThat(app.getRoutes()).hasSize(3);
39+
var route = app.getRoutes().getFirst();
40+
assertThat(route.getMethod()).isEqualTo("SSE");
41+
var ctx = mock(Context.class, RETURNS_SELF);
42+
var emitter = mock(ServerSentEmitter.class);
43+
when(emitter.getContext()).thenReturn(ctx);
44+
doAnswer(
45+
call -> {
46+
call.getArgument(0, ServerSentEmitter.Handler.class).handle(emitter);
47+
return ctx;
48+
})
49+
.when(ctx)
50+
.upgrade(any(ServerSentEmitter.Handler.class));
51+
52+
route.getHandler().apply(ctx);
53+
54+
verify(ctx).setResponseType("text/event-stream; charset=utf-8");
55+
verify(emitter).send("Welcome");
56+
verify(emitter).onClose(any());
57+
verify(emitter).close();
58+
verify(ctx).setResponseCode(StatusCode.OK);
59+
60+
when(ctx.path("name")).thenReturn(Value.value(new ValueFactory(), "name", "Ada"));
61+
when(ctx.getRequestPath()).thenReturn("/events/Ada");
62+
app.getRoutes().get(2).getHandler().apply(ctx);
63+
verify(emitter).send("Ada:/events/Ada");
64+
});
65+
}
66+
67+
@Test
68+
public void shouldGenerateKotlinEmitterHandler() throws Exception {
69+
new ProcessorRunner(new SseController())
70+
.withSourceCode(
71+
true,
72+
source -> {
73+
assertThat(source).contains("app.sse(\"/events/sse\", this::sse)");
74+
assertThat(source).contains("fun sse(emitter: io.jooby.ServerSentEmitter)");
75+
assertThat(source).contains("val ctx = emitter.getContext()");
76+
});
77+
}
78+
79+
@Test
80+
public void shouldRejectNonVoidSseMethods() {
81+
var compilation =
82+
Compiler.javac()
83+
.withProcessors(new JoobyProcessor())
84+
.compile(
85+
JavaFileObjects.forSourceString(
86+
"InvalidSse",
87+
"""
88+
import io.jooby.annotation.SSE;
89+
public class InvalidSse {
90+
@SSE("/sse")
91+
public String sse(io.jooby.ServerSentEmitter emitter) {
92+
return "invalid";
93+
}
94+
}
95+
"""));
96+
CompilationSubject.assertThat(compilation).failed();
97+
CompilationSubject.assertThat(compilation).hadErrorContaining("@SSE method must return void");
98+
}
99+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby
7+
8+
import io.jooby.internal.mvc.KotlinSseController_
9+
import io.jooby.value.Value
10+
import io.jooby.value.ValueFactory
11+
import org.junit.jupiter.api.Assertions.assertEquals
12+
import org.junit.jupiter.api.Test
13+
import org.mockito.ArgumentMatchers.any
14+
import org.mockito.Mockito.*
15+
16+
class KotlinSseTest {
17+
@Test
18+
fun shouldSupportKotlinSseControllers() {
19+
20+
val app = Jooby().apply { mvc(KotlinSseController_()) }
21+
22+
assertEquals(
23+
listOf("/kotlin-sse/events", "/kotlin-sse/alias", "/kotlin-sse/{name}"),
24+
app.routes.map { it.pattern })
25+
26+
val ctx = mock(Context::class.java, RETURNS_SELF)
27+
val emitter = mock(ServerSentEmitter::class.java)
28+
`when`(emitter.context).thenReturn(ctx)
29+
`when`(ctx.path("name")).thenReturn(Value.value(ValueFactory(), "name", "Ada"))
30+
`when`(ctx.requestPath).thenReturn("/kotlin-sse/Ada")
31+
doAnswer { call ->
32+
call.getArgument<ServerSentEmitter.Handler>(0).handle(emitter)
33+
ctx
34+
}.`when`(ctx).upgrade(any(ServerSentEmitter.Handler::class.java))
35+
36+
app.routes.forEach { route ->
37+
assertEquals("SSE", route.method)
38+
route.handler.apply(ctx)
39+
}
40+
41+
verify(emitter, times(2)).send("Welcome")
42+
verify(emitter).send("Ada:/kotlin-sse/Ada")
43+
verify(emitter, times(3)).close()
44+
verify(ctx, times(3)).setResponseType("text/event-stream; charset=utf-8")
45+
}
46+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Jooby https://jooby.io
3+
* Apache License Version 2.0 https://jooby.io/LICENSE.txt
4+
* Copyright 2014 Edgar Espina
5+
*/
6+
package io.jooby.internal.mvc
7+
8+
import io.jooby.Context
9+
import io.jooby.ServerSentEmitter
10+
import io.jooby.annotation.Path
11+
import io.jooby.annotation.PathParam
12+
import io.jooby.annotation.SSE
13+
14+
@Path("/kotlin-sse")
15+
class KotlinSseController {
16+
17+
@SSE("/events", "/alias")
18+
fun events(emitter: ServerSentEmitter) {
19+
emitter.send("Welcome")
20+
emitter.close()
21+
}
22+
23+
@SSE(path = ["/{name}"])
24+
fun named(ctx: Context, @PathParam name: String, emitter: ServerSentEmitter): Unit {
25+
emitter.send("$name:${ctx.requestPath}")
26+
emitter.close()
27+
}
28+
29+
}

0 commit comments

Comments
 (0)