diff --git a/examples/webserver/pom.xml b/examples/webserver/pom.xml index 74e48b0d..84c6b219 100644 --- a/examples/webserver/pom.xml +++ b/examples/webserver/pom.xml @@ -53,5 +53,6 @@ tracing tutorial websocket + sse diff --git a/examples/webserver/sse/README.md b/examples/webserver/sse/README.md new file mode 100644 index 00000000..1742e306 --- /dev/null +++ b/examples/webserver/sse/README.md @@ -0,0 +1,16 @@ +# Helidon SE SSE Example + +This example demonstrates how to use Server Sent Events (SSE) with both the WebServer and WebClient APIs. + +This project implements a couple of SSE endpoints that send either text or JSON messages. +The unit test uses the WebClient API to test the endpoints. + +## Build, run and test + +Build and start the server: +```shell +mvn package +java -jar target/helidon-examples-webserver-sse.jar +``` + +Then open http://localhost:8080 in your browser. diff --git a/examples/webserver/sse/pom.xml b/examples/webserver/sse/pom.xml new file mode 100644 index 00000000..a471da0f --- /dev/null +++ b/examples/webserver/sse/pom.xml @@ -0,0 +1,96 @@ + + + + 4.0.0 + + io.helidon.applications + helidon-se + 4.2.0-SNAPSHOT + + + io.helidon.examples.webserver + helidon-examples-webserver-sse + 1.0.0-SNAPSHOT + Helidon Examples WebServer SSE + + + io.helidon.examples.webserver.sse.Main + + + + + io.helidon.webserver + helidon-webserver + + + io.helidon.webserver + helidon-webserver-sse + + + io.helidon.webserver + helidon-webserver-static-content + + + jakarta.json + jakarta.json-api + + + io.helidon.http.media + helidon-http-media-jsonp + + + io.helidon.logging + helidon-logging-jul + + + io.helidon.webclient + helidon-webclient-sse + test + + + io.helidon.webserver.testing.junit5 + helidon-webserver-testing-junit5 + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-libs + + + + + + \ No newline at end of file diff --git a/examples/webserver/sse/src/main/java/io/helidon/examples/webserver/sse/Main.java b/examples/webserver/sse/src/main/java/io/helidon/examples/webserver/sse/Main.java new file mode 100644 index 00000000..9f90c429 --- /dev/null +++ b/examples/webserver/sse/src/main/java/io/helidon/examples/webserver/sse/Main.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.examples.webserver.sse; + +import io.helidon.logging.common.LogConfig; +import io.helidon.webserver.WebServer; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.staticcontent.StaticContentService; + +/** + * This application provides a simple service with a UI to exercise Server Sent Events (SSE). + */ +class Main { + + private Main() { + } + + /** + * Executes the example. + * + * @param args command line arguments, ignored + */ + public static void main(String[] args) { + // load logging configuration + LogConfig.configureRuntime(); + + WebServer server = WebServer.builder() + .routing(Main::routing) + .port(8080) + .build() + .start(); + + System.out.println("WEB server is up! http://localhost:" + server.port()); + } + + /** + * Updates the routing rules. + * + * @param rules routing rules + */ + static void routing(HttpRules rules) { + rules.register("/", StaticContentService.builder("WEB") + .welcomeFileName("index.html") + .build()) + .register("/api", new SseService()); + } +} diff --git a/examples/webserver/sse/src/main/java/io/helidon/examples/webserver/sse/SseService.java b/examples/webserver/sse/src/main/java/io/helidon/examples/webserver/sse/SseService.java new file mode 100644 index 00000000..eb21de21 --- /dev/null +++ b/examples/webserver/sse/src/main/java/io/helidon/examples/webserver/sse/SseService.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.examples.webserver.sse; + +import java.io.UncheckedIOException; +import java.lang.System.Logger.Level; +import java.time.Duration; + +import io.helidon.http.sse.SseEvent; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.http.HttpService; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; +import io.helidon.webserver.sse.SseSink; + +import jakarta.json.spi.JsonProvider; + +/** + * SSE service. + */ +class SseService implements HttpService { + + private static final System.Logger LOGGER = System.getLogger(SseService.class.getName()); + private static final JsonProvider JSON_PROVIDER = JsonProvider.provider(); + + @Override + public void routing(HttpRules httpRules) { + httpRules.get("/sse_text", this::sseText) + .get("/sse_json", this::sseJson); + } + + void sseText(ServerRequest req, ServerResponse res) throws InterruptedException { + int count = req.query().first("count").asInt().orElse(1); + int delay = req.query().first("delay").asInt().orElse(0); + try (SseSink sseSink = res.sink(SseSink.TYPE)) { + for (int i = 0; i < count; i++) { + try { + sseSink.emit(SseEvent.builder() + .comment("comment#" + i) + .name("my-event") + .data("data#" + i) + .build()); + } catch (UncheckedIOException e) { + LOGGER.log(Level.DEBUG, e.getMessage()); // connection close? + return; + } + + if (delay > 0) { + Thread.sleep(Duration.ofSeconds(delay)); + } + } + } + } + + void sseJson(ServerRequest req, ServerResponse res) throws InterruptedException { + int count = req.query().first("count").asInt().orElse(1); + int delay = req.query().first("delay").asInt().orElse(0); + try (SseSink sseSink = res.sink(SseSink.TYPE)) { + for (int i = 0; i < count; i++) { + try { + sseSink.emit(SseEvent.create(JSON_PROVIDER.createObjectBuilder() + .add("data", "data#" + i) + .build())); + } catch (UncheckedIOException e) { + LOGGER.log(Level.DEBUG, e.getMessage()); // connection close? + return; + } + + if (delay > 0) { + Thread.sleep(Duration.ofSeconds(delay)); + } + } + } + } +} diff --git a/examples/webserver/sse/src/main/java/io/helidon/examples/webserver/sse/package-info.java b/examples/webserver/sse/src/main/java/io/helidon/examples/webserver/sse/package-info.java new file mode 100644 index 00000000..687abe6d --- /dev/null +++ b/examples/webserver/sse/src/main/java/io/helidon/examples/webserver/sse/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Helidon Examples WebServer SSE. + */ +package io.helidon.examples.webserver.sse; diff --git a/examples/webserver/sse/src/main/resources/WEB/index.html b/examples/webserver/sse/src/main/resources/WEB/index.html new file mode 100644 index 00000000..633518a8 --- /dev/null +++ b/examples/webserver/sse/src/main/resources/WEB/index.html @@ -0,0 +1,107 @@ + + + + + + Helidon Examples WebServer SSE + + + + +

Text events

+
+
+ + +
+
+
+

JSON events

+
+
+ + +
+
+
+ + + diff --git a/examples/webserver/sse/src/main/resources/logging.properties b/examples/webserver/sse/src/main/resources/logging.properties new file mode 100644 index 00000000..452ea9d0 --- /dev/null +++ b/examples/webserver/sse/src/main/resources/logging.properties @@ -0,0 +1,20 @@ +# +# Copyright (c) 2024 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +handlers=io.helidon.logging.jul.HelidonConsoleHandler +java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS.%1$tL %5$s%6$s%n +# Global logging level. Can be overridden by specific loggers +.level=INFO diff --git a/examples/webserver/sse/src/test/java/io/helidon/examples/webserver/sse/SseServiceTest.java b/examples/webserver/sse/src/test/java/io/helidon/examples/webserver/sse/SseServiceTest.java new file mode 100644 index 00000000..e249ccb0 --- /dev/null +++ b/examples/webserver/sse/src/test/java/io/helidon/examples/webserver/sse/SseServiceTest.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.examples.webserver.sse; + +import java.util.ArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import jakarta.json.JsonObject; + +import io.helidon.http.sse.SseEvent; +import io.helidon.webclient.http1.Http1Client; +import io.helidon.webclient.sse.SseSource; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpRoute; + +import org.junit.jupiter.api.Test; + +import static io.helidon.http.HeaderValues.ACCEPT_EVENT_STREAM; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +@ServerTest +class SseServiceTest { + + private final Http1Client client; + + SseServiceTest(Http1Client client) { + this.client = client; + } + + @SetUpRoute + static void routing(HttpRules rules) { + rules.register(new SseService()); + } + + @Test + void testSseText() throws InterruptedException { + try (var response = client.get("/sse_text") + .queryParam("count", "3") + .header(ACCEPT_EVENT_STREAM).request()) { + var latch = new CountDownLatch(3); + var events = new ArrayList(); + response.source(SseSource.TYPE, event -> { + events.add(event); + latch.countDown(); + }); + assertThat(latch.await(5, TimeUnit.SECONDS), is(true)); + assertThat(events.size(), is(3)); + for (int i = 0; i < 3; i++) { + var event = events.get(i); + assertThat(event.comment().orElse(null), is("comment#" + i)); + assertThat(event.name().orElse(null), is("my-event")); + assertThat(event.data(String.class), is("data#" + i)); + } + } + } + + @Test + void testSseJson() throws InterruptedException { + try (var response = client.get("/sse_json") + .queryParam("count", "3") + .header(ACCEPT_EVENT_STREAM).request()) { + var latch = new CountDownLatch(3); + var events = new ArrayList(); + response.source(SseSource.TYPE, event -> { + events.add(event.data(JsonObject.class)); + latch.countDown(); + }); + assertThat(latch.await(5, TimeUnit.SECONDS), is(true)); + assertThat(events.size(), is(3)); + for (int i = 0; i < 3; i++) { + var event = events.get(i); + assertThat(event, is(notNullValue())); + assertThat(event.getString("data", null), is("data#" + i)); + } + } + } +}