diff --git a/changes.md b/changes.md index fea3b6525b..40da6670f9 100644 --- a/changes.md +++ b/changes.md @@ -74,10 +74,14 @@ Changes log answers pings, pings idle sessions, and supports the "websocket.maxSessions", "websocket.maxMessageSize", "websocket.maxFrameSize" and "websocket.compression" parameters. Server and client support "permessage-deflate" compression (RFC 7692), - enabled by default. + enabled by default. `WebSocketClient.setClient` shares the I/O threads of a borrowed, + started Netty HTTP `Client` instead of creating its own, with or without a context + (issue #1599). `Client.getHelper()` is now public, as an engine extension point. - Added the `NettyConnectionUpgrade` engine hook: a Restlet returning a "101 Switching Protocols" response with this response attribute takes over the HTTP/1.1 connection (`NettyUpgradeContext`), which the WebSocket extension relies on. + `NettyClientHelper.getEventLoopGroup()` lets extensions share the event loop of the Netty + HTTP client connector. - Added `Response.getProtocol()` and `setProtocol()`: the protocol, with its version, used by the server to reply (for example HTTP/1.1, HTTP/2.0 or HTTP/3.0), set by the HTTP client connector. When unknown, the protocol of the request is returned. @@ -97,6 +101,9 @@ Changes log - Fixed the OpenAPI extension build path for Commons Lang types inherited from Swagger. - Slf4j bridge reports wrong line number and caller class in log4j. Issue #233. - When GET is an allowed method of a server resource, HEAD is now also listed (e.g. in the Allow header of a 405 response). Issue #266. + - `Client.setContext()` now stores the connector helper in the "org.restlet.engine.helper" + context attribute, as the constructors do, so clients added with + `Component.getClients().add(Protocol)` expose it too. - Misc - Fixed JDK HTTP client accessibility in core and WebSocket tests in Eclipse and VS Code, without adding a production module dependency. diff --git a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java index ab3ccc1e63..7d15465b47 100644 --- a/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java +++ b/org.restlet.ext.websocket/src/main/java/org/restlet/ext/websocket/WebSocketClient.java @@ -8,6 +8,7 @@ */ package org.restlet.ext.websocket; +import io.netty.channel.EventLoopGroup; import java.io.IOException; import java.net.URI; import java.time.Duration; @@ -17,50 +18,255 @@ import java.util.Objects; import java.util.logging.Logger; import javax.net.ssl.SSLContext; +import org.restlet.Client; +import org.restlet.engine.netty.NettyClientHelper; import org.restlet.ext.websocket.internal.NettyWebSocketConnector; /** * Opens WebSocket client sessions ("ws" and "wss" URIs) over HTTP/1.1 upgrades. Each session uses - * its own connection. Close the client to release its I/O threads. + * its own connection. + * + *
Configure the client before starting it. {@link #connect(String)} starts a stopped client, + * like {@link org.restlet.Restlet#handle(org.restlet.Request)}. Stop or close the client to close + * its sessions; it can be started again afterwards. + * + *
By default, the client creates its own I/O threads, released when it stops. To share the I/O
+ * threads of the application instead, set a borrowed HTTP {@link Client} with {@link
+ * #setClient(Client)}.
*/
public final class WebSocketClient implements AutoCloseable {
- private final NettyWebSocketConnector connector;
+ private SSLContext sslContext;
- private WebSocketClient(Builder builder) throws IOException {
- this.connector =
- new NettyWebSocketConnector(
- builder.sslContext,
- builder.connectTimeout.toMillis(),
- builder.idleTimeout.toMillis(),
- builder.maxMessageSize,
- (builder.maxFrameSize > 0) ? builder.maxFrameSize : builder.maxMessageSize,
- builder.compression,
- builder.hostnameVerification,
- Logger.getLogger(WebSocketClient.class.getName()));
+ private Duration connectTimeout = Duration.ofSeconds(15);
+
+ private Duration idleTimeout = Duration.ofSeconds(30);
+
+ private int maxMessageSize = 65536;
+
+ private int maxFrameSize;
+
+ private boolean compression = true;
+
+ private boolean hostnameVerification = true;
+
+ private Client client;
+
+ private NettyWebSocketConnector connector;
+
+ /** Constructor using the default settings. */
+ public WebSocketClient() {}
+
+ /**
+ * Returns the SSL context used for "wss" URIs.
+ *
+ * @return The SSL context, or null for the JVM default context.
+ */
+ public synchronized SSLContext getSslContext() {
+ return sslContext;
+ }
+
+ /**
+ * Sets the SSL context used for "wss" URIs. Defaults to the JVM default context.
+ *
+ * @param sslContext The SSL context.
+ */
+ public synchronized void setSslContext(SSLContext sslContext) {
+ requireStopped();
+ this.sslContext = sslContext;
+ }
+
+ /**
+ * Returns the connect and handshake timeout.
+ *
+ * @return The timeout; zero for none.
+ */
+ public synchronized Duration getConnectTimeout() {
+ return connectTimeout;
+ }
+
+ /**
+ * Sets the connect and handshake timeout. Defaults to 15 seconds; zero for none.
+ *
+ * @param connectTimeout The timeout.
+ */
+ public synchronized void setConnectTimeout(Duration connectTimeout) {
+ requireStopped();
+ this.connectTimeout = requireNonNegative(connectTimeout, "connectTimeout");
+ }
+
+ /**
+ * Returns the idle time after which the server is pinged.
+ *
+ * @return The idle timeout; zero when disabled.
+ */
+ public synchronized Duration getIdleTimeout() {
+ return idleTimeout;
+ }
+
+ /**
+ * Sets the idle time after which the server is pinged, the connection being closed if it stays
+ * idle as long again. Defaults to 30 seconds; zero to disable.
+ *
+ * @param idleTimeout The idle timeout.
+ */
+ public synchronized void setIdleTimeout(Duration idleTimeout) {
+ requireStopped();
+ this.idleTimeout = requireNonNegative(idleTimeout, "idleTimeout");
+ }
+
+ /**
+ * Returns the maximum size in bytes of a received message.
+ *
+ * @return The maximum message size.
+ */
+ public synchronized int getMaxMessageSize() {
+ return maxMessageSize;
+ }
+
+ /**
+ * Sets the maximum size in bytes of a received message. Defaults to 65536.
+ *
+ * @param maxMessageSize The maximum message size.
+ */
+ public synchronized void setMaxMessageSize(int maxMessageSize) {
+ requireStopped();
+ if (maxMessageSize <= 0) {
+ throw new IllegalArgumentException("maxMessageSize must be positive");
+ }
+ this.maxMessageSize = maxMessageSize;
+ }
+
+ /**
+ * Returns the maximum size in bytes of a received frame.
+ *
+ * @return The maximum frame size, the maximum message size when not set.
+ */
+ public synchronized int getMaxFrameSize() {
+ return (maxFrameSize > 0) ? maxFrameSize : maxMessageSize;
+ }
+
+ /**
+ * Sets the maximum size in bytes of a received frame. A message may be fragmented in several
+ * frames. Defaults to the maximum message size, and is capped by it.
+ *
+ * @param maxFrameSize The maximum frame size.
+ */
+ public synchronized void setMaxFrameSize(int maxFrameSize) {
+ requireStopped();
+ if (maxFrameSize <= 0) {
+ throw new IllegalArgumentException("maxFrameSize must be positive");
+ }
+ this.maxFrameSize = maxFrameSize;
+ }
+
+ /**
+ * Indicates whether "permessage-deflate" compression (RFC 7692) is offered to the server.
+ *
+ * @return True if compression is offered.
+ */
+ public synchronized boolean isCompression() {
+ return compression;
+ }
+
+ /**
+ * Sets whether "permessage-deflate" compression (RFC 7692) is offered to the server. Defaults
+ * to true; it is used only if the server accepts it.
+ *
+ * @param compression True to offer compression.
+ */
+ public synchronized void setCompression(boolean compression) {
+ requireStopped();
+ this.compression = compression;
+ }
+
+ /**
+ * Indicates whether the server host name is verified for "wss" URIs.
+ *
+ * @return True if the host name is verified.
+ */
+ public synchronized boolean isHostnameVerification() {
+ return hostnameVerification;
+ }
+
+ /**
+ * Sets whether the server host name is verified for "wss" URIs. Defaults to true.
+ *
+ * @param hostnameVerification True to verify the host name.
+ */
+ public synchronized void setHostnameVerification(boolean hostnameVerification) {
+ requireStopped();
+ this.hostnameVerification = hostnameVerification;
}
/**
- * Creates a client with the default settings.
+ * Returns the borrowed HTTP client whose I/O threads are shared.
*
- * @return The client.
- * @throws IOException If the client cannot be created.
+ * @return The borrowed client, or null if the WebSocket client creates its own I/O threads.
*/
- public static WebSocketClient create() throws IOException {
- return builder().build();
+ public synchronized Client getClient() {
+ return client;
+ }
+
+ /**
+ * Sets a borrowed HTTP client whose I/O threads are shared, instead of creating new ones. The
+ * client must use the Netty HTTP connector of the core and be started before this WebSocket
+ * client. Its owner stops it, after this WebSocket client: stopping it closes the sessions.
+ * Only the I/O threads are shared; the other settings, including TLS, are those of this
+ * WebSocket client.
+ *
+ * @param client The borrowed client, or null to create new I/O threads.
+ */
+ public synchronized void setClient(Client client) {
+ requireStopped();
+ this.client = client;
+ }
+
+ /**
+ * Starts the client, creating its I/O threads unless a borrowed client is set. Does nothing if
+ * already started.
+ *
+ * @throws IOException If the client cannot be started.
+ * @throws IllegalStateException If the borrowed client is not a started Netty HTTP client.
+ */
+ public synchronized void start() throws IOException {
+ if (connector == null) {
+ connector =
+ new NettyWebSocketConnector(
+ borrowedEventLoopGroup(),
+ sslContext,
+ connectTimeout.toMillis(),
+ idleTimeout.toMillis(),
+ maxMessageSize,
+ getMaxFrameSize(),
+ compression,
+ hostnameVerification,
+ Logger.getLogger(WebSocketClient.class.getName()));
+ }
}
/**
- * Returns a builder.
+ * Indicates whether the client is started.
*
- * @return The builder.
+ * @return True if the client is started.
*/
- public static Builder builder() {
- return new Builder();
+ public synchronized boolean isStarted() {
+ return connector != null;
}
/**
- * Opens a session, blocking until the handshake completes.
+ * Stops the client, closing its sessions and releasing its own I/O threads, if any. Does
+ * nothing if already stopped.
+ */
+ public synchronized void stop() {
+ if (connector != null) {
+ connector.close();
+ connector = null;
+ }
+ }
+
+ /**
+ * Opens a session, blocking until the handshake completes. Starts the client if needed.
*
* @param uri The "ws" or "wss" URI.
* @return The open session.
@@ -71,7 +277,7 @@ public WebSocketSession connect(String uri) throws IOException {
}
/**
- * Opens a session, blocking until the handshake completes.
+ * Opens a session, blocking until the handshake completes. Starts the client if needed.
*
* @param uri The "ws" or "wss" URI.
* @param subprotocols The subprotocols to offer, in preference order.
@@ -81,138 +287,56 @@ public WebSocketSession connect(String uri) throws IOException {
*/
public WebSocketSession connect(URI uri, List
+ * The content of this file is subject to the terms of the Apache 2.0 open
+ * source license available at https://www.opensource.org/licenses/apache-2.0
+ *
+ * Restlet is a registered trademark of QlikTech International AB.
+ */
+package org.restlet.ext.websocket;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.IOException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.restlet.Client;
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Server;
+import org.restlet.data.Protocol;
+import org.restlet.engine.Engine;
+import org.restlet.engine.connector.HttpClientHelper;
+import org.restlet.engine.connector.HttpServerHelper;
+import org.restlet.engine.netty.NettyClientHelper;
+
+/** Tests WebSocket clients sharing the I/O threads of a borrowed HTTP client. */
+class WebSocketSharedEventLoopTestCase {
+
+ private Server server;
+
+ private Client http;
+
+ private String uri;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ Engine.clearThreadLocalVariables();
+ Engine.register(false);
+ Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null));
+ Engine.getInstance().getRegisteredClients().add(new HttpClientHelper(null));
+ server =
+ new Server(
+ new Context(),
+ Protocol.HTTP,
+ 0,
+ new WebSocketUpgrader() {
+ @Override
+ protected WebSocketHandler accept(Request request, Response response) {
+ return session -> {
+ WebSocketMessage message;
+ while ((message = session.receive()) != null) {
+ session.send(message);
+ }
+ };
+ }
+ });
+ server.start();
+ uri = "ws://localhost:" + server.getActualPort() + "/";
+ http = new Client(new Context(), Protocol.HTTP);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ http.stop();
+ server.stop();
+ Engine.clearThreadLocalVariables();
+ Engine.register();
+ }
+
+ private NettyClientHelper helper(Client client) {
+ return (NettyClientHelper)
+ client.getContext().getAttributes().get("org.restlet.engine.helper");
+ }
+
+ @Test
+ void stoppingKeepsTheBorrowedEventLoop() throws Exception {
+ http.start();
+ WebSocketClient client = new WebSocketClient();
+ client.setClient(http);
+ assertSame(http, client.getClient());
+
+ WebSocketSession session = client.connect(uri);
+ session.sendText("shared");
+ assertEquals("shared", session.receive().getText());
+
+ client.stop();
+ assertNull(session.receive());
+ assertFalse(session.isOpen());
+ assertFalse(helper(http).getEventLoopGroup().isShuttingDown());
+
+ // The borrowed client still serves new WebSocket clients.
+ try (WebSocketClient other = new WebSocketClient()) {
+ other.setClient(http);
+ WebSocketSession again = other.connect(uri);
+ again.sendText("again");
+ assertEquals("again", again.receive().getText());
+ }
+ }
+
+ @Test
+ void stoppingTheBorrowedClientClosesTheSessions() throws Exception {
+ http.start();
+ try (WebSocketClient client = new WebSocketClient()) {
+ client.setClient(http);
+ WebSocketSession session = client.connect(uri);
+
+ http.stop();
+ assertNull(session.receive());
+ assertThrows(IOException.class, () -> client.connect(uri));
+ }
+ }
+
+ @Test
+ void acceptsAClientWhoseContextIsSetLater() throws Exception {
+ // As done by Component.getClients().add(Protocol.HTTP).
+ Client late = new Client(Protocol.HTTP);
+ late.setContext(new Context());
+ late.start();
+ try (WebSocketClient client = new WebSocketClient()) {
+ client.setClient(late);
+ WebSocketSession session = client.connect(uri);
+ session.sendText("late");
+ assertEquals("late", session.receive().getText());
+ } finally {
+ late.stop();
+ }
+ }
+
+ @Test
+ void acceptsAClientWithoutContext() throws Exception {
+ Client plain = new Client(Protocol.HTTP);
+ plain.start();
+ try (WebSocketClient client = new WebSocketClient()) {
+ client.setClient(plain);
+ WebSocketSession session = client.connect(uri);
+ session.sendText("plain");
+ assertEquals("plain", session.receive().getText());
+ } finally {
+ plain.stop();
+ }
+ }
+
+ @Test
+ void rejectsUnsuitableClients() throws Exception {
+ WebSocketClient client = new WebSocketClient();
+
+ client.setClient(http);
+ assertThrows(IllegalStateException.class, client::start);
+
+ client.setClient(new Client(Protocol.HTTP));
+ assertThrows(IllegalStateException.class, client::start);
+
+ client.setClient(new Client(new Context(), Protocol.FTP));
+ assertThrows(IllegalStateException.class, client::start);
+
+ http.start();
+ client.setClient(http);
+ client.start();
+ assertThrows(IllegalStateException.class, () -> client.setClient(null));
+ client.stop();
+ }
+}
diff --git a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java
index 7a33d55ce2..8e5160502f 100644
--- a/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java
+++ b/org.restlet.ext.websocket/src/test/java/org/restlet/ext/websocket/WebSocketTestCase.java
@@ -60,7 +60,8 @@ void setUp() throws IOException {
Engine.register(false);
Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null));
Engine.getInstance().getRegisteredClients().add(new HttpClientHelper(null));
- client = WebSocketClient.builder().connectTimeout(Duration.ofSeconds(5)).build();
+ client = new WebSocketClient();
+ client.setConnectTimeout(Duration.ofSeconds(5));
}
@AfterEach
diff --git a/org.restlet/src/main/java/org/restlet/Client.java b/org.restlet/src/main/java/org/restlet/Client.java
index 79d008356b..f24c9c7b21 100644
--- a/org.restlet/src/main/java/org/restlet/Client.java
+++ b/org.restlet/src/main/java/org/restlet/Client.java
@@ -107,11 +107,14 @@ public Client(String protocolName) {
}
/**
- * Returns the helper provided by the implementation.
+ * Returns the helper provided by the implementation. This is an engine extension point, meant
+ * for Restlet extensions that share the connector's resources, such as its I/O threads. It
+ * doesn't depend on the client having a context.
*
- * @return The helper provided by the implementation.
+ * @return The helper provided by the implementation, or null if no connector supports the
+ * protocols.
*/
- private RestletHelper This is an engine extension point, meant for Restlet extensions.
+ *
+ * @return The event loop group, or null if the connector is not started.
+ */
+ @SuppressWarnings("exports")
+ public EventLoopGroup getEventLoopGroup() {
+ return group;
+ }
+
/**
* Returns the HTTP client transport mode among the following options: "HTTP1_1", "HTTP2",
* "HTTP3" (experimental) and "DYNAMIC". Default to "HTTP1_1".