From f1b5e4185f507c96e43cbc0980acd534ef6b81f7 Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Thu, 15 Dec 2022 17:14:25 +0100 Subject: [PATCH 1/3] WebSocket client and testing update --- bom/pom.xml | 7 +- docs-internal/nima-testing.md | 46 +++ .../nima/protocols/ProtocolsMain.java | 6 +- .../microprofile/tyrus/TyrusConnection.java | 12 +- .../tyrus/EchoEndpointProgTest.java | 4 +- .../microprofile/tyrus/EchoEndpointTest.java | 4 +- nima/testing/junit5/pom.xml | 5 +- .../junit5/webserver/DirectPeerInfo.java | 13 +- .../junit5/webserver/DirectSocket.java | 19 +- .../HelidonRoutingJunitExtension.java | 189 ++++++----- .../HelidonServerJunitExtension.java | 288 ++++++++--------- .../webserver/Http1DirectJunitExtension.java | 129 ++++++++ .../webserver/Http1ServerJunitExtension.java | 152 +++++++++ .../testing/junit5/webserver/Junit5Util.java | 85 +++++ .../nima/testing/junit5/webserver/Socket.java | 5 +- .../webserver/spi/DirectJunitExtension.java | 83 +++++ .../webserver/spi/HelidonJunitExtension.java | 65 ++++ .../webserver/spi/ServerJunitExtension.java | 122 ++++++++ .../junit5/webserver/spi/package-info.java | 23 ++ .../webserver/src/main/java/module-info.java | 12 +- .../junit5/webserver/TestRoutingTest.java | 6 +- .../junit5/webserver/TestServerTest.java | 50 ++- nima/testing/junit5/websocket/pom.xml | 58 ++++ .../junit5/websocket/DirectWsClient.java | 76 +++++ .../junit5/websocket/DirectWsConnection.java | 171 ++++++++++ .../junit5/websocket/WsDirectExtension.java | 116 +++++++ .../junit5/websocket/WsServerExtension.java | 85 +++++ .../junit5/websocket/package-info.java} | 23 +- .../websocket/src/main/java/module-info.java | 35 +++ .../junit5/websocket/WsDirectTestingTest.java | 35 +++ .../WsSocketAbstractTestingTest.java | 165 ++++++++++ .../websocket/WsSocketServerTestingTest.java | 36 +++ .../src/test/resources/logging.properties | 20 ++ .../webserver/WsConversationService.java | 6 +- .../webserver/WebSocketOriginTest.java | 10 +- .../websocket/webserver/WebSocketTest.java | 14 +- .../webserver/WsConversationTest.java | 7 +- .../webclient/http1/ClientRequestImpl.java | 63 +--- .../nima/webclient/http1/Http1Client.java | 3 +- .../webclient/http1/Http1StatusParser.java | 102 ++++++ .../io/helidon/nima/webserver/LoomServer.java | 14 +- .../io/helidon/nima/webserver/WebServer.java | 9 +- .../http1/spi/Http1UpgradeProvider.java | 5 +- .../websocket/client/etc/spotbugs/exclude.xml | 40 +++ nima/websocket/client/pom.xml | 83 +++++ .../websocket/client/ClientWsConnection.java | 280 +++++++++++++++++ .../nima/websocket/client/WsClient.java | 139 +++++++++ .../websocket/client/WsClientException.java | 41 +++ .../nima/websocket/client/WsClientImpl.java | 295 ++++++++++++++++++ .../nima/websocket/client/package-info.java | 20 ++ .../client/src/main/java/module-info.java | 35 +++ nima/websocket/pom.xml | 5 +- .../nima/websocket/webserver/ClientFrame.java | 102 ------ .../nima/websocket/webserver/ServerFrame.java | 108 ------- .../websocket/webserver/WsConnection.java | 154 ++++----- .../nima/websocket/webserver/WsOpCode.java | 48 --- .../{WebSocket.java => WsRoute.java} | 31 +- .../{WebSocketRouting.java => WsRouting.java} | 45 +-- .../webserver/WsUpgradeProvider.java | 12 +- nima/websocket/websocket/pom.xml | 6 +- .../nima/websocket/AbstractWsFrame.java | 129 ++++++++ .../helidon/nima/websocket/ClientWsFrame.java | 236 ++++++++++++++ .../helidon/nima/websocket/ServerWsFrame.java | 126 ++++++++ .../{CloseCodes.java => WsCloseCodes.java} | 6 +- .../nima/websocket/WsCloseException.java | 44 +++ .../io/helidon/nima/websocket/WsFrame.java | 78 +++++ .../io/helidon/nima/websocket/WsOpCode.java | 86 +++++ .../io/helidon/nima/websocket/WsSession.java | 6 +- .../websocket/src/main/java/module-info.java | 2 + .../nativeimage/nima1/Nima1Main.java | 7 +- 70 files changed, 3746 insertions(+), 796 deletions(-) create mode 100644 docs-internal/nima-testing.md create mode 100644 nima/testing/junit5/webserver/src/main/java/io/helidon/nima/testing/junit5/webserver/Http1DirectJunitExtension.java create mode 100644 nima/testing/junit5/webserver/src/main/java/io/helidon/nima/testing/junit5/webserver/Http1ServerJunitExtension.java create mode 100644 nima/testing/junit5/webserver/src/main/java/io/helidon/nima/testing/junit5/webserver/Junit5Util.java create mode 100644 nima/testing/junit5/webserver/src/main/java/io/helidon/nima/testing/junit5/webserver/spi/DirectJunitExtension.java create mode 100644 nima/testing/junit5/webserver/src/main/java/io/helidon/nima/testing/junit5/webserver/spi/HelidonJunitExtension.java create mode 100644 nima/testing/junit5/webserver/src/main/java/io/helidon/nima/testing/junit5/webserver/spi/ServerJunitExtension.java create mode 100644 nima/testing/junit5/webserver/src/main/java/io/helidon/nima/testing/junit5/webserver/spi/package-info.java create mode 100644 nima/testing/junit5/websocket/pom.xml create mode 100644 nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsClient.java create mode 100644 nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsConnection.java create mode 100644 nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsDirectExtension.java create mode 100644 nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsServerExtension.java rename nima/{websocket/webserver/src/main/java/io/helidon/nima/websocket/webserver/Frame.java => testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/package-info.java} (64%) create mode 100644 nima/testing/junit5/websocket/src/main/java/module-info.java create mode 100644 nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsDirectTestingTest.java create mode 100644 nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketAbstractTestingTest.java create mode 100644 nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketServerTestingTest.java create mode 100644 nima/testing/junit5/websocket/src/test/resources/logging.properties create mode 100644 nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1StatusParser.java create mode 100644 nima/websocket/client/etc/spotbugs/exclude.xml create mode 100644 nima/websocket/client/pom.xml create mode 100644 nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/ClientWsConnection.java create mode 100644 nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClient.java create mode 100644 nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientException.java create mode 100644 nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientImpl.java create mode 100644 nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/package-info.java create mode 100644 nima/websocket/client/src/main/java/module-info.java delete mode 100644 nima/websocket/webserver/src/main/java/io/helidon/nima/websocket/webserver/ClientFrame.java delete mode 100644 nima/websocket/webserver/src/main/java/io/helidon/nima/websocket/webserver/ServerFrame.java delete mode 100644 nima/websocket/webserver/src/main/java/io/helidon/nima/websocket/webserver/WsOpCode.java rename nima/websocket/webserver/src/main/java/io/helidon/nima/websocket/webserver/{WebSocket.java => WsRoute.java} (65%) rename nima/websocket/webserver/src/main/java/io/helidon/nima/websocket/webserver/{WebSocketRouting.java => WsRouting.java} (68%) create mode 100644 nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/AbstractWsFrame.java create mode 100644 nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ClientWsFrame.java create mode 100644 nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ServerWsFrame.java rename nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/{CloseCodes.java => WsCloseCodes.java} (96%) create mode 100644 nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsCloseException.java create mode 100644 nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsFrame.java create mode 100644 nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsOpCode.java diff --git a/bom/pom.xml b/bom/pom.xml index c4dac5986f0..0d19c246a03 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -1,7 +1,7 @@ + + + 4.0.0 + + + io.helidon.nima.testing.junit5 + helidon-nima-testing-junit5-project + 4.0.0-SNAPSHOT + ../pom.xml + + + helidon-nima-testing-junit5-websocket + Helidon Níma Testing JUnit5 WebSocket + + + + io.helidon.nima.testing.junit5 + helidon-nima-testing-junit5-webserver + + + io.helidon.nima.websocket + helidon-nima-websocket-client + + + io.helidon.nima.websocket + helidon-nima-websocket-webserver + + + org.junit.jupiter + junit-jupiter-api + + + org.hamcrest + hamcrest-all + provided + + + diff --git a/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsClient.java b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsClient.java new file mode 100644 index 00000000000..d0ead51cf99 --- /dev/null +++ b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsClient.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2023 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.nima.testing.junit5.websocket; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.List; + +import io.helidon.common.http.Http; +import io.helidon.common.http.HttpPrologue; +import io.helidon.nima.websocket.WsListener; +import io.helidon.nima.websocket.client.WsClient; +import io.helidon.nima.websocket.webserver.WsRoute; +import io.helidon.nima.websocket.webserver.WsRouting; + +/** + * A client for WebSocket, that directly invokes routing (and bypasses network). + */ +public class DirectWsClient implements WsClient { + private final List connections = new ArrayList<>(); + private final WsRouting routing; + + private DirectWsClient(WsRouting routing) { + this.routing = routing; + + routing.beforeStart(); + } + + /** + * Create a new client based on the provided routing. + * + * @param routing used to discover route to handle a new connection + * @return a new instance for the provided routing + */ + public static DirectWsClient create(WsRouting routing) { + return new DirectWsClient(routing); + } + + @Override + public void connect(URI uri, WsListener clientListener) { + HttpPrologue prologue = HttpPrologue.create("ws", "ws", "13", Http.Method.GET, uri.getRawPath(), false); + WsRoute route = routing.findRoute(prologue); + DirectWsConnection directWsConnection = DirectWsConnection.create(prologue, clientListener, route); + directWsConnection.start(); + connections.add(directWsConnection); + } + + @Override + public void connect(String path, WsListener listener) { + try { + connect(new URI("ws", null, "helidon-unit", 65000, path, null, null), listener); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Cannot create URI from provided path", e); + } + } + + void close() { + connections.forEach(DirectWsConnection::stop); + this.routing.afterStop(); + } +} diff --git a/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsConnection.java b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsConnection.java new file mode 100644 index 00000000000..2d1dc189783 --- /dev/null +++ b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/DirectWsConnection.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2023 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.nima.testing.junit5.websocket; + +import java.net.InetSocketAddress; +import java.util.Optional; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.helidon.common.buffers.BufferData; +import io.helidon.common.buffers.DataReader; +import io.helidon.common.buffers.DataWriter; +import io.helidon.common.context.Context; +import io.helidon.common.http.HttpPrologue; +import io.helidon.common.http.WritableHeaders; +import io.helidon.common.socket.HelidonSocket; +import io.helidon.nima.http.encoding.ContentEncodingContext; +import io.helidon.nima.http.media.MediaContext; +import io.helidon.nima.testing.junit5.webserver.DirectPeerInfo; +import io.helidon.nima.testing.junit5.webserver.DirectSocket; +import io.helidon.nima.webserver.ConnectionContext; +import io.helidon.nima.webserver.Router; +import io.helidon.nima.webserver.ServerContext; +import io.helidon.nima.webserver.http.DirectHandlers; +import io.helidon.nima.websocket.WsListener; +import io.helidon.nima.websocket.client.ClientWsConnection; +import io.helidon.nima.websocket.webserver.WsConnection; +import io.helidon.nima.websocket.webserver.WsRoute; + +class DirectWsConnection { + private final AtomicBoolean serverStarted = new AtomicBoolean(); + + private final HttpPrologue prologue; + private final WsListener clientListener; + private final WsRoute serverRoute; + private final DataReader clientReader; + private final DataWriter clientWriter; + private final DataReader serverReader; + private final DataWriter serverWriter; + private final HelidonSocket socket; + private final ConnectionContext ctx; + private final ExecutorService executorService; + private volatile Future serverFuture; + private volatile Future clientFuture; + + DirectWsConnection(HttpPrologue prologue, WsListener clientListener, WsRoute serverRoute) { + this.prologue = prologue; + this.clientListener = clientListener; + this.serverRoute = serverRoute; + this.executorService = Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name("direct-test-ws", 1) + .factory()); + + ArrayBlockingQueue serverToClient = new ArrayBlockingQueue<>(1024); + ArrayBlockingQueue clientToServer = new ArrayBlockingQueue<>(1024); + this.clientReader = reader(serverToClient); + this.clientWriter = writer(clientToServer); + this.serverReader = reader(clientToServer); + this.serverWriter = writer(serverToClient); + DirectPeerInfo info = new DirectPeerInfo(InetSocketAddress.createUnresolved("localhost", 64000), + "localhost", + 64000, + Optional.empty(), + Optional.empty()); + this.socket = DirectSocket.create(info, info, false); + this.ctx = ConnectionContext.create( + ServerContext.create(Context.create(), + MediaContext.create(), + ContentEncodingContext.create()), + executorService, + serverWriter, + serverReader, + Router.builder().build(), + "unit-server", + "unit-channel", + DirectHandlers.builder().build(), + socket, + -1); + } + + static DirectWsConnection create(HttpPrologue prologue, WsListener clientListener, WsRoute serverRoute) { + return new DirectWsConnection(prologue, clientListener, serverRoute); + } + + void start() { + if (serverStarted.compareAndSet(false, true)) { + WsConnection serverConnection = WsConnection.create(ctx, prologue, WritableHeaders.create(), "", serverRoute); + ClientWsConnection clientConnection = ClientWsConnection.create(clientListener, + socket, + clientReader, + clientWriter, + Optional.empty()); + serverFuture = executorService.submit(serverConnection::handle); + clientFuture = executorService.submit(clientConnection); + } + } + + void stop() { + Future s = serverFuture; + Future c = clientFuture; + if (s != null) { + s.cancel(true); + } + if (c != null) { + c.cancel(true); + } + } + + private static DataReader reader(ArrayBlockingQueue queue) { + return new DataReader(() -> { + byte[] data; + try { + data = queue.take(); + } catch (InterruptedException e) { + throw new IllegalArgumentException("Thread interrupted", e); + } + if (data.length == 0) { + return null; + } + return data; + }); + } + + private DataWriter writer(ArrayBlockingQueue queue) { + return new DataWriter() { + @Override + public void write(BufferData... buffers) { + writeNow(buffers); + } + + @Override + public void write(BufferData buffer) { + writeNow(buffer); + } + + @Override + public void writeNow(BufferData... buffers) { + for (BufferData buffer : buffers) { + writeNow(buffer); + } + } + + @Override + public void writeNow(BufferData buffer) { + byte[] bytes = new byte[buffer.available()]; + buffer.read(bytes); + try { + queue.put(bytes); + } catch (InterruptedException e) { + throw new IllegalStateException("Thread interrupted", e); + } + } + }; + } +} diff --git a/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsDirectExtension.java b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsDirectExtension.java new file mode 100644 index 00000000000..eff8f3691a5 --- /dev/null +++ b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsDirectExtension.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2023 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.nima.testing.junit5.websocket; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import io.helidon.nima.testing.junit5.webserver.Junit5Util; +import io.helidon.nima.testing.junit5.webserver.spi.DirectJunitExtension; +import io.helidon.nima.webserver.WebServer; +import io.helidon.nima.websocket.client.WsClient; +import io.helidon.nima.websocket.webserver.WsRouting; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; + +/** + * A {@link java.util.ServiceLoader} provider implementation that adds support for injection of WebSocket related + * artifacts, such as {@link io.helidon.nima.testing.junit5.websocket.DirectWsClient} in Helidon Níma unit tests. + */ +public class WsDirectExtension implements DirectJunitExtension { + private final Map clients = new HashMap<>(); + + @Override + public void afterAll(ExtensionContext context) { + clients.values().forEach(DirectWsClient::close); + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + + Class paramType = parameterContext.getParameter().getType(); + if (DirectWsClient.class.equals(paramType) || WsClient.class.equals(paramType)) { + return true; + } + return false; + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Class parameterType) { + Class paramType = parameterContext.getParameter().getType(); + if (DirectWsClient.class.equals(paramType) || WsClient.class.equals(paramType)) { + String socketName = Junit5Util.socketName(parameterContext.getParameter()); + + DirectWsClient directClient = clients.get(socketName); + if (directClient == null) { + if (WebServer.DEFAULT_SOCKET_NAME.equals(socketName)) { + throw new IllegalStateException("There is no WebSocket routing specified. Please add static method " + + "annotated with @SetUpRoute that accepts WebSocketRouting.Builder"); + } else { + throw new IllegalStateException("There is no default routing specified for socket \"" + socketName + "\"." + + "annotated with @SetUpRoute that accepts WebSocketRouting.Builder" + + " and add @Socket(\"" + socketName + "\") " + + "annotation to the parameter"); + } + } + return directClient; + } + + throw new ParameterResolutionException("Parameter not supported by this extension: " + parameterType); + } + + @Override + public Optional> setUpRouteParamHandler(Class type) { + if (WsRouting.Builder.class.equals(type)) { + return Optional.of(new RoutingParamHandler(clients)); + } + return Optional.empty(); + } + + private static final class RoutingParamHandler implements DirectJunitExtension.ParamHandler { + private final Map clients; + + private RoutingParamHandler(Map clients) { + this.clients = clients; + } + + @Override + public WsRouting.Builder get(String socketName) { + return WsRouting.builder(); + } + + @Override + public void handle(Method method, String socketName, WsRouting.Builder value) { + if (clients.putIfAbsent(socketName, DirectWsClient.create(value.build())) != null) { + throw new IllegalStateException("Method " + + method + + " defines WebSocket routing for socket \"" + + socketName + + "\"" + + " that is already defined for class \"" + + method.getDeclaringClass().getName() + + "\"."); + } + } + } + +} diff --git a/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsServerExtension.java b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsServerExtension.java new file mode 100644 index 00000000000..48714c49a92 --- /dev/null +++ b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/WsServerExtension.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023 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.nima.testing.junit5.websocket; + +import java.util.Optional; + +import io.helidon.nima.testing.junit5.webserver.Junit5Util; +import io.helidon.nima.testing.junit5.webserver.spi.ServerJunitExtension; +import io.helidon.nima.webserver.ListenerConfiguration; +import io.helidon.nima.webserver.Router; +import io.helidon.nima.webserver.WebServer; +import io.helidon.nima.websocket.client.WsClient; +import io.helidon.nima.websocket.webserver.WsRouting; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; + +/** + * A {@link java.util.ServiceLoader} provider implementation that adds support for injection of WebSocket related + * artifacts, such as {@link io.helidon.nima.websocket.client.WsClient} in Helidon Níma integration tests. + */ +public class WsServerExtension implements ServerJunitExtension { + @Override + public Optional> setUpRouteParamHandler(Class type) { + if (WsRouting.Builder.class.equals(type)) { + return Optional.of(new RoutingParamHandler()); + } + return Optional.empty(); + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + return WsClient.class.equals(parameterContext.getParameter().getType()); + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, + ExtensionContext extensionContext, + Class parameterType, + WebServer server) { + String socketName = Junit5Util.socketName(parameterContext.getParameter()); + + if (WsClient.class.equals(parameterType)) { + return WsClient.builder() + .baseUri("ws://localhost:" + server.port(socketName)) + .build(); + } + throw new ParameterResolutionException("WebSocket extension only supports WsClient parameter type"); + } + + private static final class RoutingParamHandler implements ParamHandler { + @Override + public WsRouting.Builder get(String socketName, + WebServer.Builder serverBuilder, + ListenerConfiguration.Builder listenerBuilder, + Router.RouterBuilder routerBuilder) { + return WsRouting.builder(); + } + + @Override + public void handle(String socketName, + WebServer.Builder serverBuilder, + ListenerConfiguration.Builder listenerBuilder, + Router.RouterBuilder routerBuilder, + WsRouting.Builder value) { + routerBuilder.addRouting(value.build()); + } + } +} diff --git a/nima/websocket/webserver/src/main/java/io/helidon/nima/websocket/webserver/Frame.java b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/package-info.java similarity index 64% rename from nima/websocket/webserver/src/main/java/io/helidon/nima/websocket/webserver/Frame.java rename to nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/package-info.java index ac79b692247..a5c7d57ffa1 100644 --- a/nima/websocket/webserver/src/main/java/io/helidon/nima/websocket/webserver/Frame.java +++ b/nima/testing/junit5/websocket/src/main/java/io/helidon/nima/testing/junit5/websocket/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2023 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. @@ -14,20 +14,7 @@ * limitations under the License. */ -package io.helidon.nima.websocket.webserver; - -import io.helidon.common.buffers.BufferData; - -interface Frame { - boolean fin(); - - WsOpCode opCode(); - - boolean masked(); - - long payloadLength(); - - int[] maskingKey(); - - BufferData payloadData(); -} +/** + * Unit and integration testing support for Níma WebSocket and JUnit 5. + */ +package io.helidon.nima.testing.junit5.websocket; diff --git a/nima/testing/junit5/websocket/src/main/java/module-info.java b/nima/testing/junit5/websocket/src/main/java/module-info.java new file mode 100644 index 00000000000..9bb326371e1 --- /dev/null +++ b/nima/testing/junit5/websocket/src/main/java/module-info.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 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. + */ + +import io.helidon.nima.testing.junit5.websocket.WsDirectExtension; +import io.helidon.nima.testing.junit5.websocket.WsServerExtension; + +/** + * Unit and integration testing support for Níma WebSocket and JUnit 5. + */ +module io.helidon.nima.testing.junit5.websocket { + requires transitive io.helidon.nima.testing.junit5.webserver; + requires io.helidon.nima.websocket.webserver; + requires io.helidon.nima.websocket.client; + + exports io.helidon.nima.testing.junit5.websocket; + + provides io.helidon.nima.testing.junit5.webserver.spi.ServerJunitExtension + with WsServerExtension; + + provides io.helidon.nima.testing.junit5.webserver.spi.DirectJunitExtension + with WsDirectExtension; +} \ No newline at end of file diff --git a/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsDirectTestingTest.java b/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsDirectTestingTest.java new file mode 100644 index 00000000000..86c3809d37a --- /dev/null +++ b/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsDirectTestingTest.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 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.nima.testing.junit5.websocket; + +import io.helidon.nima.testing.junit5.webserver.DirectClient; +import io.helidon.nima.testing.junit5.webserver.RoutingTest; +import io.helidon.nima.webclient.http1.Http1Client; +import io.helidon.nima.websocket.client.WsClient; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; + +@RoutingTest +class WsDirectTestingTest extends WsSocketAbstractTestingTest { + WsDirectTestingTest(Http1Client httpClient, WsClient wsClient) { + super(httpClient, wsClient); + + assertThat(httpClient, instanceOf(DirectClient.class)); + assertThat(wsClient, instanceOf(DirectWsClient.class)); + } +} diff --git a/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketAbstractTestingTest.java b/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketAbstractTestingTest.java new file mode 100644 index 00000000000..958c9081ac7 --- /dev/null +++ b/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketAbstractTestingTest.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2023 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.nima.testing.junit5.websocket; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import io.helidon.nima.testing.junit5.webserver.SetUpRoute; +import io.helidon.nima.testing.junit5.webserver.Socket; +import io.helidon.nima.webclient.http1.Http1Client; +import io.helidon.nima.webserver.http.HttpRouting; +import io.helidon.nima.websocket.WsCloseCodes; +import io.helidon.nima.websocket.WsListener; +import io.helidon.nima.websocket.WsSession; +import io.helidon.nima.websocket.client.WsClient; +import io.helidon.nima.websocket.webserver.WsRouting; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.fail; + +abstract class WsSocketAbstractTestingTest { + private static final ServerSideListener WS_LISTENER = new ServerSideListener(); + + private final Http1Client httpClient; + private final WsClient wsClient; + + protected WsSocketAbstractTestingTest(Http1Client httpClient, WsClient wsClient) { + this.httpClient = httpClient; + this.wsClient = wsClient; + } + + @SetUpRoute + static void routing(HttpRouting.Builder http, WsRouting.Builder ws) { + http.get("/test", (req, res) -> res.send("http")); + ws.endpoint("/testWs", WS_LISTENER); + } + + @SetUpRoute("custom") + static void customRouting(WsRouting.Builder ws) { + ws.endpoint("/customWs", WS_LISTENER); + } + + @Test + void testHttpEndpoint() { + String message = httpClient.get("/test").request(String.class); + assertThat(message, is("http")); + } + + @Test + void testWsEndpoint() { + WS_LISTENER.reset(); + + ClientSideListener clientListener = new ClientSideListener(); + wsClient.connect("/testWs", clientListener); + + assertThat("We should have received a response", clientListener.await()); + assertThat(clientListener.message, is("ws")); + + assertThat(WS_LISTENER.opened, is(true)); + assertThat(WS_LISTENER.closed, is(true)); + assertThat(WS_LISTENER.message, is("hello")); + } + + @Test + void testWsEndpointCustomSocket(@Socket("custom") WsClient wsClient) { + WS_LISTENER.reset(); + + ClientSideListener clientListener = new ClientSideListener(); + wsClient.connect("/customWs", clientListener); + + assertThat("We should have received a response", clientListener.await()); + assertThat(clientListener.message, is("ws")); + + assertThat(WS_LISTENER.opened, is(true)); + assertThat(WS_LISTENER.closed, is(true)); + assertThat(WS_LISTENER.message, is("hello")); + } + + private static class ClientSideListener implements WsListener { + private final CountDownLatch cdl = new CountDownLatch(1); + private String message; + private volatile Throwable throwable; + + @Override + public void onOpen(WsSession session) { + session.send("hello", true); + } + + @Override + public void receive(WsSession session, String text, boolean last) { + this.message = text; + session.close(WsCloseCodes.NORMAL_CLOSE, "End"); + } + + @Override + public void onClose(WsSession session, int status, String reason) { + cdl.countDown(); + } + + @Override + public void onError(WsSession session, Throwable t) { + this.throwable = t; + cdl.countDown(); + } + + boolean await() { + try { + boolean await = cdl.await(10, TimeUnit.SECONDS); + if (throwable != null) { + fail(throwable); + } + return await; + } catch (InterruptedException e) { + fail(e); + return false; + } + } + } + + private static class ServerSideListener implements WsListener { + boolean opened; + boolean closed; + String message; + + @Override + public void receive(WsSession session, String text, boolean last) { + message = text; + session.send("ws", true); + } + + @Override + public void onClose(WsSession session, int status, String reason) { + closed = true; + } + + @Override + public void onOpen(WsSession session) { + opened = true; + } + + void reset() { + opened = false; + closed = false; + message = null; + } + } +} + diff --git a/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketServerTestingTest.java b/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketServerTestingTest.java new file mode 100644 index 00000000000..57c3f4b4679 --- /dev/null +++ b/nima/testing/junit5/websocket/src/test/java/io/helidon/nima/testing/junit5/websocket/WsSocketServerTestingTest.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 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.nima.testing.junit5.websocket; + +import io.helidon.nima.testing.junit5.webserver.DirectClient; +import io.helidon.nima.testing.junit5.webserver.ServerTest; +import io.helidon.nima.webclient.http1.Http1Client; +import io.helidon.nima.websocket.client.WsClient; + +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; + +@ServerTest +class WsSocketServerTestingTest extends WsSocketAbstractTestingTest { + WsSocketServerTestingTest(Http1Client httpClient, WsClient wsClient) { + super(httpClient, wsClient); + + assertThat(httpClient, not(instanceOf(DirectClient.class))); + assertThat(wsClient, not(instanceOf(DirectWsClient.class))); + } +} diff --git a/nima/testing/junit5/websocket/src/test/resources/logging.properties b/nima/testing/junit5/websocket/src/test/resources/logging.properties new file mode 100644 index 00000000000..49ea4eb1733 --- /dev/null +++ b/nima/testing/junit5/websocket/src/test/resources/logging.properties @@ -0,0 +1,20 @@ +# +# Copyright (c) 2023 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 %4$s %3$s !thread!: %5$s%6$s%n + +.level=WARNING diff --git a/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationService.java b/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationService.java index 81f5d480f95..59cdcf19bc8 100644 --- a/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationService.java +++ b/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -113,10 +113,10 @@ private void waitMessage(WsAction action, WsSession session) { WsAction r = received.poll(WAIT_SECONDS, TimeUnit.SECONDS); assert r != null; if (!r.equals(action)) { - session.abort(); + session.terminate(); } } catch (Exception e) { - session.abort(); + session.terminate(); } } } diff --git a/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketOriginTest.java b/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketOriginTest.java index f29bea9c66c..5882761c62d 100644 --- a/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketOriginTest.java +++ b/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketOriginTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -34,10 +34,10 @@ import io.helidon.nima.webserver.Router; import io.helidon.nima.webserver.WebServer; import io.helidon.nima.webserver.http1.Http1ConnectionProvider; -import io.helidon.nima.websocket.CloseCodes; +import io.helidon.nima.websocket.WsCloseCodes; import io.helidon.nima.websocket.WsListener; import io.helidon.nima.websocket.WsSession; -import io.helidon.nima.websocket.webserver.WebSocketRouting; +import io.helidon.nima.websocket.webserver.WsRouting; import io.helidon.nima.websocket.webserver.WsUpgradeProvider; import org.junit.jupiter.api.Test; @@ -69,7 +69,7 @@ static void updateServer(WebServer.Builder builder) { @SetUpRoute static void routing(Router.RouterBuilder router) { - router.addRouting(WebSocketRouting.builder() + router.addRouting(WsRouting.builder() .endpoint("/single", WebSocketOriginTest::single)); } @@ -93,7 +93,7 @@ public CompletionStage onText(java.net.http.WebSocket webSocket, }) .get(5, TimeUnit.SECONDS); webSocket.sendText("lower", true); - webSocket.sendClose(CloseCodes.NORMAL_CLOSE, "finished"); + webSocket.sendClose(WsCloseCodes.NORMAL_CLOSE, "finished"); Boolean wasLast = wsCompleted.get(5, TimeUnit.SECONDS); assertThat(wasLast, is(true)); assertThat(received, hasItem("LOWER")); diff --git a/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketTest.java b/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketTest.java index d9fe5617fff..ea7b7f5a6be 100644 --- a/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketTest.java +++ b/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WebSocketTest.java @@ -31,8 +31,8 @@ import io.helidon.nima.testing.junit5.webserver.SetUpRoute; import io.helidon.nima.webserver.Router; import io.helidon.nima.webserver.WebServer; -import io.helidon.nima.websocket.CloseCodes; -import io.helidon.nima.websocket.webserver.WebSocketRouting; +import io.helidon.nima.websocket.WsCloseCodes; +import io.helidon.nima.websocket.webserver.WsRouting; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -60,7 +60,7 @@ class WebSocketTest { @SetUpRoute static void router(Router.RouterBuilder router) { service = new EchoService(); - router.addRouting(WebSocketRouting.builder().endpoint("/echo", service)); + router.addRouting(WsRouting.builder().endpoint("/echo", service)); } @BeforeEach @@ -72,7 +72,7 @@ void resetClosed() { void checkClosed() { EchoService.CloseInfo closeInfo = service.closeInfo(); assertThat(closeInfo, notNullValue()); - assertThat(closeInfo.status(), is(CloseCodes.NORMAL_CLOSE)); + assertThat(closeInfo.status(), is(WsCloseCodes.NORMAL_CLOSE)); assertThat(closeInfo.reason(), is("normal")); } @@ -89,7 +89,7 @@ void testOnce() throws Exception { ws.request(10); ws.sendText("Hello", true).get(5, TimeUnit.SECONDS); - ws.sendClose(CloseCodes.NORMAL_CLOSE, "normal").get(5, TimeUnit.SECONDS); + ws.sendClose(WsCloseCodes.NORMAL_CLOSE, "normal").get(5, TimeUnit.SECONDS); List results = listener.getResults(); assertThat(results, contains("Hello")); @@ -106,7 +106,7 @@ void testMulti() throws Exception { ws.sendText("First", true).get(5, TimeUnit.SECONDS); ws.sendText("Second", true).get(5, TimeUnit.SECONDS); - ws.sendClose(CloseCodes.NORMAL_CLOSE, "normal").get(5, TimeUnit.SECONDS); + ws.sendClose(WsCloseCodes.NORMAL_CLOSE, "normal").get(5, TimeUnit.SECONDS); assertThat(listener.getResults(), contains("First", "Second")); } @@ -122,7 +122,7 @@ void testFragmentedAndMulti() throws Exception { ws.sendText("First", false).get(5, TimeUnit.SECONDS); ws.sendText("Second", true).get(5, TimeUnit.SECONDS); ws.sendText("Third", true).get(5, TimeUnit.SECONDS); - ws.sendClose(CloseCodes.NORMAL_CLOSE, "normal").get(5, TimeUnit.SECONDS); + ws.sendClose(WsCloseCodes.NORMAL_CLOSE, "normal").get(5, TimeUnit.SECONDS); assertThat(listener.getResults(), contains("FirstSecond", "Third")); } diff --git a/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationTest.java b/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationTest.java index 779f706951a..ef866ec43dd 100644 --- a/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationTest.java +++ b/nima/tests/integration/websocket/server/src/test/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.helidon.nima.tests.integration.websocket.webserver; import java.net.URI; @@ -25,7 +26,7 @@ import io.helidon.nima.tests.integration.websocket.webserver.WsConversationClient.WsConversationListener; import io.helidon.nima.webserver.Router; import io.helidon.nima.webserver.WebServer; -import io.helidon.nima.websocket.webserver.WebSocketRouting; +import io.helidon.nima.websocket.webserver.WsRouting; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Test; @@ -50,7 +51,7 @@ class WsConversationTest { @SetUpRoute static void router(Router.RouterBuilder router) { service = new WsConversationService(); - router.addRouting(WebSocketRouting.builder().endpoint("/conversation", service)); + router.addRouting(WsRouting.builder().endpoint("/conversation", service)); } @Test diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java index 1b811a262ed..deee2187ba0 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.helidon.nima.webclient.http1; import java.io.ByteArrayOutputStream; @@ -273,7 +274,8 @@ private String resolvePathParams(String path) { } private Http1ClientResponse readResponse(ClientRequestHeaders usedHeaders, ClientConnection connection, DataReader reader) { - Http.Status responseStatus = readStatus(reader); + // todo configurable max status line length + Http.Status responseStatus = Http1StatusParser.readStatus(reader, 256); ClientResponseHeaders responseHeaders = readHeaders(reader); return new ClientResponseImpl(responseStatus, usedHeaders, responseHeaders, connection, reader); @@ -289,63 +291,6 @@ private ClientResponseHeaders readHeaders(DataReader reader) { return ClientResponseHeaders.create(writable); } - private Http.Status readStatus(DataReader reader) { - // todo configurable max status line length - int newLine = reader.findNewLine(256); - if (newLine == 4096) { - throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: \n" - + reader.readBuffer(newLine).debugDataHex()); - } - int slash = reader.findOrNewLine(Bytes.SLASH_BYTE, newLine); - if (slash == newLine) { - throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: \n" - + reader.readBuffer(newLine).debugDataHex()); - } - String protocol = reader.readAsciiString(slash); - if (!protocol.equals("HTTP")) { - throw new IllegalStateException("HTTP response did not contain correct status line. Protocol is not HTTP: \n" - + BufferData.create(protocol.getBytes(StandardCharsets.US_ASCII)) - .debugDataHex()); - } - reader.skip(1); // / - newLine -= slash; - newLine--; - int space = reader.findOrNewLine(Bytes.SPACE_BYTE, newLine); - if (space == newLine) { - throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: HTTP/\n" - + reader.readBuffer(newLine).debugDataHex()); - } - String protocolVersion = reader.readAsciiString(space); - reader.skip(1); // space - newLine -= space; - newLine--; - if (!protocolVersion.equals("1.1")) { - throw new IllegalStateException("HTTP response did not contain correct status line. Version is not 1.1: \n" - + BufferData.create(protocolVersion.getBytes(StandardCharsets.US_ASCII)) - .debugDataHex()); - } - // HTTP/1.1 200 OK - space = reader.findOrNewLine(Bytes.SPACE_BYTE, newLine); - if (space == newLine) { - throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: HTTP/1.1\n" - + reader.readBuffer(newLine).debugDataHex()); - } - String code = reader.readAsciiString(space); - reader.skip(1); // the new line - newLine -= space; - newLine--; - String phrase = reader.readAsciiString(newLine); // the rest of the line is reason phrase - reader.skip(2); // skip the last CRLF - - try { - return Http.Status.create(Integer.parseInt(code), phrase); - } catch (NumberFormatException e) { - throw new IllegalStateException("HTTP Response did not cntain HTTP status line. Line HTTP/1.1 \n" - + BufferData.create(code.getBytes(StandardCharsets.US_ASCII)) + "\n" - + BufferData.create(phrase.getBytes(StandardCharsets.US_ASCII))); - } - } - private boolean handleKeepAlive(WritableHeaders headers) { if (headers.contains(HeaderValues.CONNECTION_CLOSE)) { return false; diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1Client.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1Client.java index e9983dd0415..31df4163172 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1Client.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1Client.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -23,7 +23,6 @@ * HTTP/1.1 client. */ public interface Http1Client extends HttpClient { - /** * A new fluent API builder to customize instances. * diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1StatusParser.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1StatusParser.java new file mode 100644 index 00000000000..2f16de7baea --- /dev/null +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1StatusParser.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2023 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.nima.webclient.http1; + +import java.nio.charset.StandardCharsets; + +import io.helidon.common.buffers.BufferData; +import io.helidon.common.buffers.Bytes; +import io.helidon.common.buffers.DataReader; +import io.helidon.common.http.Http; + +/** + * Parser of HTTP/1.1 response status. + */ +public final class Http1StatusParser { + private Http1StatusParser() { + } + + /** + * Read the status line from HTTP/1.1 response. + * + * @param reader data reader to obtain bytes from + * @param maxLength maximal number of bytes that can be processed before end of line is reached + * @return parsed HTTP status + * @throws java.lang.IllegalStateException in case of unexpected data + * @throws io.helidon.common.buffers.DataReader.InsufficientDataAvailableException when not enough data can be obtained + * @throws io.helidon.common.buffers.DataReader.IncorrectNewLineException in case we are missing correct end of line + * (CRLF) + * @throws java.lang.RuntimeException additional exceptions may be thrown from + * the reader, depending on its + * implementation + */ + public static Http.Status readStatus(DataReader reader, int maxLength) { + int newLine = reader.findNewLine(maxLength); + if (newLine == maxLength) { + throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: \n" + + reader.readBuffer(newLine).debugDataHex()); + } + int slash = reader.findOrNewLine(Bytes.SLASH_BYTE, newLine); + if (slash == newLine) { + throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: \n" + + reader.readBuffer(newLine).debugDataHex()); + } + String protocol = reader.readAsciiString(slash); + if (!protocol.equals("HTTP")) { + throw new IllegalStateException("HTTP response did not contain correct status line. Protocol is not HTTP: \n" + + BufferData.create(protocol.getBytes(StandardCharsets.US_ASCII)) + .debugDataHex()); + } + reader.skip(1); // / + newLine -= slash; + newLine--; + int space = reader.findOrNewLine(Bytes.SPACE_BYTE, newLine); + if (space == newLine) { + throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: HTTP/\n" + + reader.readBuffer(newLine).debugDataHex()); + } + String protocolVersion = reader.readAsciiString(space); + reader.skip(1); // space + newLine -= space; + newLine--; + if (!protocolVersion.equals("1.1")) { + throw new IllegalStateException("HTTP response did not contain correct status line. Version is not 1.1: \n" + + BufferData.create(protocolVersion.getBytes(StandardCharsets.US_ASCII)) + .debugDataHex()); + } + // HTTP/1.1 200 OK + space = reader.findOrNewLine(Bytes.SPACE_BYTE, newLine); + if (space == newLine) { + throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line: HTTP/1.1\n" + + reader.readBuffer(newLine).debugDataHex()); + } + String code = reader.readAsciiString(space); + reader.skip(1); // the new line + newLine -= space; + newLine--; + String phrase = reader.readAsciiString(newLine); // the rest of the line is reason phrase + reader.skip(2); // skip the last CRLF + + try { + return Http.Status.create(Integer.parseInt(code), phrase); + } catch (NumberFormatException e) { + throw new IllegalStateException("HTTP Response did not contain HTTP status line. Line HTTP/1.1 \n" + + BufferData.create(code.getBytes(StandardCharsets.US_ASCII)) + "\n" + + BufferData.create(phrase.getBytes(StandardCharsets.US_ASCII))); + } + } +} diff --git a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/LoomServer.java b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/LoomServer.java index 69b67782432..65e3a02740a 100644 --- a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/LoomServer.java +++ b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/LoomServer.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -33,6 +33,7 @@ import java.util.function.Consumer; import io.helidon.common.Version; +import io.helidon.common.context.Context; import io.helidon.nima.webserver.http.DirectHandlers; import io.helidon.nima.webserver.spi.ServerConnectionProvider; @@ -44,15 +45,17 @@ class LoomServer implements WebServer { private final AtomicBoolean running = new AtomicBoolean(); private final Lock lifecycleLock = new ReentrantLock(); private final ExecutorService executorService; + private final Context context; private final boolean registerShutdownHook; - private volatile Thread shutdownHook; + private volatile Thread shutdownHook; private volatile List startFutures; private volatile boolean alreadyStarted = false; LoomServer(Builder builder, DirectHandlers simpleHandlers) { this.registerShutdownHook = builder.shutdownHook(); - ServerContextImpl serverContext = new ServerContextImpl(builder.context(), + this.context = builder.context(); + ServerContextImpl serverContext = new ServerContextImpl(context, builder.mediaContext(), builder.contentEncodingContext()); @@ -160,6 +163,11 @@ public boolean hasTls(String socketName) { return false; } + @Override + public Context context() { + return context; + } + private void stopIt() { parallel("stop", ServerListener::stop); running.set(false); diff --git a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/WebServer.java b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/WebServer.java index b5ce260cbc3..9bb238c0a7b 100644 --- a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/WebServer.java +++ b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/WebServer.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -114,6 +114,13 @@ default boolean hasTls() { return hasTls(DEFAULT_SOCKET_NAME); } + /** + * Context associated with the {@code WebServer}, used as a parent for request contexts. + * + * @return a server context + */ + Context context(); + /** * Returns {@code true} if TLS is configured for the named socket. * diff --git a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http1/spi/Http1UpgradeProvider.java b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http1/spi/Http1UpgradeProvider.java index 37236259105..040ee25e4c8 100644 --- a/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http1/spi/Http1UpgradeProvider.java +++ b/nima/webserver/webserver/src/main/java/io/helidon/nima/webserver/http1/spi/Http1UpgradeProvider.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -39,7 +39,8 @@ public interface Http1UpgradeProvider { * @param ctx connection context * @param prologue http prologue of the upgrade request * @param headers http headers of the upgrade request - * @return a new connection to use instead of the original {@link io.helidon.nima.webserver.http1.Http1Connection} + * @return a new connection to use instead of the original {@link io.helidon.nima.webserver.http1.Http1Connection}, + * or {@code null} if the connection cannot be upgraded */ ServerConnection upgrade(ConnectionContext ctx, HttpPrologue prologue, WritableHeaders headers); } diff --git a/nima/websocket/client/etc/spotbugs/exclude.xml b/nima/websocket/client/etc/spotbugs/exclude.xml new file mode 100644 index 00000000000..8e078d9d02a --- /dev/null +++ b/nima/websocket/client/etc/spotbugs/exclude.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + diff --git a/nima/websocket/client/pom.xml b/nima/websocket/client/pom.xml new file mode 100644 index 00000000000..485d45f28c3 --- /dev/null +++ b/nima/websocket/client/pom.xml @@ -0,0 +1,83 @@ + + + + 4.0.0 + + io.helidon.nima.websocket + helidon-nima-websocket-project + 4.0.0-SNAPSHOT + + + helidon-nima-websocket-client + Helidon Níma WebSocket Client + + + etc/spotbugs/exclude.xml + + + + + io.helidon.nima.webclient + helidon-nima-webclient + + + io.helidon.common + helidon-common-buffers + + + io.helidon.nima.websocket + helidon-nima-websocket + + + io.helidon.common.features + helidon-common-features-api + provided + true + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.helidon.common.features + helidon-common-features-processor + ${helidon.version} + + + + + + + + diff --git a/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/ClientWsConnection.java b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/ClientWsConnection.java new file mode 100644 index 00000000000..04461a4fd86 --- /dev/null +++ b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/ClientWsConnection.java @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2023 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.nima.websocket.client; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import io.helidon.common.buffers.BufferData; +import io.helidon.common.buffers.DataReader; +import io.helidon.common.buffers.DataWriter; +import io.helidon.common.socket.HelidonSocket; +import io.helidon.nima.websocket.ClientWsFrame; +import io.helidon.nima.websocket.ServerWsFrame; +import io.helidon.nima.websocket.WsCloseCodes; +import io.helidon.nima.websocket.WsCloseException; +import io.helidon.nima.websocket.WsListener; +import io.helidon.nima.websocket.WsOpCode; +import io.helidon.nima.websocket.WsSession; + +/** + * Client WebSocket connection. This connection handles a single WebSocket interaction, using + * {@link io.helidon.nima.websocket.WsListener} to handle connection events. + */ +public class ClientWsConnection implements WsSession, Runnable { + private static final System.Logger LOGGER = System.getLogger(ClientWsConnection.class.getName()); + + private final WsListener listener; + private final HelidonSocket helidonSocket; + private final DataReader reader; + private final DataWriter writer; + private final Optional subProtocol; + private final BufferData sendBuffer = BufferData.growing(1024); + + private ContinuationType recvContinuation = ContinuationType.NONE; + private boolean sendContinuation; + private boolean closeSent; + private boolean terminated; + + ClientWsConnection(WsListener listener, + HelidonSocket helidonSocket, + DataReader reader, + DataWriter writer, + Optional subProtocol) { + this.listener = listener; + this.helidonSocket = helidonSocket; + this.reader = reader; + this.writer = writer; + this.subProtocol = subProtocol; + } + + /** + * Create a new connection. The connection needs to run on ana executor service (it implements {@link java.lang.Runnable}) + * so it does not block the current thread. + * + * @param listener WebSocket listener to handle events on this connection + * @param helidonSocket Helidon Socket to obtain information about peer + * @param reader used to read data from remote server + * @param writer use to write data to remote server + * @param subProtocol chosen sub-protocol of this connection (negotiated during upgrade from HTTP/1) + * @return a new WebSocket connection + */ + public static ClientWsConnection create(WsListener listener, + HelidonSocket helidonSocket, + DataReader reader, + DataWriter writer, + Optional subProtocol) { + return new ClientWsConnection(listener, helidonSocket, reader, writer, subProtocol); + } + + @Override + public void run() { + Thread.currentThread().setName(helidonSocket.socketId() + " ws client"); + try { + doRun(); + } catch (Exception e) { + try { + listener.onError(this, e); + this.close(WsCloseCodes.UNEXPECTED_CONDITION, e.getMessage()); + } catch (Exception ex) { + if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { + ex.addSuppressed(e); + LOGGER.log(System.Logger.Level.TRACE, "Exception while handling exception.", ex); + } + } + } finally { + helidonSocket.close(); + } + } + + @Override + public WsSession send(String text, boolean last) { + return send(ClientWsFrame.data(text, last)); + } + + @Override + public WsSession send(BufferData bufferData, boolean last) { + return send(ClientWsFrame.data(bufferData, last)); + } + + @Override + public WsSession ping(BufferData bufferData) { + return send(ClientWsFrame.control(WsOpCode.PING, bufferData)); + } + + @Override + public WsSession pong(BufferData bufferData) { + return send(ClientWsFrame.control(WsOpCode.PONG, bufferData)); + } + + @Override + public WsSession close(int code, String reason) { + closeSent = true; + + byte[] reasonBytes = reason.getBytes(StandardCharsets.UTF_8); + BufferData bufferData = BufferData.create(2 + reasonBytes.length); + bufferData.writeInt16(code); + bufferData.write(reasonBytes); + send(ClientWsFrame.control(WsOpCode.CLOSE, bufferData)); + + return this; + } + + @Override + public WsSession terminate() { + terminated = true; + close(WsCloseCodes.NORMAL_CLOSE, "Terminate"); + + return this; + } + + @Override + public Optional subProtocol() { + return subProtocol; + } + + private ClientWsConnection send(ClientWsFrame frame) { + WsOpCode opCode = frame.opCode(); + if (opCode == WsOpCode.TEXT || opCode == WsOpCode.BINARY) { + if (sendContinuation) { + opCode = WsOpCode.CONTINUATION; + } + sendContinuation = !frame.fin(); + } + frame.opCode(opCode); + + if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { + helidonSocket.log(LOGGER, System.Logger.Level.TRACE, "ws client frame send %s", frame); + } + + sendBuffer.clear(); + int opCodeFull = frame.fin() ? 0b10000000 : 0; + opCodeFull |= opCode.code(); + sendBuffer.write(opCodeFull); + + long payloadLength = frame.payloadLength(); + if (frame.payloadLength() < 126) { + // this is a masked frame (all client frames MUST be masked) + payloadLength = payloadLength | 0b10000000; + sendBuffer.write((int) payloadLength); + // TODO finish other options (payload longer than 126 bytes) + } + + // write masking key + int[] maskingKey = frame.maskingKey(); + sendBuffer.write(maskingKey[0]); + sendBuffer.write(maskingKey[1]); + sendBuffer.write(maskingKey[2]); + sendBuffer.write(maskingKey[3]); + sendBuffer.write(frame.maskedData()); + writer.writeNow(sendBuffer); + return this; + } + + private void doRun() { + listener.onOpen(this); + while (!terminated) { + try { + ServerWsFrame frame = readFrame(); + if (!processFrame(frame)) { + return; + } + } catch (DataReader.InsufficientDataAvailableException e) { + return; + } catch (WsCloseException e) { + if (!closeSent) { + try { + close(e.closeCode(), e.getMessage()); + } catch (Exception ex) { + // we may receive an exception if the remote site closed the connection already + if (LOGGER.isLoggable(System.Logger.Level.DEBUG)) { + helidonSocket.log(LOGGER, + System.Logger.Level.DEBUG, + "Failed to send close, remote probably closed connection", + ex); + } + } + } + } catch (Exception e) { + if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { + LOGGER.log(System.Logger.Level.TRACE, "Failed while reading or processing frames", e); + } + return; + } + } + } + + private boolean processFrame(ServerWsFrame frame) { + BufferData payload = frame.payloadData(); + switch (frame.opCode()) { + case CONTINUATION -> { + boolean finalFrame = frame.fin(); + ContinuationType ct = recvContinuation; + if (finalFrame) { + recvContinuation = ContinuationType.NONE; + } + switch (ct) { + case TEXT -> listener.receive(this, payload.readString(payload.available(), StandardCharsets.UTF_8), finalFrame); + case BINARY -> listener.receive(this, payload, finalFrame); + default -> { + close(WsCloseCodes.PROTOCOL_ERROR, "Unexpected continuation received"); + throw new WsClientException("Unexpected continuation received"); + } + } + } + case TEXT -> { + recvContinuation = ContinuationType.TEXT; + listener.receive(this, payload.readString(payload.available(), StandardCharsets.UTF_8), frame.fin()); + } + case BINARY -> { + recvContinuation = ContinuationType.BINARY; + listener.receive(this, payload, frame.fin()); + } + case CLOSE -> { + int status = payload.readInt16(); + String reason; + if (payload.available() > 0) { + reason = payload.readString(payload.available(), StandardCharsets.UTF_8); + } else { + reason = "normal"; + } + listener.onClose(this, status, reason); + throw new WsCloseException("normal", WsCloseCodes.NORMAL_CLOSE); + } + case PING -> listener.onPing(this, payload); + case PONG -> listener.onPong(this, payload); + default -> throw new WsCloseException("invalid-op-code", WsCloseCodes.PROTOCOL_ERROR); + } + return true; + } + + private ServerWsFrame readFrame() { + try { + // TODO check may payload size, danger of oom + return ServerWsFrame.read(helidonSocket, reader, Integer.MAX_VALUE); + } catch (WsCloseException e) { + close(e.closeCode(), e.getMessage()); + throw e; + } + } + + private enum ContinuationType { + NONE, + TEXT, + BINARY + } +} diff --git a/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClient.java b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClient.java new file mode 100644 index 00000000000..7835a1722d9 --- /dev/null +++ b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClient.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2023 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.nima.websocket.client; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import io.helidon.common.http.Headers; +import io.helidon.common.http.Http; +import io.helidon.common.http.WritableHeaders; +import io.helidon.nima.webclient.WebClient; +import io.helidon.nima.websocket.WsListener; + +/** + * WebSocket client. + */ +public interface WsClient extends WebClient { + /** + * A new fluent API builder to create new instances of client. + * + * @return a new builder + */ + static Builder builder() { + return new Builder(); + } + + /** + * Starts a new WebSocket connection and runs it in a new virtual thread. + * This method returns when the connection is established and a new {@link io.helidon.nima.websocket.WsSession} is + * started. + * + * @param uri URI to connect to + * @param listener listener to handle WebSocket + */ + void connect(URI uri, WsListener listener); + + /** + * Starts a new WebSocket connection and runs it in a new virtual thread. + * This method returns when the connection is established and a new {@link io.helidon.nima.websocket.WsSession} is + * started. + * + * @param path path to connect to, if client uses a base URI, this is resolved against the base URI + * @param listener listener to handle WebSocket + */ + void connect(String path, WsListener listener); + + /** + * Fluent API builder for {@link io.helidon.nima.websocket.client.WsClient}. + */ + class Builder extends WebClient.Builder { + /** + * Supported WebSocket version. + */ + static final String SUPPORTED_VERSION = "13"; + static final Http.HeaderValue HEADER_UPGRADE_WS = Http.Header.createCached(Http.Header.UPGRADE, "websocket"); + static final Http.HeaderName HEADER_WS_PROTOCOL = Http.Header.create("Sec-WebSocket-Protocol"); + private static final Http.HeaderValue HEADER_WS_VERSION = Http.Header.createCached(Http.Header.create( + "Sec-WebSocket-Version"), SUPPORTED_VERSION); + private final List subprotocols = new ArrayList<>(); + private final WritableHeaders headers = WritableHeaders.create(); + + private Builder() { + } + + @Override + public WsClient build() { + // these headers cannot be modified by user + headers.set(HEADER_UPGRADE_WS); + headers.set(HEADER_WS_VERSION); + headers.set(Http.HeaderValues.CONTENT_LENGTH_ZERO); + if (subprotocols.isEmpty()) { + headers.remove(HEADER_WS_PROTOCOL); + } else { + headers.set(HEADER_WS_PROTOCOL, subprotocols); + } + + return new WsClientImpl(this); + } + + /** + * Add sub-protocol. A list of preferred sub-protocols is sent to server, and it chooses zero or one of them. + * + * @param preferred preferred sub-protocol to use, first one added is considered to be the most desired one + * @return updated builder instance + */ + public Builder addSubProtocol(String preferred) { + Objects.requireNonNull(preferred); + this.subprotocols.add(preferred); + return this; + } + + /** + * Configure sub-protocols. + * A list of preferred sub-protocols is sent to server, and it chooses zero or one of them. + * + * @param preferred preferred sub-protocols to use + * @return updated builder instance + */ + public Builder subProtocols(String... preferred) { + Objects.requireNonNull(preferred); + subprotocols.clear(); + Collections.addAll(subprotocols, preferred); + return this; + } + + /** + * Configure a custom header to be sent. Some headers cannot be modified (Upgrade, WebSocket version, Content Length). + * + * @param header header to add + * @return updated builder instance + */ + public Builder header(Http.HeaderValue header) { + Objects.requireNonNull(header); + headers.set(header); + return this; + } + + Headers headers() { + return headers; + } + } +} diff --git a/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientException.java b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientException.java new file mode 100644 index 00000000000..aa6d1629f91 --- /dev/null +++ b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientException.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2023 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.nima.websocket.client; + +/** + * A WebSocket client exception. + */ +public class WsClientException extends RuntimeException { + /** + * Exception without an underlying cause. + * + * @param message descriptive message of what happened + */ + public WsClientException(String message) { + super(message); + } + + /** + * Exception caused by another exception. + * + * @param message descriptive message + * @param cause underlying cause + */ + public WsClientException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientImpl.java b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientImpl.java new file mode 100644 index 00000000000..fee5c75cd6d --- /dev/null +++ b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/WsClientImpl.java @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2023 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.nima.websocket.client; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Optional; +import java.util.Random; + +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; +import javax.net.ssl.SSLSocket; + +import io.helidon.common.LazyValue; +import io.helidon.common.buffers.BufferData; +import io.helidon.common.buffers.Bytes; +import io.helidon.common.buffers.DataReader; +import io.helidon.common.buffers.DataWriter; +import io.helidon.common.http.Headers; +import io.helidon.common.http.Http; +import io.helidon.common.http.Http.Header; +import io.helidon.common.http.Http1HeadersParser; +import io.helidon.common.http.WritableHeaders; +import io.helidon.common.socket.HelidonSocket; +import io.helidon.common.socket.PlainSocket; +import io.helidon.common.socket.SocketContext; +import io.helidon.common.socket.SocketWriter; +import io.helidon.common.socket.TlsSocket; +import io.helidon.nima.webclient.LoomClient; +import io.helidon.nima.webclient.http1.Http1StatusParser; +import io.helidon.nima.webclient.spi.DnsResolver; +import io.helidon.nima.websocket.WsListener; + +import static java.lang.System.Logger.Level.TRACE; + +class WsClientImpl extends LoomClient implements WsClient { + private static final System.Logger LOGGER = System.getLogger(WsClient.class.getName()); + private static final Http.HeaderValue HEADER_CONN_UPGRADE = Header.create(Header.CONNECTION, "Upgrade"); + private static final Http.HeaderName HEADER_WS_ACCEPT = Header.create("Sec-WebSocket-Accept"); + private static final Http.HeaderName HEADER_WS_KEY = Header.create("Sec-WebSocket-Key"); + private static final LazyValue RANDOM = LazyValue.create(SecureRandom::new); + private static final byte[] KEY_SUFFIX = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.US_ASCII); + private static final int KEY_SUFFIX_LENGTH = KEY_SUFFIX.length; + private static final Base64.Encoder B64_ENCODER = Base64.getEncoder(); + + private final Headers headers; + + protected WsClientImpl(WsClient.Builder builder) { + super(builder); + this.headers = WritableHeaders.create(builder.headers()); + } + + @Override + public void connect(URI uri, WsListener listener) { + // there is no connection pooling, as each connection is upgraded to be a websocket connection + Socket socket; + SSLSocket sslSocket = tls().enabled() ? null : tls().createSocket("http"); + socket = sslSocket == null ? new Socket() : sslSocket; + + socketOptions().configureSocket(socket); + DnsResolver dnsResolver = dnsResolver(); + try { + if (dnsResolver.useDefaultJavaResolver()) { + socket.connect(new InetSocketAddress(uri.getHost(), uri().getPort()), + (int) socketOptions().connectTimeout().toMillis()); + } else { + InetAddress address = dnsResolver.resolveAddress(uri.getHost(), dnsAddressLookup()); + socket.connect(new InetSocketAddress(address, uri.getPort()), (int) socketOptions().connectTimeout().toMillis()); + } + } catch (Exception e) { + throw new WsClientException("Failed to connect to remote server on " + uri, e); + } + String channelId = "0x" + HexFormat.of().toHexDigits(System.identityHashCode(socket)); + HelidonSocket helidonSocket = sslSocket == null + ? PlainSocket.client(socket, channelId) + : TlsSocket.client(sslSocket, channelId); + + try { + finishConnect(channelId, helidonSocket, sslSocket, uri, listener); + } catch (Exception e) { + try { + helidonSocket.close(); + } catch (Exception ex) { + ex.addSuppressed(e); + throw ex; + } + throw e; + } + } + + @Override + public void connect(String path, WsListener listener) { + URI baseUri = super.uri(); + if (baseUri == null) { + connect(URI.create(path), listener); + } else { + connect(baseUri.resolve(path), listener); + } + } + + protected String hash(SocketContext ctx, String wsKey) { + byte[] wsKeyBytes = wsKey.getBytes(StandardCharsets.US_ASCII); + int wsKeyBytesLength = wsKeyBytes.length; + byte[] toHash = new byte[wsKeyBytesLength + KEY_SUFFIX_LENGTH]; + System.arraycopy(wsKeyBytes, 0, toHash, 0, wsKeyBytesLength); + System.arraycopy(KEY_SUFFIX, 0, toHash, wsKeyBytesLength, KEY_SUFFIX_LENGTH); + + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-1"); + return B64_ENCODER.encodeToString(digest.digest(toHash)); + } catch (NoSuchAlgorithmException e) { + ctx.log(LOGGER, System.Logger.Level.ERROR, "SHA-1 must be provided for WebSocket to work", e); + throw new IllegalStateException("SHA-1 not provided", e); + } + } + + private void finishConnect(String channelId, HelidonSocket helidonSocket, SSLSocket sslSocket, URI uri, WsListener listener) { + DataWriter writer = SocketWriter.create(executor(), helidonSocket, 0); + + if (sslSocket != null) { + try { + sslSocket.startHandshake(); + } catch (IOException e) { + throw new WsClientException("Failed to do SSL handshake", e); + } + if (LOGGER.isLoggable(TRACE)) { + debugTls(sslSocket, channelId); + } + String negotiatedProtocol = sslSocket.getApplicationProtocol(); + if (negotiatedProtocol != null && !"http".equals(negotiatedProtocol)) { + helidonSocket.close(); + throw new IllegalStateException("Failed to negotiate http protocol. Protocol from socket: " + negotiatedProtocol); + } + } + + // TLS negotiated, socket connected - now upgrade (GET request to the correct path) + /* + GET path HTTP/1.1 + Upgrade: websocket + Sec-WebSocket-Extensions: list of extensions + Sec-WebSocket-Protocol: subprotocols + Sec-WebSocket-Key: computed value + Sec-WebSocket-Version: 13 + */ + + /* + Prepare prologue + */ + String prologue = "GET " + uri.getPath() + " HTTP/1.1\r\n"; + /* + Prepare headers + */ + WritableHeaders headers = WritableHeaders.create(this.headers); + byte[] nonce = new byte[16]; + RANDOM.get().nextBytes(nonce); + String secWsKey = B64_ENCODER.encodeToString(nonce); + headers.set(HEADER_WS_KEY, secWsKey); + headers.setIfAbsent(Header.create(Header.HOST, uri.getRawAuthority())); + + BufferData data = BufferData.growing(512); + data.writeAscii(prologue); + for (Http.HeaderValue header : headers) { + header.writeHttp1Header(data); + } + // end of headers - write CRLF (also end of request) + data.write(Bytes.CR_BYTE); + data.write(Bytes.LF_BYTE); + writer.write(data); + + // we have written a full upgrade request, now let's wait for response and make sure it is a valid WS upgrade + // response + /* + HTTP/1.1 101 Switching Protocols + Connection: Upgrade + Upgrade: websocket + Sec-WebSocket-Accept: ... + */ + DataReader reader = new DataReader(helidonSocket); + Http.Status status = Http1StatusParser.readStatus(reader, 256); + if (!status.equals(Http.Status.SWITCHING_PROTOCOLS_101)) { + throw new WsClientException("Failed to upgrade to WebSocket, expected switching protocols status, but got: " + + status); + } + WritableHeaders responseHeaders = Http1HeadersParser.readHeaders(reader, 4096, true); + if (!responseHeaders.contains(HEADER_CONN_UPGRADE)) { + throw new WsClientException("Failed to upgrade to WebSocket, expected Connection: Upgrade header. Headers: " + + responseHeaders); + } + if (!responseHeaders.contains(WsClient.Builder.HEADER_UPGRADE_WS)) { + throw new WsClientException("Failed to upgrade to WebSocket, expected Upgrade: websocket header. Headers: " + + responseHeaders); + } + if (!responseHeaders.contains(HEADER_WS_ACCEPT)) { + throw new WsClientException("Failed to upgrade to WebSocket, expected Sec-WebSocket-Accept header. Headers: " + + responseHeaders); + } + String secWsAccept = responseHeaders.get(HEADER_WS_ACCEPT).value(); + if (!hash(helidonSocket, secWsKey).equals(secWsAccept)) { + throw new WsClientException("Failed to upgrade to WebSocket, expected valid secWsKey. Headers: " + + responseHeaders); + } + + // we are upgraded, there is no entity, we can switch to web socket + ClientWsConnection session; + + // sub-protocol exists + if (headers.contains(WsClient.Builder.HEADER_WS_PROTOCOL)) { + session = new ClientWsConnection(listener, + helidonSocket, + reader, + writer, + Optional.of(headers.get(WsClient.Builder.HEADER_WS_PROTOCOL).value())); + } else { + session = new ClientWsConnection(listener, helidonSocket, reader, writer, Optional.empty()); + } + // we have connected, now (as we give control to socket listener), we need to run on a separate thread + executor().submit(session); + } + + private void debugTls(SSLSocket sslSocket, String channelId) { + SSLSession sslSession = sslSocket.getSession(); + if (sslSession == null) { + LOGGER.log(TRACE, "No SSL session"); + return; + } + + String msg = "[client " + channelId + "] TLS negotiated:\n" + + "Application protocol: " + sslSocket.getApplicationProtocol() + "\n" + + "Handshake application protocol: " + sslSocket.getHandshakeApplicationProtocol() + "\n" + + "Protocol: " + sslSession.getProtocol() + "\n" + + "Cipher Suite: " + sslSession.getCipherSuite() + "\n" + + "Peer host: " + sslSession.getPeerHost() + "\n" + + "Peer port: " + sslSession.getPeerPort() + "\n" + + "Application buffer size: " + sslSession.getApplicationBufferSize() + "\n" + + "Packet buffer size: " + sslSession.getPacketBufferSize() + "\n" + + "Local principal: " + sslSession.getLocalPrincipal() + "\n"; + + try { + msg += "Peer principal: " + sslSession.getPeerPrincipal() + "\n"; + msg += "Peer certs: " + certsToString(sslSession.getPeerCertificates()) + "\n"; + } catch (SSLPeerUnverifiedException e) { + msg += "Peer not verified"; + } + + LOGGER.log(TRACE, msg); + } + + private String certsToString(Certificate[] peerCertificates) { + String[] certs = new String[peerCertificates.length]; + + for (int i = 0; i < peerCertificates.length; i++) { + Certificate peerCertificate = peerCertificates[i]; + if (peerCertificate instanceof X509Certificate x509) { + certs[i] = "type=" + peerCertificate.getType() + + ";key=" + peerCertificate.getPublicKey().getAlgorithm() + + "(" + peerCertificate.getPublicKey().getFormat() + ")" + + ";x509=V" + x509.getVersion() + + ";from=" + x509.getNotBefore() + + ";to=" + x509.getNotAfter() + + ";serial=" + x509.getSerialNumber().toString(16); + } else { + certs[i] = "type=" + peerCertificate.getType() + ";key=" + peerCertificate.getPublicKey(); + } + + } + + return String.join(", ", certs); + } +} diff --git a/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/package-info.java b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/package-info.java new file mode 100644 index 00000000000..db4b2d8026c --- /dev/null +++ b/nima/websocket/client/src/main/java/io/helidon/nima/websocket/client/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2023 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. + */ + +/** + * WebSocket client. + */ +package io.helidon.nima.websocket.client; diff --git a/nima/websocket/client/src/main/java/module-info.java b/nima/websocket/client/src/main/java/module-info.java new file mode 100644 index 00000000000..5fb988a8175 --- /dev/null +++ b/nima/websocket/client/src/main/java/module-info.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 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. + */ + +import io.helidon.common.features.api.Feature; +import io.helidon.common.features.api.HelidonFlavor; + +/** + * WebSocket client. + */ +@Feature(value = "WebSocket Client", + description = "WebSocket Client", + in = HelidonFlavor.NIMA, invalidIn = HelidonFlavor.SE, + path = {"WebSocket", "Client"} +) +module io.helidon.nima.websocket.client { + requires static io.helidon.common.features.api; + + requires io.helidon.nima.websocket; + requires io.helidon.nima.webclient; + + exports io.helidon.nima.websocket.client; +} \ No newline at end of file diff --git a/nima/websocket/pom.xml b/nima/websocket/pom.xml index c7fe90d03a4..1408aaf1911 100644 --- a/nima/websocket/pom.xml +++ b/nima/websocket/pom.xml @@ -1,7 +1,7 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 io.helidon.nima.websocket @@ -32,6 +32,10 @@ io.helidon.common helidon-common-buffers + + io.helidon.common + helidon-common-socket + io.helidon.common helidon-common-http diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/AbstractWsFrame.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/AbstractWsFrame.java new file mode 100644 index 00000000000..83c21529c01 --- /dev/null +++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/AbstractWsFrame.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2023 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.nima.websocket; + +import io.helidon.common.LazyValue; +import io.helidon.common.buffers.BufferData; +import io.helidon.common.buffers.DataReader; + +abstract sealed class AbstractWsFrame implements WsFrame permits ServerWsFrame, ClientWsFrame { + private final LazyValue unmaskedData; + private final long payloadLength; + private final boolean fin; + private final boolean isPayload; + + private volatile WsOpCode opCode; + + protected AbstractWsFrame(LazyValue unmaskedData, + long payloadLength, + boolean fin, + boolean isPayload, + WsOpCode opCode) { + this.unmaskedData = unmaskedData; + this.payloadLength = payloadLength; + this.fin = fin; + this.opCode = opCode; + this.isPayload = isPayload; + } + + @Override + public boolean fin() { + return fin; + } + + @Override + public WsOpCode opCode() { + return opCode; + } + + @Override + public long payloadLength() { + return payloadLength; + } + + @Override + public BufferData payloadData() { + return unmaskedData.get(); + } + + @Override + public boolean isPayload() { + return isPayload; + } + + /** + * Configure the operation code of this frame. + * + * @param opCode code to use + */ + public void opCode(WsOpCode opCode) { + this.opCode = opCode; + } + + @Override + public String toString() { + return opCode + (fin ? " (last): \n" : ": \n") + unmaskedData.get().debugDataHex(); + } + + protected static FrameHeader readFrameHeader(DataReader reader, int maxFrameLength) { + int opCodeByte = reader.read(); + boolean fin = (opCodeByte & 0b10000000) != 0; + int extensionFlags = opCodeByte & 0b01110000; + if (extensionFlags != 0) { + throw new WsCloseException("Extension flags defined where none should be", WsCloseCodes.PROTOCOL_ERROR); + } + WsOpCode opCode = WsOpCode.get(opCodeByte & 0b00001111); + + // byte 1 (possible to byte 9 if maximal number of bytes used for length) + int lenByte = reader.read(); + boolean masked = (lenByte & 0b10000000) != 0; + + long frameLength; + int length = lenByte & 0b01111111; + if (length < 126) { + frameLength = length; + } else if (length == 126) { + frameLength = reader.readBuffer(2).readInt16(); + } else { + frameLength = reader.readBuffer(8).readLong(); + } + + if (frameLength < 0) { + throw new WsCloseException("Negative payload length", WsCloseCodes.PROTOCOL_ERROR); + } + if (frameLength > maxFrameLength) { + throw new WsCloseException("Payload too large", WsCloseCodes.TOO_BIG); + } + + return new FrameHeader(opCode, fin, masked, length); + } + + protected static BufferData readPayload(DataReader reader, FrameHeader header) { + return reader.readBuffer(header.length()); + } + + protected static boolean isPayload(FrameHeader header) { + WsOpCode opCode = header.opCode; + return opCode == WsOpCode.BINARY || opCode == WsOpCode.TEXT; + } + + protected record FrameHeader(WsOpCode opCode, + boolean fin, + boolean masked, + int length) { + } +} diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ClientWsFrame.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ClientWsFrame.java new file mode 100644 index 00000000000..2ea93ea9167 --- /dev/null +++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ClientWsFrame.java @@ -0,0 +1,236 @@ +/* + * Copyright (c) 2022, 2023 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.nima.websocket; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Random; + +import io.helidon.common.LazyValue; +import io.helidon.common.buffers.BufferData; +import io.helidon.common.buffers.DataReader; +import io.helidon.common.socket.SocketContext; + +/** + * Frame from a client (always masked). + */ +public final class ClientWsFrame extends AbstractWsFrame { + private static final System.Logger LOGGER = System.getLogger(ClientWsFrame.class.getName()); + private static final LazyValue RANDOM = LazyValue.create(SecureRandom::new); + + private final LazyValue masked; + private final int[] mask; + + private ClientWsFrame(WsOpCode opCode, + long payloadLength, + BufferData data, + boolean fin, + int[] mask, + boolean masked, + boolean isPayload) { + super(unmaskedValue(masked, data, mask), payloadLength, fin, isPayload, opCode); + + this.mask = mask; + + if (masked) { + this.masked = LazyValue.create(data); + } else { + this.masked = LazyValue.create(() -> mask(data, mask)); + } + } + + /** + * Create a text data frame. This method does not split the message into smaller frames if the number of bytes + * is too high, make sure to use with buffer sizes that are guaranteed to be processed by clients. + * + * @param text text data content + * @param last whether the data is last + * @return a new client frame + */ + public static ClientWsFrame data(String text, boolean last) { + BufferData bufferData = BufferData.create(text.getBytes(StandardCharsets.UTF_8)); + return new ClientWsFrame(WsOpCode.TEXT, + bufferData.available(), + bufferData, + last, + newMaskingKey(), + false, + true); + } + + /** + * Create a binary data frame. This method does not split the message into smaller frames if the number of bytes + * is too high, make sure to use with buffer sizes that are guaranteed to be processed by clients. + * + * @param bufferData binary data content + * @param last whether the data is last + * @return a new client frame + */ + public static ClientWsFrame data(BufferData bufferData, boolean last) { + return new ClientWsFrame(WsOpCode.BINARY, + bufferData.available(), + bufferData, + last, + newMaskingKey(), + false, + true); + } + + /** + * Create a new control frame. + * + * @param opCode operation code of this frame + * @param bufferData data of the frame, maximally 125 bytes + * @return a new client frame + * @throws java.lang.IllegalArgumentException in case the length is invalid + */ + public static ClientWsFrame control(WsOpCode opCode, BufferData bufferData) { + return new ClientWsFrame(opCode, + bufferData.available(), + bufferData, + true, + newMaskingKey(), + false, + false); + } + + /** + * Read client frame from request data. + * + * @param ctx socket context + * @param dataReader data reader to get frame bytes from + * @param maxFrameLength maximal length of a frame, to protect memory from too big frames + * @return a new client frame + * @throws io.helidon.nima.websocket.WsCloseException in case of invalid frame + * @throws java.lang.RuntimeException depending on implementation of dataReader + */ + public static ClientWsFrame read(SocketContext ctx, + DataReader dataReader, + int maxFrameLength) { + + FrameHeader header = readFrameHeader(dataReader, maxFrameLength); + + if (!header.masked()) { + throw new WsCloseException("Unmasked client frame", WsCloseCodes.PROTOCOL_ERROR); + } + + // next 4 bytes - masking key + int[] maskingKey = new int[4]; + maskingKey[0] = dataReader.read(); + maskingKey[1] = dataReader.read(); + maskingKey[2] = dataReader.read(); + maskingKey[3] = dataReader.read(); + + // next frameLength bytes - actual payload + BufferData payload = readPayload(dataReader, header); + + ClientWsFrame frame = new ClientWsFrame(header.opCode(), + header.length(), + payload, + header.fin(), + maskingKey, + true, + isPayload(header)); + + if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { + ctx.log(LOGGER, System.Logger.Level.TRACE, "ws client frame recv %s", frame); + } + + return frame; + } + + @Override + public boolean masked() { + return true; + } + + @Override + public int[] maskingKey() { + return mask; + } + + /** + * Masked data of this frame, to be sent over the network. + * + * @return masked data + */ + public BufferData maskedData() { + return masked.get(); + } + + private static LazyValue unmaskedValue(boolean masked, BufferData data, int[] mask) { + if (masked) { + return LazyValue.create(() -> unmask(data, mask)); + } else { + return LazyValue.create(data); + } + } + + private static int[] newMaskingKey() { + Random random = RANDOM.get(); + + int[] maskingKey = new int[4]; + for (int i = 0; i < maskingKey.length; i++) { + maskingKey[i] = random.nextInt(256); + + } + + return maskingKey; + } + + private static BufferData unmask(BufferData data, int[] masks) { + int length = data.available(); + BufferData unmasked = BufferData.create(length); + + /* + Octet i of the transformed data ("transformed-octet-i") is the XOR of + octet i of the original data ("original-octet-i") with octet at index + i modulo 4 of the masking key ("masking-key-octet-j"): + + j = i MOD 4 + transformed-octet-i = original-octet-i XOR masking-key-octet-j + */ + for (int i = 0; i < length; i++) { + int maskIndex = i % 4; + int mask = masks[maskIndex]; + unmasked.write(data.read() ^ mask); + } + + return unmasked; + } + + private static BufferData mask(BufferData data, int[] masks) { + int length = data.available(); + BufferData unmasked = BufferData.create(length); + + /* + Octet i of the transformed data ("transformed-octet-i") is the XOR of + octet i of the original data ("original-octet-i") with octet at index + i modulo 4 of the masking key ("masking-key-octet-j"): + + j = i MOD 4 + transformed-octet-i = original-octet-i XOR masking-key-octet-j + */ + for (int i = 0; i < length; i++) { + int maskIndex = i % 4; + int mask = masks[maskIndex]; + unmasked.write(data.read() ^ mask); + } + + return unmasked; + } +} diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ServerWsFrame.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ServerWsFrame.java new file mode 100644 index 00000000000..79663b86d7c --- /dev/null +++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/ServerWsFrame.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2022, 2023 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.nima.websocket; + +import java.nio.charset.StandardCharsets; + +import io.helidon.common.LazyValue; +import io.helidon.common.buffers.BufferData; +import io.helidon.common.buffers.DataReader; +import io.helidon.common.socket.SocketContext; + +/** + * Frame from a server (never masked). + */ +public final class ServerWsFrame extends AbstractWsFrame { + private static final System.Logger LOGGER = System.getLogger(ServerWsFrame.class.getName()); + + ServerWsFrame(WsOpCode opCode, BufferData data, boolean fin, boolean isPayload) { + super(LazyValue.create(data), data.available(), fin, isPayload, opCode); + } + + /** + * Create a text data frame. This method does not split the message into smaller frames if the number of bytes + * is too high, make sure to use with buffer sizes that are guaranteed to be processed by clients. + * + * @param text text data content + * @param last whether the data is last + * @return a new server frame + */ + public static ServerWsFrame data(String text, boolean last) { + BufferData bufferData = BufferData.create(text.getBytes(StandardCharsets.UTF_8)); + + return new ServerWsFrame(WsOpCode.TEXT, + bufferData, + last, + true); + } + + /** + * Create a binary data frame. This method does not split the message into smaller frames if the number of bytes + * is too high, make sure to use with buffer sizes that are guaranteed to be processed by clients. + * + * @param bufferData binary data content + * @param last whether the data is last + * @return a new server frame + */ + public static ServerWsFrame data(BufferData bufferData, boolean last) { + return new ServerWsFrame(WsOpCode.BINARY, + bufferData, + last, + true); + } + + /** + * Create a new control frame. + * + * @param opCode operation code of this frame + * @param bufferData data of the frame, maximally 125 bytes + * @return a new server frame + * @throws java.lang.IllegalArgumentException in case the length is invalid + */ + public static ServerWsFrame control(WsOpCode opCode, BufferData bufferData) { + if (bufferData.available() > 125) { + throw new IllegalArgumentException("Control frames cannot have more than 125 bytes"); + } + return new ServerWsFrame(opCode, bufferData, true, false); + } + + /** + * Read server frame from request data. + * + * @param ctx socket context + * @param dataReader data reader to get frame bytes from + * @param maxFrameLength maximal length of a frame, to protect memory from too big frames + * @return a new server frame + * @throws io.helidon.nima.websocket.WsCloseException in case of invalid frame + * @throws java.lang.RuntimeException depending on implementation of dataReader + */ + public static ServerWsFrame read(SocketContext ctx, DataReader dataReader, int maxFrameLength) { + + FrameHeader header = readFrameHeader(dataReader, maxFrameLength); + + if (header.masked()) { + throw new WsCloseException("Masked server frame", WsCloseCodes.PROTOCOL_ERROR); + } + + // next frameLength bytes - actual payload + // we can safely cast to int, as we make sure it is smaller or equal to MAX_INT + BufferData payload = readPayload(dataReader, header); + + ServerWsFrame frame = new ServerWsFrame(header.opCode(), + payload, + header.fin(), + isPayload(header)); + + if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { + ctx.log(LOGGER, System.Logger.Level.TRACE, "ws server frame recv %s", frame); + } + + return frame; + } + + @Override + public boolean masked() { + return false; + } + + @Override + public int[] maskingKey() { + throw new IllegalStateException("Server WebSocket frames must not have masking key"); + } +} diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/CloseCodes.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsCloseCodes.java similarity index 96% rename from nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/CloseCodes.java rename to nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsCloseCodes.java index dd2b262dcb2..95904936e61 100644 --- a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/CloseCodes.java +++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsCloseCodes.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -20,7 +20,7 @@ * Codes to use with {@link io.helidon.nima.websocket.WsSession#close(int, String)} and to receive * in {@link io.helidon.nima.websocket.WsListener#onClose(WsSession, int, String)}. */ -public final class CloseCodes { +public final class WsCloseCodes { /** * Code to use with {@link io.helidon.nima.websocket.WsSession#close(int, String)} to indicate normal close operation. */ @@ -118,6 +118,6 @@ public final class CloseCodes { */ public static final int APPLICATION_MAX = 4999; - private CloseCodes() { + private WsCloseCodes() { } } diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsCloseException.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsCloseException.java new file mode 100644 index 00000000000..adac7539988 --- /dev/null +++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsCloseException.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 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.nima.websocket; + +/** + * Exception requesting a close of the WebSocket communication. + */ +public class WsCloseException extends RuntimeException { + private final int code; + + /** + * Create a new exception. + * + * @param message message to use, will be used as the description of the close code + * @param closeCode WebSocket close code, see {@link io.helidon.nima.websocket.WsCloseCodes} + */ + public WsCloseException(String message, int closeCode) { + super(message); + this.code = closeCode; + } + + /** + * Close code that should be used to close the connection. + * + * @return close code + */ + public int closeCode() { + return code; + } +} diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsFrame.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsFrame.java new file mode 100644 index 00000000000..0d6b09f3901 --- /dev/null +++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsFrame.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2022, 2023 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.nima.websocket; + +import io.helidon.common.buffers.BufferData; + +/** + * WebSocket frame. A frame represents single message from WebSocket client or server. + */ +public interface WsFrame { + /** + * Is the end of message (or end of continuation). + * + * @return {@code true} if this is a full message, or the last message in a continuation + */ + boolean fin(); + + /** + * Operation code of this frame. + * + * @return code of this frame + */ + WsOpCode opCode(); + + /** + * Whether this frame is masked. Server frames must not be masked, client frames must be masked. + * + * @return {@code true} for masked frames + */ + boolean masked(); + + /** + * Length of the payload bytes. + * + * @return payload length + */ + long payloadLength(); + + /** + * Masking key, if {@link #masked()} returns {@code true}. + * + * @return masking key if available + * @throws java.lang.IllegalStateException if this frame is not masked + */ + int[] maskingKey(); + + /** + * Always unmasked. + * + * @return payload bytes + */ + BufferData payloadData(); + + /** + * Helper method to check whether this is a payload frame (text or binary), + * or a control frame (such as ping, pong, close etc.). + * + * @return {@code true} for text or binary frames, {@code false} for control frames + * @see #opCode() + */ + default boolean isPayload() { + return opCode() == WsOpCode.TEXT || opCode() == WsOpCode.BINARY; + } +} diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsOpCode.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsOpCode.java new file mode 100644 index 00000000000..9b93082de24 --- /dev/null +++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsOpCode.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2022, 2023 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.nima.websocket; + +/** + * WebSocket operation code. + * Each frame has an operation code to easily understand what should be done. + */ +public enum WsOpCode { + /** + * Continuation frame. + */ + CONTINUATION(0), + /** + * Payload frame with text payload. + */ + TEXT(0x1), + /** + * Payload frame with binary payload. + */ + BINARY(0x2), + /** + * Close control frame. + */ + CLOSE(0x8), + /** + * Ping control frame. + */ + PING(0x9), + /** + * Pong control frame. + */ + PONG(0xA); + + private static final WsOpCode[] OP_CODES = new WsOpCode[16]; + + static { + for (WsOpCode value : WsOpCode.values()) { + OP_CODES[value.code] = value; + } + } + + private final int code; + + WsOpCode(int code) { + this.code = code; + } + + /** + * Get operation code based on its numeric code. + * + * @param code code + * @return operation code for the numeric code + * @throws java.lang.IllegalArgumentException in case the code is not valid + */ + public static WsOpCode get(int code) { + WsOpCode opCode = OP_CODES[code]; + if (opCode == null) { + throw new IllegalArgumentException("Requested code " + code + " is invalid, there is no OpCode for it."); + } + return opCode; + } + + /** + * Numeric code (used in binary frame representation) of this operation code. + * + * @return numeric code + */ + public int code() { + return code; + } +} diff --git a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsSession.java b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsSession.java index 6b6c54090af..7d37d143b24 100644 --- a/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsSession.java +++ b/nima/websocket/websocket/src/main/java/io/helidon/nima/websocket/WsSession.java @@ -61,18 +61,18 @@ public interface WsSession { /** * Close session. * - * @param code close code, may be one of {@link io.helidon.nima.websocket.CloseCodes} + * @param code close code, may be one of {@link io.helidon.nima.websocket.WsCloseCodes} * @param reason reason description * @return this instance */ WsSession close(int code, String reason); /** - * Abort session. + * Terminate session. Sends a close and closes the connection. * * @return this instance */ - WsSession abort(); + WsSession terminate(); /** * The WebSocket sub-protocol negotiated for this session. diff --git a/nima/websocket/websocket/src/main/java/module-info.java b/nima/websocket/websocket/src/main/java/module-info.java index e91b600ff85..6480c1531d7 100644 --- a/nima/websocket/websocket/src/main/java/module-info.java +++ b/nima/websocket/websocket/src/main/java/module-info.java @@ -29,6 +29,8 @@ module io.helidon.nima.websocket { requires static io.helidon.common.features.api; + requires io.helidon.common; + requires transitive io.helidon.common.socket; requires transitive io.helidon.common.buffers; requires transitive io.helidon.common.http; diff --git a/tests/integration/native-image/nima-1/src/main/java/io/helidon/tests/integration/nativeimage/nima1/Nima1Main.java b/tests/integration/native-image/nima-1/src/main/java/io/helidon/tests/integration/nativeimage/nima1/Nima1Main.java index 70467f95ca0..0e6b67e4659 100644 --- a/tests/integration/native-image/nima-1/src/main/java/io/helidon/tests/integration/nativeimage/nima1/Nima1Main.java +++ b/tests/integration/native-image/nima-1/src/main/java/io/helidon/tests/integration/nativeimage/nima1/Nima1Main.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2022 Oracle and/or its affiliates. + * Copyright (c) 2019, 2023 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. @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package io.helidon.tests.integration.nativeimage.nima1; import java.nio.file.Paths; @@ -28,7 +29,7 @@ import io.helidon.nima.webserver.WebServer; import io.helidon.nima.webserver.http.HttpRouting; import io.helidon.nima.webserver.staticcontent.StaticContentSupport; -import io.helidon.nima.websocket.webserver.WebSocketRouting; +import io.helidon.nima.websocket.webserver.WsRouting; import static io.helidon.config.ConfigSources.classpath; import static io.helidon.config.ConfigSources.file; @@ -68,7 +69,7 @@ static WebServer startServer() { WebServer server = WebServer.builder() .port(7076) .addRouting(createRouting(config)) - .addRouting(WebSocketRouting.builder() + .addRouting(WsRouting.builder() .endpoint("/ws/messages", WebSocketEndpoint::new) .build()) .start(); From 277e28c5f054812ee2daeebcbd6ff86a19fee99b Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Wed, 11 Jan 2023 15:19:12 +0100 Subject: [PATCH 2/3] Make sure all messages are sent before we continue with processing. --- .../webserver/WsConversationClient.java | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationClient.java b/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationClient.java index 6bd8d317b8c..b5bf03b51a2 100644 --- a/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationClient.java +++ b/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationClient.java @@ -25,13 +25,15 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.logging.Level; -import java.util.logging.Logger;; +import java.util.logging.Logger; import static io.helidon.nima.tests.integration.websocket.webserver.WsAction.Operation.RCV; import static io.helidon.nima.tests.integration.websocket.webserver.WsAction.OperationType.BINARY; import static io.helidon.nima.tests.integration.websocket.webserver.WsAction.OperationType.TEXT; import static java.nio.charset.StandardCharsets.UTF_8; +; + /** * A websocket client that is driven by a conversation instance. */ @@ -63,15 +65,25 @@ public void run() { @Override public void close() { - socket.sendClose(WebSocket.NORMAL_CLOSURE, "bye"); + try { + socket.sendClose(WebSocket.NORMAL_CLOSURE, "bye") + .get(10, TimeUnit.SECONDS); + } catch (Exception e) { + throw new RuntimeException(e); + } } private void sendMessage(WsAction action) { - switch (action.opType) { - case TEXT -> socket.sendText(action.message, true); - case BINARY -> socket.sendBinary(ByteBuffer.wrap(action.message.getBytes(UTF_8)), true); + try { + switch (action.opType) { + case TEXT -> socket.sendText(action.message, true).get(10, TimeUnit.SECONDS); + case BINARY -> socket.sendBinary(ByteBuffer.wrap(action.message.getBytes(UTF_8)), true) + .get(10, TimeUnit.SECONDS); + } + LOGGER.log(Level.FINE, () -> "Client: " + action); + } catch (Exception e) { + throw new RuntimeException(e); } - LOGGER.log(Level.FINE, () -> "Client: " + action); } private void waitMessage(WsAction action) { From 950483a1a175375f7496b204d8694d608a4fbc4e Mon Sep 17 00:00:00 2001 From: Tomas Langer Date: Wed, 11 Jan 2023 15:27:30 +0100 Subject: [PATCH 3/3] Copyright fix. --- .../integration/websocket/webserver/WsConversationClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationClient.java b/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationClient.java index b5bf03b51a2..0dd09ee1127 100644 --- a/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationClient.java +++ b/nima/tests/integration/websocket/server/src/main/java/io/helidon/nima/tests/integration/websocket/webserver/WsConversationClient.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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.