Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions docs/asciidoc/server-sent-event.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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>
});
}
----
Expand All @@ -17,14 +17,62 @@ 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>
}
}
----

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

==== MVC Controllers

Use `@SSE` to register an SSE route from a controller:

.Java
[source,java,role="primary"]
----
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();
}
}
----

.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. 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")` (`@SSE(path = ["/sse"])` in Kotlin) 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:
Expand Down
38 changes: 38 additions & 0 deletions jooby/src/main/java/io/jooby/annotation/SSE.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <pre>{@code
* @SSE("/sse")
* public void sse(ServerSentEmitter emitter) {
* emitter.send("Welcome");
* emitter.onClose(() -> {});
* emitter.close();
* }
* }</pre>
*/
@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 {};
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public enum HttpMethod implements AnnotationSupport {
TRACE("io.jooby.annotation"),
DELETE,
GET,
SSE("io.jooby.annotation"),
HEAD,
OPTIONS,
PATCH,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -196,7 +203,10 @@ public List<String> 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(
Expand All @@ -208,7 +218,13 @@ public List<String> 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(
Expand Down Expand Up @@ -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)"));

Expand Down
28 changes: 28 additions & 0 deletions modules/jooby-apt/src/test/java/tests/sse/SseController.java
Original file line number Diff line number Diff line change
@@ -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());
}
}
99 changes: 99 additions & 0 deletions modules/jooby-apt/src/test/java/tests/sse/SseTest.java
Original file line number Diff line number Diff line change
@@ -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");
}
}
46 changes: 46 additions & 0 deletions tests/src/test/kotlin/io/jooby/KotlinSseTest.kt
Original file line number Diff line number Diff line change
@@ -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<ServerSentEmitter.Handler>(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")
}
}
29 changes: 29 additions & 0 deletions tests/src/test/kotlin/io/jooby/internal/mvc/KotlinSseController.kt
Original file line number Diff line number Diff line change
@@ -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()
}

}