diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 2cad33c6bb..9336ab62e5 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -11,7 +11,7 @@ concurrency:
${{ github.repository }}-${{ github.workflow }}-${{ github.event.number || github.head_ref || github.run_id || github.sha }}
cancel-in-progress: true
env:
- JAVA_VERSION: 21
+ JAVA_VERSION: 25
jobs:
build:
runs-on: ubuntu-latest
diff --git a/.github/workflows/publish-central.yaml b/.github/workflows/publish-central.yaml
index 8e72d4833b..f8f66b9b62 100644
--- a/.github/workflows/publish-central.yaml
+++ b/.github/workflows/publish-central.yaml
@@ -8,7 +8,7 @@ concurrency:
${{ github.repository }}-${{ github.workflow }}-${{ github.event.number || github.head_ref || github.run_id || github.sha }}
cancel-in-progress: true
env:
- JAVA_VERSION: 21
+ JAVA_VERSION: 25
jobs:
publish-central:
runs-on: ubuntu-latest
diff --git a/.github/workflows/publish-github-packages.yaml b/.github/workflows/publish-github-packages.yaml
index d32a60ce3a..91fb99ba6f 100644
--- a/.github/workflows/publish-github-packages.yaml
+++ b/.github/workflows/publish-github-packages.yaml
@@ -8,7 +8,7 @@ concurrency:
${{ github.repository }}-${{ github.workflow }}-${{ github.event.number || github.head_ref || github.run_id || github.sha }}
cancel-in-progress: true
env:
- JAVA_VERSION: 21
+ JAVA_VERSION: 25
jobs:
publish-gh-packages:
runs-on: ubuntu-latest
diff --git a/.tool-versions b/.tool-versions
index 7321e33ec5..406f5bbbbf 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,2 +1,2 @@
-java adoptopenjdk-21.0.2+13.0.LTS
+java adoptopenjdk-25.0.4+101.0.LTS
maven 3.9.16
diff --git a/changes.md b/changes.md
index c997c83e2d..ba3e8a0b2b 100644
--- a/changes.md
+++ b/changes.md
@@ -3,15 +3,65 @@ Changes log
- 3.0 Milestone 2 (??-??-2026)
- Breaking changes
+ - Java 25 is now the minimum version (compiled with `--release 25`).
+ - The HTTP/HTTPS server and client connectors of the core now rely on Netty 4.2 instead of
+ Jetty 12. The connector class names are unchanged
+ (`org.restlet.engine.connector.HttpServerHelper`, `HttpsServerHelper`,
+ `HttpClientHelper`), and the Jetty dependencies are removed. No Netty type is exposed by
+ the public API: the module requires the Netty modules without `requires transitive`.
+ - Server calls run by default on virtual threads, one per call ("threadPool.virtual"
+ parameter, `true` by default). Set it to `false` to use a pool of platform threads
+ configured by the existing "threadPool.*" parameters.
+ - Server connector parameters: added "threadPool.virtual", "server.maxConcurrentCalls",
+ "eventLoop.bossThreads", "eventLoop.workerThreads". Kept "threadPool.minThreads",
+ "threadPool.maxThreads", "threadPool.threadsPriority", "threadPool.idleTimeout",
+ "connector.acceptQueueSize", "connector.idleTimeout", "http.requestHeaderSize",
+ "http.outputBufferSize", "server.maxConnections", "shutdown.gracefully",
+ "shutdown.timeout", "http.transport.protocol(s)". The other Jetty-specific parameters
+ ("connector.acceptors", "connector.selectors", "lowResource.*", "http.*CacheSize", ...)
+ are removed and ignored.
+ - HTTP/3 is now **experimental**, on Netty's QUIC and HTTP/3 codecs. It is off by default,
+ and enabled with HTTP3 in "http.transport.protocols" (server, combined with HTTP1_1 or
+ HTTP2) or "httpClientTransportMode" (client). It requires the optional
+ `io.netty:netty-codec-http3` dependency and the native QUIC library of the platform
+ (`io.netty:netty-codec-native-quic` with the platform classifier: Linux, macOS, Windows
+ x86_64). The server serves HTTP/3 on the UDP port having the same number as the TCP port
+ and advertises it with the "Alt-Svc" response header; the client doesn't discover it
+ with "Alt-Svc" and only uses it when asked to. TLS comes from the key and trust stores
+ of the `DefaultSslContextFactory` (custom `SslContextFactory` implementations and the
+ cipher suite and protocol settings are not supported). The Jetty "http3PemWorkDir" and
+ "http3.pem.workdir" parameters are removed. See `org.restlet.engine.netty.Http3Support`
+ for the documented limits.
+ - Client connector: removed `getHttpClient()`, `createHttpClient()`, the Jetty
+ authentication and cookie stores (`get/setAuthenticationStore`, `get/setCookieStore`),
+ `get/setExecutor`, `getScheduler` and the "cookieSupported", "addressResolutionTimeout",
+ "connectBlocking", "destinationIdleTimeout", "httpComplianceMode",
+ "maxRequestsQueuedPerDestination", "responseBufferSize", "strictEventOrdering" and
+ "http3PemWorkDir" parameters. Added "hostnameVerification" (`true` by default) and
+ "eventLoop.workerThreads". "maxResponseHeadersSize" is renamed "maxResponseHeaderSize"
+ (8192 by default). Redirects ("followRedirects", "maxRedirects") are now followed by
+ the connector for synchronous calls, without leaking credentials to other origins.
+ - Removed `JettyServerHelper`, `JettyServerCall`, `JettyClientCall`, `JettyHandler`,
+ `RestletSslContextFactoryClient` and `RestletSslContextFactoryServer`.
+ - `MultiPartRepresentation` no longer exposes Jetty types: parts are instances of the new
+ `MultiPartRepresentation.Part` class (name, file name, headers, content
+ representation), parsing takes an optional storage directory instead of a Jetty
+ `MultiPartConfig`, and generation streams the parts.
- Removed the OSGi support: the `Activator`, the `osgi.core` dependency and the bundle
packaging (Maven artifacts are now plain JARs, without `Bundle-*` manifest headers).
`XmlFactoryProvider` of the Jackson extension now relies on the plain `ServiceLoader`.
OSGi users should stay on 2.x.
+ - Removed the `Protocol.WAR` constant ("war" scheme, Web Archive Access Protocol), a
+ leftover of the Servlet mode, which is no longer supported. `Protocol.valueOf("war")`
+ now returns a new, unknown protocol. Use CLAP or FILE references instead.
- Bugs fixed
- 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.
- Misc
- Pinned the Maven Deploy plugin to version 3.2.0 (avoids resolving incompatible Maven 4 beta versions).
+ - Added Netty 4.2.19 (imported with the `netty-bom`). CI builds with Java 25.
+ - The `dangling-doc-comments` lint, new in Java 23, is disabled because license headers
+ use Javadoc-style comments.
- 3.0 Milestone 1 (10-11-2026)
- Breaking changes
diff --git a/org.restlet.ext.slf4j/pom.xml b/org.restlet.ext.slf4j/pom.xml
index c42ab9f34d..529db0ffa9 100644
--- a/org.restlet.ext.slf4j/pom.xml
+++ b/org.restlet.ext.slf4j/pom.xml
@@ -30,5 +30,12 @@
${lib-junit-version}test
+
+
+ org.slf4j
+ slf4j-simple
+ ${lib-slf4j-version}
+ test
+
diff --git a/org.restlet.ext.slf4j/src/main/java/module-info.java b/org.restlet.ext.slf4j/src/main/java/module-info.java
index 75b0f7f374..671f769f79 100644
--- a/org.restlet.ext.slf4j/src/main/java/module-info.java
+++ b/org.restlet.ext.slf4j/src/main/java/module-info.java
@@ -9,7 +9,7 @@
@SuppressWarnings({"module", "requires-automatic", "requires-transitive-automatic"})
module org.restlet.ext.slf4j {
requires transitive org.restlet;
- requires org.slf4j;
+ requires transitive org.slf4j;
exports org.restlet.ext.slf4j;
}
diff --git a/org.restlet.ext.spring/src/main/java/org/restlet/ext/spring/SpringContext.java b/org.restlet.ext.spring/src/main/java/org/restlet/ext/spring/SpringContext.java
index 2d8b3ee85a..3fe72b18ec 100644
--- a/org.restlet.ext.spring/src/main/java/org/restlet/ext/spring/SpringContext.java
+++ b/org.restlet.ext.spring/src/main/java/org/restlet/ext/spring/SpringContext.java
@@ -24,8 +24,7 @@
*
*
Restlet is a registered trademark of QlikTech International AB.
*/
/** Restlet Framework core: API, engine and connectors. */
-@SuppressWarnings({"module", "requires-automatic", "requires-transitive-automatic"})
module org.restlet {
requires transitive java.desktop;
requires transitive java.logging;
requires transitive java.xml;
- requires transitive org.eclipse.jetty.http2.client;
- requires transitive org.eclipse.jetty.http2.client.transport;
- requires transitive org.eclipse.jetty.http2.server;
- requires transitive org.eclipse.jetty.http3.client;
- requires transitive org.eclipse.jetty.http3.client.transport;
- requires transitive org.eclipse.jetty.http3.server;
- requires transitive org.eclipse.jetty.alpn.server;
- requires transitive org.eclipse.jetty.client;
- requires transitive org.eclipse.jetty.http;
- requires transitive org.eclipse.jetty.io;
- requires transitive org.eclipse.jetty.server;
- requires transitive org.eclipse.jetty.util;
- requires transitive org.eclipse.jetty.quic.client;
- requires transitive org.eclipse.jetty.quic.server;
+
+ // Netty is an implementation detail of the HTTP connectors: required, not re-exported.
+ requires io.netty.buffer;
+ requires io.netty.codec;
+ // Needed by the HTTP content decompressor; netty-codec-http only requires it statically, so
+ // it would not be resolved on the module path otherwise.
+ requires io.netty.codec.compression;
+ requires io.netty.codec.http;
+ requires io.netty.codec.http2;
+ requires io.netty.common;
+ requires io.netty.handler;
+ requires io.netty.handler.proxy;
+ requires io.netty.resolver;
+ requires io.netty.transport;
+
+ // Experimental HTTP/3: optional at runtime, see org.restlet.engine.netty.Http3Support.
+ requires static io.netty.codec.http3;
+ requires static io.netty.codec.classes.quic;
exports org.restlet;
exports org.restlet.data;
@@ -39,6 +42,7 @@
exports org.restlet.engine.io;
exports org.restlet.engine.local;
exports org.restlet.engine.log;
+ exports org.restlet.engine.netty;
exports org.restlet.engine.resource;
exports org.restlet.engine.security;
exports org.restlet.engine.ssl;
diff --git a/org.restlet/src/main/java/org/restlet/data/Protocol.java b/org.restlet/src/main/java/org/restlet/data/Protocol.java
index 04e0d034ac..08a9200212 100644
--- a/org.restlet/src/main/java/org/restlet/data/Protocol.java
+++ b/org.restlet/src/main/java/org/restlet/data/Protocol.java
@@ -91,10 +91,6 @@ public final class Protocol {
public static final Protocol RIAP =
new Protocol("riap", "RIAP", "Restlet Internal Access Protocol", UNKNOWN_PORT, true);
- /** Local Web Archive access protocol. */
- public static final Protocol WAR =
- new Protocol("war", "WAR", "Web Archive Access Protocol", UNKNOWN_PORT, true);
-
/**
* ZIP is a special scheme to access to representations inside Zip archive files. Example URI:
* "zip:file:///tmp/test.zip!/test.txt".
@@ -131,8 +127,6 @@ public static Protocol valueOf(String name) {
result = JDBC;
} else if (name.equalsIgnoreCase(RIAP.getSchemeName())) {
result = RIAP;
- } else if (name.equalsIgnoreCase(WAR.getSchemeName())) {
- result = WAR;
} else if (name.equalsIgnoreCase(ZIP.getSchemeName())) {
result = ZIP;
} else {
diff --git a/org.restlet/src/main/java/org/restlet/engine/adapter/Call.java b/org.restlet/src/main/java/org/restlet/engine/adapter/Call.java
index cd513dc79a..73326f7e8a 100644
--- a/org.restlet/src/main/java/org/restlet/engine/adapter/Call.java
+++ b/org.restlet/src/main/java/org/restlet/engine/adapter/Call.java
@@ -34,12 +34,10 @@ public abstract class Call {
public static boolean isBroken(Throwable exception) {
boolean result = false;
- // detect Tomcat and Jetty exceptions
+ // detect client abort exceptions
if (exception instanceof IOException) {
String exceptionName = exception.getClass().getName();
- result =
- (exceptionName.endsWith("ClientAbortException")
- || exceptionName.endsWith("jetty.io.EofException"));
+ result = exceptionName.endsWith("ClientAbortException");
}
// check for known exception messages
diff --git a/org.restlet/src/main/java/org/restlet/engine/adapter/JettyClientCall.java b/org.restlet/src/main/java/org/restlet/engine/adapter/JettyClientCall.java
deleted file mode 100644
index 2eea2fec83..0000000000
--- a/org.restlet/src/main/java/org/restlet/engine/adapter/JettyClientCall.java
+++ /dev/null
@@ -1,288 +0,0 @@
-/**
- * Copyright 2005-2026 Qlik
- *
- * 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.engine.adapter;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import java.util.logging.Level;
-import org.eclipse.jetty.client.InputStreamRequestContent;
-import org.eclipse.jetty.client.InputStreamResponseListener;
-import org.eclipse.jetty.client.Request;
-import org.eclipse.jetty.client.Response;
-import org.eclipse.jetty.http.HttpField;
-import org.eclipse.jetty.http.HttpFields;
-import org.eclipse.jetty.http.HttpFields.Mutable;
-import org.restlet.Uniform;
-import org.restlet.data.Header;
-import org.restlet.data.Protocol;
-import org.restlet.data.Status;
-import org.restlet.engine.connector.HttpClientHelper;
-import org.restlet.engine.header.HeaderConstants;
-import org.restlet.representation.Representation;
-import org.restlet.util.Series;
-
-/**
- * HTTP client connector call based on Jetty's HttpRequest class.
- *
- * @author Jerome Louvel
- * @author Tal Liron
- */
-public class JettyClientCall extends ClientCall {
-
- /** The associated HTTP client. */
- private final HttpClientHelper clientHelper;
-
- /** The wrapped HTTP request. */
- private final Request request;
-
- /** The wrapped HTTP response. */
- private volatile Response response;
-
- /** The wrapped input stream response listener. */
- private volatile InputStreamResponseListener inputStreamResponseListener;
-
- /** Indicates if the response headers were added. */
- private volatile boolean responseHeadersAdded;
-
- /**
- * Constructor.
- *
- * @param helper The parent HTTP client helper.
- * @param method The method name.
- * @param requestUri The request URI.
- */
- public JettyClientCall(HttpClientHelper helper, final String method, final String requestUri) {
- super(helper, method, requestUri);
- this.clientHelper = helper;
-
- if (requestUri.startsWith("http")) {
- this.request = helper.getHttpClient().newRequest(requestUri);
- this.request.method(method);
-
- setConfidential(
- this.request
- .getURI()
- .getScheme()
- .equalsIgnoreCase(Protocol.HTTPS.getSchemeName()));
- } else {
- throw new IllegalArgumentException("Only HTTP or HTTPS resource URIs are allowed here");
- }
- }
-
- /**
- * Returns the HTTP request.
- *
- * @return The HTTP request.
- */
- public Request getRequest() {
- return this.request;
- }
-
- /**
- * Returns the HTTP response.
- *
- * @return The HTTP response.
- */
- public Response getResponse() {
- return this.response;
- }
-
- /**
- * Returns the input stream response listener.
- *
- * @return The input stream response listener.
- */
- public InputStreamResponseListener getInputStreamResponseListener() {
- return this.inputStreamResponseListener;
- }
-
- @Override
- public String getReasonPhrase() {
- final Response httpResponse = getResponse();
- return httpResponse == null ? null : httpResponse.getReason();
- }
-
- @Override
- public OutputStream getRequestEntityStream() {
- return null;
- }
-
- @Override
- public OutputStream getRequestHeadStream() {
- return null;
- }
-
- @Override
- public InputStream getResponseEntityStream(long size) {
- final InputStreamResponseListener isrl = getInputStreamResponseListener();
- return isrl == null ? null : isrl.getInputStream();
- }
-
- /**
- * Returns the response entity if available. Note that no metadata is associated by default, you
- * have to manually set them from your headers.
- *
- *
As a jetty client decodes the input stream on the fly, we have to clear the {@link
- * org.restlet.representation.Representation#getEncodings()} to avoid decoding the input stream
- * another time.
- *
- * @param response the Response to get the entity from
- * @return The response entity if available.
- */
- @Override
- public Representation getResponseEntity(org.restlet.Response response) {
- Representation responseEntity = super.getResponseEntity(response);
- if (responseEntity != null && !responseEntity.getEncodings().isEmpty()) {
- responseEntity.getEncodings().clear();
- // Entity size is reset accordingly.
- responseEntity.setSize(Representation.UNKNOWN_SIZE);
- }
- return responseEntity;
- }
-
- /**
- * Returns the modifiable list of response headers.
- *
- * @return The modifiable list of response headers.
- */
- @Override
- public Series getResponseHeaders() {
- final Series result = super.getResponseHeaders();
-
- if (!this.responseHeadersAdded) {
- final Response httpResponse = getResponse();
- if (httpResponse != null) {
- final HttpFields headers = httpResponse.getHeaders();
- if (headers != null) {
- for (HttpField header : headers)
- result.add(header.getName(), header.getValue());
- }
- }
-
- this.responseHeadersAdded = true;
- }
-
- return result;
- }
-
- /**
- * Returns the response address.
- * Corresponds to the IP address of the responding server.
- *
- * @return The response address.
- */
- @Override
- public String getServerAddress() {
- return this.request.getURI().getHost();
- }
-
- /**
- * Returns the response status code.
- *
- * @return The response status code.
- */
- @Override
- public int getStatusCode() {
- return getResponse().getStatus();
- }
-
- /**
- * Sends the request to the client. Commits the request line, headers, and optional entity and
- * send them over the network.
- *
- * @param request The high-level request.
- * @return The result status.
- */
- @Override
- public Status sendRequest(org.restlet.Request request) {
- Status result = null;
-
- try {
- final Representation entity = request.getEntity();
-
- // Request entity
- if (entity != null && entity.isAvailable())
- this.request.body(new InputStreamRequestContent(entity.getStream()));
-
- // Set the request headers
- for (Header header : getRequestHeaders()) {
- final String name = header.getName();
- switch (name) {
- case HeaderConstants.HEADER_CONTENT_LENGTH:
- // skip this header
- break;
- case HeaderConstants.HEADER_USER_AGENT:
- this.request.agent(header.getValue());
- break;
- default:
- ((Mutable) this.request.getHeaders()).add(name, header.getValue());
- break;
- }
- }
-
- // Ensure that the connection is active
- this.inputStreamResponseListener = new InputStreamResponseListener();
- this.request.send(this.inputStreamResponseListener);
- this.response =
- this.inputStreamResponseListener.get(
- clientHelper.getIdleTimeout(), TimeUnit.MILLISECONDS);
-
- result = new Status(getStatusCode(), getReasonPhrase());
- } catch (IOException e) {
- this.clientHelper
- .getLogger()
- .log(Level.WARNING, "An error occurred while reading the request entity.", e);
- result = new Status(Status.CONNECTOR_ERROR_INTERNAL, e);
-
- // Release the connection
- getRequest().abort(e);
- } catch (TimeoutException e) {
- this.clientHelper.getLogger().log(Level.WARNING, "The HTTP request timed out.", e);
- result = new Status(Status.CONNECTOR_ERROR_COMMUNICATION, e);
-
- // Release the connection
- getRequest().abort(e);
- } catch (InterruptedException e) {
- this.clientHelper
- .getLogger()
- .log(Level.WARNING, "The HTTP request thread was interrupted.", e);
- result = new Status(Status.CONNECTOR_ERROR_COMMUNICATION, e);
-
- // Release the connection
- getRequest().abort(e);
- } catch (ExecutionException e) {
- this.clientHelper
- .getLogger()
- .log(Level.WARNING, "An error occurred while processing the HTTP request.", e);
- result = new Status(Status.CONNECTOR_ERROR_COMMUNICATION, e);
-
- // Release the connection
- getRequest().abort(e);
- }
-
- return result;
- }
-
- @Override
- public void sendRequest(
- org.restlet.Request request, org.restlet.Response response, Uniform callback) {
- sendRequest(request);
-
- final Uniform getOnSent = request.getOnSent();
- if (getOnSent != null) getOnSent.handle(request, response);
-
- if (callback != null)
- // Transmit to the callback, if any
- callback.handle(request, response);
- }
-}
diff --git a/org.restlet/src/main/java/org/restlet/engine/adapter/JettyHandler.java b/org.restlet/src/main/java/org/restlet/engine/adapter/JettyHandler.java
deleted file mode 100644
index a0df627ab8..0000000000
--- a/org.restlet/src/main/java/org/restlet/engine/adapter/JettyHandler.java
+++ /dev/null
@@ -1,84 +0,0 @@
-/**
- * Copyright 2005-2026 Qlik
- *
- * 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.engine.adapter;
-
-import org.eclipse.jetty.server.Handler;
-import org.eclipse.jetty.server.Request;
-import org.eclipse.jetty.server.Response;
-import org.eclipse.jetty.util.Callback;
-import org.restlet.Server;
-import org.restlet.engine.connector.HttpServerHelper;
-import org.restlet.engine.connector.HttpsServerHelper;
-import org.restlet.engine.connector.JettyServerHelper;
-
-/**
- * Jetty handler that knows how to convert Jetty calls into Restlet calls. This handler isn't a full
- * server, if you use it, you need to manually set up the Jetty server connector and add this
- * handler to a Jetty server.
- *
- * @author Valdis Rigdon
- * @author Jerome Louvel
- * @author Tal Liron
- */
-public class JettyHandler extends Handler.Abstract {
-
- /** The Restlet server helper. */
- private final JettyServerHelper helper;
-
- /**
- * Constructor for HTTP server connectors.
- *
- * @param server Restlet HTTP server connector.
- */
- public JettyHandler(Server server) {
- this(server, false);
- }
-
- /**
- * Constructor for HTTP server connectors.
- *
- * @param server Restlet server connector.
- * @param secure Indicates if the server supports HTTP or HTTPS.
- */
- public JettyHandler(Server server, boolean secure) {
- if (secure) {
- this.helper = new HttpsServerHelper(server);
- } else {
- this.helper = new HttpServerHelper(server);
- }
- }
-
- @Override
- protected void doStart() throws Exception {
- super.doStart();
- this.helper.start();
- }
-
- @Override
- protected void doStop() throws Exception {
- super.doStop();
- this.helper.stop();
- }
-
- /**
- * Handles a Jetty call by converting it to a Restlet call and giving it for processing to the
- * Restlet server.
- *
- * @param request The Jetty request.
- * @param response The Jetty response.
- * @param callback The Jetty callback.
- */
- @Override
- public boolean handle(Request request, Response response, Callback callback) throws Exception {
- JettyServerCall httpCall =
- new JettyServerCall(this.helper.getHelped(), request, response, callback);
- this.helper.handle(httpCall);
- return true;
- }
-}
diff --git a/org.restlet/src/main/java/org/restlet/engine/adapter/JettyServerCall.java b/org.restlet/src/main/java/org/restlet/engine/adapter/JettyServerCall.java
deleted file mode 100644
index 6afa7a6982..0000000000
--- a/org.restlet/src/main/java/org/restlet/engine/adapter/JettyServerCall.java
+++ /dev/null
@@ -1,260 +0,0 @@
-/**
- * Copyright 2005-2026 Qlik
- *
- * 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.engine.adapter;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.security.cert.Certificate;
-import java.util.Arrays;
-import java.util.List;
-import org.eclipse.jetty.http.HttpField;
-import org.eclipse.jetty.io.Connection;
-import org.eclipse.jetty.io.EndPoint;
-import org.eclipse.jetty.io.EofException;
-import org.eclipse.jetty.io.ssl.SslConnection.SslEndPoint;
-import org.eclipse.jetty.server.Request;
-import org.eclipse.jetty.server.Response;
-import org.eclipse.jetty.util.Callback;
-import org.restlet.Server;
-import org.restlet.data.Header;
-import org.restlet.util.Series;
-
-/**
- * Call that is used by the Jetty HTTP server connectors.
- *
- * @author Jerome Louvel
- * @author Tal Liron
- */
-public class JettyServerCall extends ServerCall {
-
- /** The wrapped Jetty HTTP request. */
- private final Request request;
-
- /** The wrapped Jetty HTTP response. */
- private final Response response;
-
- /** The wrapped Jetty HTTP callback. */
- private final Callback callback;
-
- /** Indicates if the request headers were parsed and added. */
- private volatile boolean requestHeadersAdded;
-
- /**
- * Constructor.
- *
- * @param server The parent server.
- * @param request The wrapped Jetty HTTP request.
- * @param response The wrapped Jetty HTTP response.
- * @param callback The wrapped Jetty HTTP callback.
- */
- public JettyServerCall(Server server, Request request, Response response, Callback callback) {
- super(server);
- this.request = request;
- this.response = response;
- this.callback = callback;
- this.requestHeadersAdded = false;
- }
-
- @Override
- public boolean abort() {
- getEndPoint().close();
- return true;
- }
-
- @Override
- public void complete() {
- getCallback().succeeded();
- }
-
- @Override
- public void flushBuffers() throws IOException {
- getEndPoint().flush();
- }
-
- /**
- * Returns the wrapped Jetty HTTP callback.
- *
- * @return The wrapped Jetty HTTP callback.
- */
- public Callback getCallback() {
- return this.callback;
- }
-
- @Override
- public List getCertificates() {
- final List result;
-
- if (getEndPoint() instanceof SslEndPoint sslEndPoint) {
- if (sslEndPoint.getSslSessionData() != null
- && sslEndPoint.getSslSessionData().peerCertificates() != null) {
- result = Arrays.asList(sslEndPoint.getSslSessionData().peerCertificates());
- } else {
- result = List.of();
- }
- } else {
- result = List.of();
- }
-
- return result;
- }
-
- /**
- * Returns the wrapped Jetty HTTP request.
- *
- * @return The wrapped Jetty HTTP request.
- */
- public Request getRequest() {
- return this.request;
- }
-
- /**
- * Returns the wrapped Jetty HTTP response.
- *
- * @return The wrapped Jetty HTTP response.
- */
- public Response getResponse() {
- return this.response;
- }
-
- @Override
- public String getCipherSuite() {
- if (getEndPoint() instanceof SslEndPoint sslEndPoint) {
- return sslEndPoint.getSslSessionData().cipherSuite();
- } else {
- return null;
- }
- }
-
- @Override
- public String getClientAddress() {
- return Request.getRemoteAddr(getRequest());
- }
-
- @Override
- public int getClientPort() {
- return Request.getRemotePort(getRequest());
- }
-
- /**
- * Returns the underlying Jetty's connection.
- *
- * @return The underlying Jetty's connection.
- */
- protected Connection getConnection() {
- return getRequest().getConnectionMetaData().getConnection();
- }
-
- /**
- * Returns the underlying Jetty's endpoint.
- *
- * @return The underlying Jetty's endpoint.
- */
- protected EndPoint getEndPoint() {
- return getConnection().getEndPoint();
- }
-
- @Override
- public String getMethod() {
- return getRequest().getMethod();
- }
-
- @Override
- public InputStream getRequestEntityStream(long size) {
- return Request.asInputStream(getRequest());
- }
-
- @Override
- public String getHostDomain() {
- return request.getHttpURI().getHost();
- }
-
- @Override
- public int getHostPort() {
- return request.getHttpURI().getPort();
- }
-
- @Override
- public Series getRequestHeaders() {
- final Series result = super.getRequestHeaders();
-
- if (!this.requestHeadersAdded) {
- // Copy the headers from the request object
- for (HttpField field : getRequest().getHeaders()) {
- result.add(field.getName(), field.getValue());
- }
-
- this.requestHeadersAdded = true;
- }
-
- return result;
- }
-
- public InputStream getRequestHeadStream() {
- // Not available
- return null;
- }
-
- @Override
- public String getRequestUri() {
- return getRequest().getHttpURI().asString();
- }
-
- @Override
- public OutputStream getResponseEntityStream() {
- return Response.asBufferedOutputStream(getRequest(), getResponse());
- }
-
- @Override
- public String getServerAddress() {
- return Request.getLocalAddr(getRequest());
- }
-
- @Override
- public Integer getSslKeySize() {
- if (getEndPoint() instanceof SslEndPoint sslEndPoint) {
- return sslEndPoint.getSslSessionData().keySize();
- } else {
- return null;
- }
- }
-
- @Override
- public String getSslSessionId() {
- if (getEndPoint() instanceof SslEndPoint sslEndPoint) {
- return sslEndPoint.getSslSessionData().sslSessionId();
- } else {
- return null;
- }
- }
-
- @Override
- public boolean isConfidential() {
- return getRequest().isSecure();
- }
-
- @Override
- public boolean isConnectionBroken(Throwable exception) {
- return (exception instanceof EofException) || super.isConnectionBroken(exception);
- }
-
- @Override
- public void sendResponse(org.restlet.Response response) throws IOException {
- // Add call headers
- for (Header header : getResponseHeaders()) {
- getResponse().getHeaders().add(header.getName(), header.getValue());
- }
-
- // First set the response status
- getResponse().setStatus(getStatusCode());
-
- // Write the response entity if it exists
- super.sendResponse(response);
- }
-}
diff --git a/org.restlet/src/main/java/org/restlet/engine/connector/HttpClientHelper.java b/org.restlet/src/main/java/org/restlet/engine/connector/HttpClientHelper.java
index ae03874126..7e1ccfc219 100644
--- a/org.restlet/src/main/java/org/restlet/engine/connector/HttpClientHelper.java
+++ b/org.restlet/src/main/java/org/restlet/engine/connector/HttpClientHelper.java
@@ -8,226 +8,20 @@
*/
package org.restlet.engine.connector;
-import java.net.InetSocketAddress;
-import java.net.SocketAddress;
-import java.nio.file.Path;
-import java.util.Arrays;
-import java.util.Optional;
-import java.util.concurrent.Executor;
-import java.util.logging.Level;
-import org.eclipse.jetty.client.AuthenticationStore;
-import org.eclipse.jetty.client.HttpClient;
-import org.eclipse.jetty.client.HttpClientTransport;
-import org.eclipse.jetty.client.HttpProxy;
-import org.eclipse.jetty.client.transport.HttpClientConnectionFactory;
-import org.eclipse.jetty.client.transport.HttpClientTransportDynamic;
-import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP;
-import org.eclipse.jetty.http.HttpCompliance;
-import org.eclipse.jetty.http.HttpCookieStore;
-import org.eclipse.jetty.http.HttpField;
-import org.eclipse.jetty.http.HttpHeader;
-import org.eclipse.jetty.http2.client.HTTP2Client;
-import org.eclipse.jetty.http2.client.transport.ClientConnectionFactoryOverHTTP2;
-import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2;
-import org.eclipse.jetty.http3.client.HTTP3Client;
-import org.eclipse.jetty.http3.client.transport.ClientConnectionFactoryOverHTTP3;
-import org.eclipse.jetty.http3.client.transport.HttpClientTransportOverHTTP3;
-import org.eclipse.jetty.io.ClientConnectionFactory;
-import org.eclipse.jetty.io.ClientConnector;
-import org.eclipse.jetty.quic.client.ClientQuicConfiguration;
-import org.eclipse.jetty.util.ssl.SslContextFactory;
-import org.eclipse.jetty.util.thread.QueuedThreadPool;
-import org.eclipse.jetty.util.thread.ScheduledExecutorScheduler;
-import org.eclipse.jetty.util.thread.Scheduler;
import org.restlet.Client;
-import org.restlet.Request;
import org.restlet.data.Protocol;
-import org.restlet.engine.adapter.ClientCall;
-import org.restlet.engine.adapter.JettyClientCall;
-import org.restlet.engine.security.RestletSslContextFactoryClient;
-import org.restlet.engine.ssl.DefaultSslContextFactory;
-import org.restlet.engine.ssl.SslUtils;
-import org.restlet.engine.util.ReferenceUtils;
+import org.restlet.engine.netty.NettyClientHelper;
/**
- * HTTP client connector using the Jetty project. Here is the list of parameters that are supported.
- * They should be set in the Client's context before it is started:
- *
- *
- *
list of supported parameters
- *
- *
Parameter name
- *
Value type
- *
Default value
- *
Description
- *
- *
- *
addressResolutionTimeout
- *
long
- *
15000
- *
The timeout in milliseconds for the DNS resolution of host addresses
- *
- *
- *
bindAddress
- *
String
- *
null
- *
The address to bind socket channels to. You must set both this and bindPort
- *
- *
- *
bindPort
- *
int
- *
null
- *
The address to bind socket channels to. You must set both this and bindAddress
- *
- *
- *
cookieSupported
- *
boolean
- *
false
- *
Whether to support HTTP cookie, storing and automatically sending them back
- *
- *
- *
connectBlocking
- *
boolean
- *
false
- *
Indicates whether the connect operation is blocking. See {@link HttpClient#isConnectBlocking()}.
- *
- *
- *
connectTimeout
- *
long
- *
15000
- *
The max time in milliseconds a connection can take to connect to destinations
- *
- *
- *
destinationIdleTimeout
- *
long
- *
0
- *
the time in milliseconds after which idle destinations are removed
- *
- *
- *
followRedirects
- *
boolean
- *
true
- *
Whether to follow HTTP redirects
- *
- *
- *
httpComplianceMode
- *
String
- *
RFC7230
- *
Indicate the HTTP compliance mode among the following options: "RFC7230", "RFC2616", "LEGACY", "RFC7230_LEGACY".
- * See {@link HttpCompliance}.
- *
- *
- *
httpClientTransportMode
- *
String
- *
HTTP1_1
- *
Indicate the HTTP client transport mode among the following options: "HTTP1_1", "HTTP2", "HTTP3", "DYNAMIC". See
- * {@link HttpClientTransport}.
- *
- *
- *
idleTimeout
- *
long
- *
30000
- *
The max time in milliseconds a connection can be idle (that is, without traffic of bytes in either
- * direction)
- *
- *
- *
maxConnectionsPerDestination
- *
int
- *
64
- *
Sets the max number of connections to open to each destination
- *
- *
- *
maxRedirects
- *
int
- *
8
- *
The max number of HTTP redirects that are followed
- *
- *
- *
maxRequestsQueuedPerDestination
- *
int
- *
1024
- *
Sets the max number of requests that may be queued to a destination
- *
- *
- *
maxResponseHeaderSize
- *
int
- *
-1
- *
The max size in bytes of the response headers. -1 is unlimited.
- *
- *
- *
proxyHost
- *
String
- *
System property "http.proxyHost"
- *
The host name of the HTTP proxy.
- *
- *
- *
proxyPort
- *
int
- *
System property "http.proxyPort" or "3128"
- *
The port of the HTTP proxy.
- *
- *
- *
requestBufferSize
- *
int
- *
4096
- *
The size in bytes of the buffer used to write requests
- *
- *
- *
responseBufferSize
- *
int
- *
16384
- *
The size in bytes of the buffer used to read responses
- *
- *
- *
strictEventOrdering
- *
boolean
- *
false
- *
Whether request events must be strictly ordered
- *
- *
- *
userAgentField
- *
String
- *
null
- *
The "User-Agent" HTTP header string; when null, uses the Jetty default
- *
- *
- *
sslContextFactory
- *
String
- *
org.restlet.ext.ssl.DefaultSslContextFactory
- *
Let you specify a {@link SslContextFactory} qualified class name as a parameter, or an instance as an attribute
- * for a more complete and flexible SSL context setting
- *
- *
- *
http3PemWorkDir
- *
string
- *
No default value
- *
Directory where are exported trusted certificates, required for HTTP3 support. There is no default value to let
- * you configure a secured enough directory.
- *
- *
- *
- * For the default SSL parameters see the Javadocs of the {@link DefaultSslContextFactory} class.
+ * Netty HTTP and HTTPS client connector. See {@link NettyClientHelper} for the list of supported
+ * parameters.
*
* @author Jerome Louvel
- * @author Tal Liron
*/
-public class HttpClientHelper extends org.restlet.engine.adapter.HttpClientHelper {
-
- /** The wrapped Jetty HTTP client. */
- private volatile HttpClient httpClient;
-
- /** The wrapped Jetty authentication store. */
- private volatile AuthenticationStore authenticationStore;
-
- /** The wrapped Jetty cookie store. */
- private volatile HttpCookieStore cookieStore;
-
- /** The wrapper Executor. */
- private volatile Executor executor;
+public class HttpClientHelper extends NettyClientHelper {
/**
- * Constructor. Properties can still be set before the wrapped Jetty HTTP client is effectively
- * created and configured via the {@link #createHttpClient()} method.
+ * Constructor.
*
* @param client The client connector to help.
*/
@@ -235,513 +29,5 @@ public HttpClientHelper(Client client) {
super(client);
getProtocols().add(Protocol.HTTP);
getProtocols().add(Protocol.HTTPS);
- this.authenticationStore = null;
- this.cookieStore =
- isCookieSupported() ? new HttpCookieStore.Default() : new HttpCookieStore.Empty();
- this.executor = null;
- }
-
- /**
- * Creates a low-level HTTP client call from a high-level uniform call.
- *
- * @param request The high-level request.
- * @return A low-level HTTP client call.
- */
- public ClientCall create(Request request) {
- ClientCall result = null;
-
- result =
- new JettyClientCall(
- this,
- request.getMethod().toString(),
- ReferenceUtils.update(request.getResourceRef(), request).toString());
-
- return result;
- }
-
- /**
- * Creates a Jetty HTTP client.
- *
- * @return A new HTTP client.
- */
- protected HttpClient createHttpClient() {
- SslContextFactory.Client sslContextFactory = null;
-
- try {
- sslContextFactory =
- new RestletSslContextFactoryClient(SslUtils.getSslContextFactory(this));
- } catch (Exception e) {
- getLogger().log(Level.WARNING, "Unable to create the Jetty SSL context factory", e);
- }
-
- HttpTransportProtocol httpTransportProtocol =
- HttpTransportProtocol.fromName(getHttpClientTransportMode());
- final HttpClientTransport httpTransport =
- switch (httpTransportProtocol) {
- case HTTP1_1 -> getHttpTransportForHttp11();
- case HTTP2 -> getHttpClientTransportForHttp2();
- case HTTP3 -> getHttpClientTransportForHttp3(sslContextFactory);
- case DYNAMIC -> getHttpClientTransportForDynamicMode(sslContextFactory);
- };
-
- final HttpClient result = new HttpClient(httpTransport);
- result.setAddressResolutionTimeout(getAddressResolutionTimeout());
- if (getAuthenticationStore() != null) {
- result.setAuthenticationStore(getAuthenticationStore());
- }
- result.setBindAddress(getBindAddress());
- result.setConnectBlocking(isConnectBlocking());
- result.setConnectTimeout(getConnectTimeout());
- result.setDestinationIdleTimeout(getDestinationIdleTimeout());
- result.setExecutor(getExecutor());
- result.setFollowRedirects(isFollowRedirects());
-
- final String httpComplianceMode = getHttpComplianceMode();
- final HttpCompliance httpCompliance =
- switch (httpComplianceMode) {
- case "RFC7230" -> HttpCompliance.RFC7230;
- case "RFC7230_LEGACY" -> HttpCompliance.RFC7230_LEGACY;
- case "RFC2616" -> HttpCompliance.RFC2616;
- case "RFC2616_LEGACY" -> HttpCompliance.RFC2616_LEGACY;
- default -> {
- getLogger()
- .log(
- Level.WARNING,
- "Unknown HTTP compliance mode: {0}, default to RFC7230",
- httpComplianceMode);
- yield HttpCompliance.RFC7230;
- }
- };
- result.setHttpCompliance(httpCompliance);
-
- result.setHttpCookieStore(getCookieStore());
- result.setIdleTimeout(getIdleTimeout());
- result.setMaxConnectionsPerDestination(getMaxConnectionsPerDestination());
- result.setMaxRedirects(getMaxRedirects());
- result.setMaxRequestsQueuedPerDestination(getMaxRequestsQueuedPerDestination());
- result.setMaxResponseHeadersSize(getMaxResponseHeadersSize());
-
- final String httpProxyHost = getProxyHost();
- if (httpProxyHost != null) {
- HttpProxy proxy = new HttpProxy(httpProxyHost, getProxyPort());
- result.getProxyConfiguration().addProxy(proxy);
- }
-
- result.setRequestBufferSize(getRequestBufferSize());
- result.setResponseBufferSize(getResponseBufferSize());
- result.setScheduler(getScheduler());
- result.setSslContextFactory(sslContextFactory);
- result.setStrictEventOrdering(isStrictEventOrdering());
-
- final String userAgentField = getUserAgentField();
- if (userAgentField != null) {
- result.setUserAgentField(new HttpField(HttpHeader.USER_AGENT, userAgentField));
- }
-
- return result;
- }
-
- private static HttpClientTransportOverHTTP getHttpTransportForHttp11() {
- return new HttpClientTransportOverHTTP();
- }
-
- private HttpClientTransport getHttpClientTransportForHttp2() {
- HTTP2Client http2Client = new HTTP2Client();
- HttpClientTransportOverHTTP2 http2Transport = new HttpClientTransportOverHTTP2(http2Client);
- http2Transport.setUseALPN(true);
-
- return http2Transport;
- }
-
- private HttpClientTransport getHttpClientTransportForHttp3(
- SslContextFactory.Client sslContextFactory) {
- Path pemWorkDirectory = getHttp3PemWorkDirectoryPath();
- ClientQuicConfiguration quicConfiguration =
- new ClientQuicConfiguration(sslContextFactory, pemWorkDirectory);
- HTTP3Client http3Client = new HTTP3Client(quicConfiguration);
- http3Client.getQuicConfiguration().setSessionRecvWindow(64 * 1024 * 1024);
-
- return new HttpClientTransportOverHTTP3(http3Client);
- }
-
- private HttpClientTransport getHttpClientTransportForDynamicMode(
- SslContextFactory.Client sslContextFactory) {
-
- ClientConnectionFactory.Info http1 = HttpClientConnectionFactory.HTTP11;
-
- HTTP2Client http2Client = new HTTP2Client();
- ClientConnectionFactoryOverHTTP2.HTTP2 http2 =
- new ClientConnectionFactoryOverHTTP2.HTTP2(http2Client);
-
- ClientQuicConfiguration quicConfiguration =
- new ClientQuicConfiguration(sslContextFactory, getHttp3PemWorkDirectoryPath());
- HTTP3Client http3Client = new HTTP3Client(quicConfiguration);
- ClientConnectionFactoryOverHTTP3.HTTP3 http3 =
- new ClientConnectionFactoryOverHTTP3.HTTP3(http3Client);
-
- return new HttpClientTransportDynamic(new ClientConnector(), http1, http2, http3);
- }
-
- /**
- * The timeout in milliseconds for the DNS resolution of host addresses. Defaults to 15_000.
- *
- * @return The address resolution timeout.
- */
- public long getAddressResolutionTimeout() {
- return Long.parseLong(
- getHelpedParameters().getFirstValue("addressResolutionTimeout", "15000"));
- }
-
- /**
- * Returns the wrapped Jetty authentication store.
- *
- * @return The wrapped Jetty authentication store.
- */
- public AuthenticationStore getAuthenticationStore() {
- return authenticationStore;
- }
-
- /**
- * Sets the wrapped Jetty authentication store.
- *
- * @param authenticationStore The wrapped Jetty authentication store.
- */
- public void setAuthenticationStore(AuthenticationStore authenticationStore) {
- this.authenticationStore = authenticationStore;
- }
-
- /**
- * The address to bind socket channels to. Default to null.
- *
- * @return The bind address or null.
- */
- public SocketAddress getBindAddress() {
- final String bindAddress = getHelpedParameters().getFirstValue("bindAddress", null);
- final String bindPort = getHelpedParameters().getFirstValue("bindPort", null);
-
- if ((bindAddress != null) && (bindPort != null)) {
- return new InetSocketAddress(bindAddress, Integer.parseInt(bindPort));
- } else {
- return null;
- }
- }
-
- /**
- * The max time in milliseconds a connection can take to connect to destinations. Defaults to
- * 15_000.
- *
- * @return The connect timeout.
- */
- public long getConnectTimeout() {
- return Long.parseLong(getHelpedParameters().getFirstValue("connectTimeout", "15000"));
- }
-
- /**
- * Returns the wrapped Jetty cookie store.
- *
- * @return The wrapped Jetty cookie store.
- */
- public HttpCookieStore getCookieStore() {
- return this.cookieStore;
- }
-
- /**
- * Sets the wrapped Jetty cookie store.
- *
- * @param cookieStore The wrapped Jetty cookie store.
- */
- public void setCookieStore(HttpCookieStore cookieStore) {
- this.cookieStore = cookieStore;
- }
-
- /**
- * The timeout in milliseconds for idle destinations to be removed. Defaults to 0.
- *
- * @return The address resolution timeout.
- */
- public long getDestinationIdleTimeout() {
- return Long.parseLong(getHelpedParameters().getFirstValue("destinationIdleTimeout", "0"));
- }
-
- /**
- * Returns the executor. By default, returns an instance of {@link QueuedThreadPool}.
- *
- * @return Returns the executor.
- */
- public Executor getExecutor() {
- return this.executor;
- }
-
- /**
- * Sets the executor.
- *
- * @param executor The executor.
- */
- public void setExecutor(Executor executor) {
- this.executor = executor;
- }
-
- /**
- * Returns the wrapped Jetty HTTP client.
- *
- * @return The wrapped Jetty HTTP client.
- */
- public HttpClient getHttpClient() {
- return this.httpClient;
- }
-
- /**
- * Returns the HTTP compliance mode among the following options: "RFC7230", "RFC2616", "LEGACY",
- * "RFC7230_LEGACY". See {@link HttpCompliance}. Default to "RFC7230".
- *
- * @return The HTTP compliance mode.
- */
- public String getHttpComplianceMode() {
- return getHelpedParameters().getFirstValue("httpComplianceMode", "RFC7230");
- }
-
- /**
- * Returns the HTTP client transport mode among the following options: "HTTP1_1", "HTTP2",
- * "HTTP3", "DYNAMIC. See {@link HttpClientTransport}. Default to "HTTP1_1".
- *
- * @return The HTTP client transport mode.
- */
- public String getHttpClientTransportMode() {
- return getHelpedParameters()
- .getFirstValue("httpClientTransportMode", HttpTransportProtocol.HTTP1_1.name());
- }
-
- /**
- * Directory where are extracted the supported certificates.
- *
- * @return Directory where are extracted the supported certificates.
- */
- public String getHttp3PemWorkDir() {
- return getHelpedParameters().getFirstValue("http3PemWorkDir");
- }
-
- private Path getHttp3PemWorkDirectoryPath() {
- return Optional.ofNullable(getHttp3PemWorkDir()).map(Path::of).orElse(null);
- }
-
- /**
- * The max time in milliseconds a connection can be idle (that is, without traffic of bytes in
- * either direction). Defaults to 30_000.
- *
- * @return The idle timeout.
- */
- public long getIdleTimeout() {
- return Long.parseLong(getHelpedParameters().getFirstValue("idleTimeout", "30000"));
- }
-
- /**
- * Sets the max number of connections to open to each destination. Defaults to 64.
- *
- *
RFC 2616 suggests that 2 connections should be opened per each destination, but browsers
- * commonly open 6. If this client is used for load testing, it is common to have only one
- * destination (the server to load test), and it is recommended to set this value to a high
- * value (at least as much as the threads present in the {@link #getExecutor() executor}).
- *
- * @return The maximum connections per destination.
- */
- public int getMaxConnectionsPerDestination() {
- return Integer.parseInt(
- getHelpedParameters().getFirstValue("maxConnectionsPerDestination", "64"));
- }
-
- /**
- * The max number of HTTP redirects that are followed. Defaults to 8.
- *
- * @return The maximum redirects.
- */
- public int getMaxRedirects() {
- return Integer.parseInt(getHelpedParameters().getFirstValue("maxRedirects", "8"));
- }
-
- /**
- * Sets the max number of requests that may be queued to a destination. Defaults to 1024.
- *
- *
If this client performs a high rate of requests to a destination, and all the connections
- * managed by that destination are busy with other requests, then new requests will be queued up
- * in the destination. This parameter controls how many requests can be queued before starting
- * to reject them. If this client is used for load testing, it is common to have this parameter
- * set to a high value, although this may impact latency (requests sit in the queue for a long
- * time before being sent).
- *
- * @return The maximum requests queues per destination.
- */
- public int getMaxRequestsQueuedPerDestination() {
- return Integer.parseInt(
- getHelpedParameters().getFirstValue("maxRequestsQueuedPerDestination", "1024"));
- }
-
- /**
- * Returns the max size in bytes of the response headers. Default is -1 that is unlimited.
- *
- * @return the max size in bytes of the response headers.
- */
- public int getMaxResponseHeadersSize() {
- return Integer.parseInt(
- getHelpedParameters().getFirstValue("maxResponseHeadersSize", "-1"));
- }
-
- /**
- * Returns the host name of the HTTP proxy, if specified.
- *
- * @return the host name of the HTTP proxy, if specified.
- */
- public String getProxyHost() {
- return getHelpedParameters()
- .getFirstValue("proxyHost", System.getProperty("http.proxyHost"));
- }
-
- /**
- * Returns the port of the HTTP proxy, if specified, 3128 otherwise.
- *
- * @return the port of the HTTP proxy.
- */
- public int getProxyPort() {
- return Integer.parseInt(
- getHelpedParameters()
- .getFirstValue("proxyPort", System.getProperty("http.proxyPort", "3128")));
- }
-
- /**
- * The size in bytes of the buffer used to write requests. Defaults to 4096.
- *
- * @return The request buffer size.
- */
- public int getRequestBufferSize() {
- return Integer.parseInt(getHelpedParameters().getFirstValue("requestBufferSize", "4096"));
- }
-
- /**
- * The size in bytes of the buffer used to read responses. Defaults to 16_384.
- *
- * @return The response buffer size.
- */
- public int getResponseBufferSize() {
- return Integer.parseInt(getHelpedParameters().getFirstValue("responseBufferSize", "16384"));
- }
-
- /**
- * The scheduler. Defaults to null. When null, creates a new instance of {@link
- * ScheduledExecutorScheduler}.
- *
- * @return The scheduler.
- */
- public Scheduler getScheduler() {
- return null;
- }
-
- /**
- * The "User-Agent" HTTP header string. When null, uses the Jetty default. Default to null.
- *
- * @return The user agent field or null.
- */
- public String getUserAgentField() {
- return getHelpedParameters().getFirstValue("userAgentField", null);
- }
-
- /**
- * Indicates whether the connect operation is blocking. See {@link
- * HttpClient#isConnectBlocking()}.
- *
- * @return True if the connect operation is blocking.
- */
- public boolean isConnectBlocking() {
- return Boolean.parseBoolean(getHelpedParameters().getFirstValue("connectBlocking"));
- }
-
- /**
- * Whether to support cookies, storing, and automatically sending them back. Defaults to false.
- *
- * @return Whether to support cookies.
- */
- public boolean isCookieSupported() {
- return Boolean.parseBoolean(getHelpedParameters().getFirstValue("cookieSupported"));
- }
-
- /**
- * Whether to follow HTTP redirects. Defaults to true.
- *
- * @return Whether to follow redirects.
- */
- public boolean isFollowRedirects() {
- return Boolean.parseBoolean(getHelpedParameters().getFirstValue("followRedirects", "true"));
- }
-
- /**
- * Whether request events must be strictly ordered. Defaults to false.
- *
- *
Client listeners may send a second request. If the second request is for the same
- * destination, there is an inherent race condition for the use of the connection: the first
- * request may still be associated with the connection, so the second request cannot use that
- * connection and is forced to open another one.
- *
- *
From the point of view of connection usage, the connection is reusable just before the
- * "complete" event, so it would be possible to reuse that connection from complete listeners;
- * but in this case the second request's events will fire before the "complete" events of the
- * first request.
- *
- *
This setting enforces strict event ordering so that a "begin" event of a second request
- * can never fire before the "complete" event of a first request, but at the expense of an
- * increased usage of connections.
- *
- *
When not enforced, a "begin" event of a second request may happen before the "complete"
- * event of a first request and allow for better usage of connections.
- *
- * @return Whether request events must be strictly ordered.
- */
- public boolean isStrictEventOrdering() {
- return Boolean.parseBoolean(getHelpedParameters().getFirstValue("strictEventOrdering"));
- }
-
- @Override
- public void start() throws Exception {
- super.start();
-
- if (this.httpClient == null) {
- this.httpClient = createHttpClient();
- }
-
- if (httpClient != null) {
- getLogger().info("Starting a Jetty HTTP/HTTPS client");
- httpClient.start();
- }
- }
-
- @Override
- public void stop() throws Exception {
- if (httpClient != null) {
- getLogger().info("Stopping a Jetty HTTP/HTTPS client");
- httpClient.stop();
- }
-
- super.stop();
- }
-
- /** Supported HTTP transport protocols. */
- private enum HttpTransportProtocol {
- HTTP1_1,
- HTTP2,
- HTTP3,
- DYNAMIC;
-
- static HttpTransportProtocol fromName(final String name) {
- try {
- return HttpTransportProtocol.valueOf(name);
- } catch (final IllegalArgumentException iae) {
- String supportedHttpTransportProtocols =
- Arrays.toString(HttpTransportProtocol.values());
-
- final String errorMessage =
- String.format(
- "'%s' is not one of the supported values: %s",
- name, supportedHttpTransportProtocols);
-
- throw new IllegalArgumentException(errorMessage);
- }
- }
}
}
diff --git a/org.restlet/src/main/java/org/restlet/engine/connector/HttpServerHelper.java b/org.restlet/src/main/java/org/restlet/engine/connector/HttpServerHelper.java
index 929e6c16cf..a7fa103812 100644
--- a/org.restlet/src/main/java/org/restlet/engine/connector/HttpServerHelper.java
+++ b/org.restlet/src/main/java/org/restlet/engine/connector/HttpServerHelper.java
@@ -10,32 +10,35 @@
import java.util.Arrays;
import java.util.List;
-import org.eclipse.jetty.http2.server.HTTP2CServerConnectionFactory;
-import org.eclipse.jetty.server.ConnectionFactory;
-import org.eclipse.jetty.server.Connector;
-import org.eclipse.jetty.server.HttpConfiguration;
-import org.eclipse.jetty.server.HttpConnectionFactory;
import org.restlet.Server;
import org.restlet.data.Protocol;
+import org.restlet.engine.netty.NettyServerHelper;
/**
- * Jetty HTTP server connector.
+ * Netty HTTP server connector. Here is the list of additional parameters that are supported. See
+ * {@link NettyServerHelper} for the common ones.
*
*
*
list of supported parameters
*
- *
http.transport.mode
- *
string
+ *
Parameter name
+ *
Value type
+ *
Default value
+ *
Description
+ *
+ *
+ *
http.transport.protocol
+ *
String
*
HTTP1_1
- *
Supported protocol. Values: HTTP1_1 or HTTP2. The protocol HTTP 1.1 is always supported, the support of HTTP2 (in
- * clear text mode) is done thanks to upgrade from HTTP 1.1 protocol.
+ *
Supported protocol. Values: HTTP1_1 or HTTP2. HTTP/1.1 is always supported. With HTTP2,
+ * cleartext HTTP/2 (h2c) is also accepted, through the HTTP/1.1 "Upgrade" mechanism or with prior
+ * knowledge.
*
*
*
* @author Jerome Louvel
- * @author Tal Liron
*/
-public class HttpServerHelper extends JettyServerHelper {
+public class HttpServerHelper extends NettyServerHelper {
/**
* Constructor.
@@ -47,30 +50,22 @@ public HttpServerHelper(Server server) {
getProtocols().add(Protocol.HTTP);
}
- /**
- * Create and configure the Jetty HTTP connector
- *
- * @param configuration The HTTP configuration.
- */
@Override
- protected ConnectionFactory[] createConnectionFactories(final HttpConfiguration configuration) {
- HttpTransportProtocol httpTransportProtocol =
- HttpTransportProtocol.fromName(getHttpTransportProtocol());
+ protected void validateConfiguration() {
+ HttpTransportProtocol.fromName(getHttpTransportProtocol());
+ }
- return switch (httpTransportProtocol) {
- case HTTP1_1 -> new ConnectionFactory[] {new HttpConnectionFactory(configuration)};
- case HTTP2 ->
- new ConnectionFactory[] {
- new HttpConnectionFactory(
- configuration), // still necessary to support protocol upgrade
- new HTTP2CServerConnectionFactory(configuration)
- };
+ @Override
+ protected List getApplicationProtocols() {
+ return switch (HttpTransportProtocol.fromName(getHttpTransportProtocol())) {
+ case HTTP1_1 -> List.of(HTTP_1_1);
+ case HTTP2 -> List.of(HTTP_1_1, HTTP_2);
};
}
@Override
- protected List createConnectors(org.eclipse.jetty.server.Server server) {
- return List.of(createServerConnector(server, createHttpConfiguration()));
+ protected boolean isConfidential() {
+ return false;
}
/**
@@ -91,16 +86,11 @@ private enum HttpTransportProtocol {
static HttpTransportProtocol fromName(final String name) {
try {
return HttpTransportProtocol.valueOf(name);
- } catch (final IllegalArgumentException iae) {
- String supportedHttpTransportProtocols =
- Arrays.toString(HttpTransportProtocol.values());
-
- final String errorMessage =
+ } catch (final IllegalArgumentException | NullPointerException _) {
+ throw new IllegalArgumentException(
String.format(
"'%s' is not one of the supported values: %s",
- name, supportedHttpTransportProtocols);
-
- throw new IllegalArgumentException(errorMessage);
+ name, Arrays.toString(HttpTransportProtocol.values())));
}
}
}
diff --git a/org.restlet/src/main/java/org/restlet/engine/connector/HttpsServerHelper.java b/org.restlet/src/main/java/org/restlet/engine/connector/HttpsServerHelper.java
index 4580fc8d61..157b859502 100644
--- a/org.restlet/src/main/java/org/restlet/engine/connector/HttpsServerHelper.java
+++ b/org.restlet/src/main/java/org/restlet/engine/connector/HttpsServerHelper.java
@@ -8,33 +8,17 @@
*/
package org.restlet.engine.connector;
-import java.nio.file.Path;
-import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
-import java.util.Optional;
-import java.util.logging.Level;
-import org.eclipse.jetty.alpn.server.ALPNServerConnectionFactory;
-import org.eclipse.jetty.http2.server.HTTP2ServerConnectionFactory;
-import org.eclipse.jetty.http3.server.HTTP3ServerConnectionFactory;
-import org.eclipse.jetty.quic.server.QuicServerConnector;
-import org.eclipse.jetty.quic.server.ServerQuicConfiguration;
-import org.eclipse.jetty.server.AbstractConnectionFactory;
-import org.eclipse.jetty.server.ConnectionFactory;
-import org.eclipse.jetty.server.Connector;
-import org.eclipse.jetty.server.HttpConfiguration;
-import org.eclipse.jetty.server.HttpConnectionFactory;
-import org.eclipse.jetty.server.ServerConnector;
-import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.restlet.Server;
import org.restlet.data.Protocol;
-import org.restlet.engine.security.RestletSslContextFactoryServer;
+import org.restlet.engine.netty.NettyServerHelper;
import org.restlet.engine.ssl.DefaultSslContextFactory;
-import org.restlet.engine.ssl.SslUtils;
+import org.restlet.engine.ssl.SslContextFactory;
/**
- * Jetty HTTPS server connector. Here is the list of additional parameters that are supported. They
- * should be set in the Server's context before it is started:
+ * Netty HTTPS server connector. Here is the list of additional parameters that are supported. See
+ * {@link NettyServerHelper} for the common ones.
*
*
*
list of supported parameters
@@ -48,32 +32,27 @@
*
sslContextFactory
*
String
*
org.restlet.engine.ssl.DefaultSslContextFactory
- *
Let you specify a {@link SslContextFactory} qualified class name as a parameter, or an instance as an attribute
- * for a more complete and flexible SSL context setting
+ *
Let you specify a {@link SslContextFactory} qualified class name as a parameter, or an
+ * instance as an attribute for a more complete and flexible SSL context setting
*
*
*
http.transport.protocols
- *
string
+ *
String
*
HTTP1_1
- *
Comma separated and sorted list of supported protocols. Available values: HTTP1_1, HTTP2, HTTP3.
- *
- *
- *
http3.pem.workdir
- *
string
- *
No default value
- *
Directory where are exported trusted certificates, required for HTTP3 support. There is no default value to let
- * you configure a secured enough directory.
+ *
Comma separated and ordered list of supported protocols. Available values: HTTP1_1, HTTP2
+ * (both negotiated with ALPN on TCP) and HTTP3. HTTP3 is experimental: it is served over
+ * QUIC on the UDP port having the same number as the TCP port, advertised to clients with the
+ * "Alt-Svc" response header, and requires the optional {@code io.netty:netty-codec-http3}
+ * dependency with its native QUIC library. It must be combined with HTTP1_1 or HTTP2. See the
+ * limits in {@code org.restlet.engine.netty.Http3Support}.
*
*
*
* For the default SSL parameters see the Javadocs of the {@link DefaultSslContextFactory} class.
*
- * @see Configure
- * SSL for Jetty
* @author Jerome Louvel
- * @author Tal Liron
*/
-public class HttpsServerHelper extends JettyServerHelper {
+public class HttpsServerHelper extends NettyServerHelper {
/**
* Constructor.
@@ -86,77 +65,27 @@ public HttpsServerHelper(Server server) {
}
@Override
- protected List createConnectors(org.eclipse.jetty.server.Server server) {
- final List result = new ArrayList<>();
-
- final List httpTransportProtocols =
- getHttpTransportProtocols().stream().map(HttpTransportProtocol::fromName).toList();
-
- if (httpTransportProtocols.stream().anyMatch(HttpTransportProtocol::isTcpProtocol)) {
- HttpConfiguration configuration = createHttpConfiguration();
- ServerConnector connector = createServerConnector(server, configuration);
- result.add(connector);
- } else if (httpTransportProtocols.contains(HttpTransportProtocol.HTTP3)) {
- ServerQuicConfiguration configuration =
- createQuicConfiguration(getQuicServerSslContextFactory());
- QuicServerConnector connector = createQuicServerConnector(server, configuration);
- result.add(connector);
- }
-
- return result;
+ protected void validateConfiguration() {
+ getApplicationProtocols();
}
@Override
- protected ConnectionFactory[] createConnectionFactories(final HttpConfiguration configuration) {
- final List connectionFactories = new ArrayList<>();
-
- final List tcpBasedTransportProtocols =
- getHttpTransportProtocols().stream()
- .map(HttpTransportProtocol::fromName)
- .filter(HttpTransportProtocol::isTcpProtocol)
- .toList();
-
- for (HttpTransportProtocol tcpBasedTransportProtocol : tcpBasedTransportProtocols) {
- final List protocolConnectionFactories =
- switch (tcpBasedTransportProtocol) {
- case HTTP1_1 -> List.of(new HttpConnectionFactory(configuration));
- case HTTP2 ->
- List.of(
- new ALPNServerConnectionFactory(),
- new HTTP2ServerConnectionFactory(configuration));
- default -> {
- String supportedHttpTransportProtocols =
- tcpBasedTransportProtocols.toString();
- final String errorMessage =
- String.format(
- "'%s' is not one of the supported values: %s",
- tcpBasedTransportProtocol,
- supportedHttpTransportProtocols);
- throw new IllegalArgumentException(errorMessage);
- }
- };
- connectionFactories.addAll(protocolConnectionFactories);
- }
-
- SslContextFactory.Server sslContextFactory = getServerSslContextFactory();
-
- return AbstractConnectionFactory.getFactories(
- sslContextFactory, connectionFactories.toArray(new ConnectionFactory[0]));
+ protected List getApplicationProtocols() {
+ return getHttpTransportProtocols().stream()
+ .map(HttpTransportProtocol::fromName)
+ .map(
+ p ->
+ switch (p) {
+ case HTTP1_1 -> HTTP_1_1;
+ case HTTP2 -> HTTP_2;
+ case HTTP3 -> HTTP_3;
+ })
+ .toList();
}
- private QuicServerConnector createQuicServerConnector(
- org.eclipse.jetty.server.Server server, ServerQuicConfiguration configuration) {
- QuicServerConnector connector =
- new QuicServerConnector(
- server, configuration, new HTTP3ServerConnectionFactory(configuration));
- final String address = getHelped().getAddress();
- if (address != null) {
- connector.setHost(address);
- }
- connector.setPort(getHelped().getPort());
- connector.setIdleTimeout(getConnectorIdleTimeout());
- connector.setShutdownIdleTimeout(getShutdownTimeout());
- return connector;
+ @Override
+ protected boolean isConfidential() {
+ return true;
}
/**
@@ -165,123 +94,28 @@ private QuicServerConnector createQuicServerConnector(
* @return Supported HTTP transport protocols.
*/
public List getHttpTransportProtocols() {
- String httpTransportProtocolsAsString =
+ String value =
getHelpedParameters()
.getFirstValue(
"http.transport.protocols", HttpTransportProtocol.HTTP1_1.name());
- return Arrays.stream(httpTransportProtocolsAsString.split(","))
- .map(String::trim)
- .distinct()
- .toList();
- }
-
- /**
- * Directory where are extracted the supported certificates.
- *
- * @return Directory where are extracted the supported certificates.
- */
- public String getHttp3PemWorkDir() {
- return getHelpedParameters().getFirstValue("http3.pem.workdir");
- }
-
- private Path getHttp3PemWorkDirectoryPath() {
- return Optional.ofNullable(getHttp3PemWorkDir()).map(Path::of).orElse(null);
- }
-
- private SslContextFactory.Server getServerSslContextFactory() {
- try {
- return new RestletSslContextFactoryServer(SslUtils.getSslContextFactory(this));
- } catch (Exception e) {
- getLogger().log(Level.WARNING, "Unable to create the Jetty SSL context factory", e);
- throw new IllegalArgumentException("Unable to create the Jetty SSL context factory", e);
- }
- }
-
- private SslContextFactory.Server getQuicServerSslContextFactory() {
- SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
-
- sslContextFactory.setKeyStorePassword(
- getHelpedParameters()
- .getFirstValue(
- "keyStorePassword",
- true,
- System.getProperty("javax.net.ssl.keyStorePassword", "")));
- sslContextFactory.setKeyStorePath(
- getHelpedParameters()
- .getFirstValue(
- "keyStorePath",
- true,
- System.getProperty("javax.net.ssl.keyStore")));
- sslContextFactory.setKeyStoreType(
- getHelpedParameters()
- .getFirstValue(
- "keyStoreType",
- true,
- System.getProperty("javax.net.ssl.keyStoreType")));
- sslContextFactory.setProtocol(getHelpedParameters().getFirstValue("protocol", true, "TLS"));
- sslContextFactory.setSecureRandomAlgorithm(
- getHelpedParameters().getFirstValue("secureRandomAlgorithm", true));
- sslContextFactory.setTrustStorePassword(
- getHelpedParameters()
- .getFirstValue(
- "trustStorePassword",
- true,
- System.getProperty("javax.net.ssl.trustStorePassword")));
- sslContextFactory.setTrustStorePath(
- getHelpedParameters()
- .getFirstValue(
- "trustStorePath",
- true,
- System.getProperty("javax.net.ssl.trustStore")));
- sslContextFactory.setTrustStoreType(
- getHelpedParameters()
- .getFirstValue(
- "trustStoreType",
- true,
- System.getProperty("javax.net.ssl.trustStoreType")));
-
- return sslContextFactory;
- }
-
- private ServerQuicConfiguration createQuicConfiguration(
- SslContextFactory.Server sslContextFactory) {
- Path pemWorkDirectory = getHttp3PemWorkDirectoryPath();
- ServerQuicConfiguration configuration =
- new ServerQuicConfiguration(sslContextFactory, pemWorkDirectory);
- configuration.setOutputBufferSize(getHttpOutputBufferSize());
- return configuration;
+ return Arrays.stream(value.split(",", -1)).map(String::trim).distinct().toList();
}
/** Supported HTTP transport protocols. */
private enum HttpTransportProtocol {
- HTTP1_1(true),
- HTTP2(true),
- HTTP3(false);
-
- private final boolean tcpProtocol;
+ HTTP1_1,
+ HTTP2,
+ HTTP3;
static HttpTransportProtocol fromName(final String name) {
try {
return HttpTransportProtocol.valueOf(name);
- } catch (final IllegalArgumentException iae) {
- String supportedHttpTransportProtocols =
- Arrays.toString(HttpTransportProtocol.values());
-
- final String errorMessage =
+ } catch (final IllegalArgumentException | NullPointerException _) {
+ throw new IllegalArgumentException(
String.format(
"'%s' is not one of the supported values: %s",
- name, supportedHttpTransportProtocols);
-
- throw new IllegalArgumentException(errorMessage);
+ name, Arrays.toString(HttpTransportProtocol.values())));
}
}
-
- HttpTransportProtocol(boolean tcpProtocol) {
- this.tcpProtocol = tcpProtocol;
- }
-
- public boolean isTcpProtocol() {
- return tcpProtocol;
- }
}
}
diff --git a/org.restlet/src/main/java/org/restlet/engine/connector/JettyServerHelper.java b/org.restlet/src/main/java/org/restlet/engine/connector/JettyServerHelper.java
deleted file mode 100644
index 97be820164..0000000000
--- a/org.restlet/src/main/java/org/restlet/engine/connector/JettyServerHelper.java
+++ /dev/null
@@ -1,745 +0,0 @@
-/**
- * Copyright 2005-2026 Qlik
- *
- * 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.engine.connector;
-
-import java.net.Socket;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Objects;
-import java.util.concurrent.Executor;
-import org.eclipse.jetty.io.ArrayByteBufferPool;
-import org.eclipse.jetty.io.ByteBufferPool;
-import org.eclipse.jetty.server.AbstractNetworkConnector;
-import org.eclipse.jetty.server.ConnectionFactory;
-import org.eclipse.jetty.server.Connector;
-import org.eclipse.jetty.server.Handler;
-import org.eclipse.jetty.server.HttpConfiguration;
-import org.eclipse.jetty.server.LowResourceMonitor;
-import org.eclipse.jetty.server.NetworkConnectionLimit;
-import org.eclipse.jetty.server.Request;
-import org.eclipse.jetty.server.Response;
-import org.eclipse.jetty.server.ServerConnector;
-import org.eclipse.jetty.server.handler.StatisticsHandler;
-import org.eclipse.jetty.util.Callback;
-import org.eclipse.jetty.util.thread.QueuedThreadPool;
-import org.eclipse.jetty.util.thread.ScheduledExecutorScheduler;
-import org.eclipse.jetty.util.thread.Scheduler;
-import org.eclipse.jetty.util.thread.ThreadPool;
-import org.restlet.Server;
-import org.restlet.engine.adapter.JettyServerCall;
-
-/**
- * Abstract Jetty web server connector. Here is the list of parameters that are supported. They
- * should be set in the Server's context before it is started:
- *
- *
- *
list of supported parameters
- *
- *
Parameter name
- *
Value type
- *
Default value
- *
Description
- *
- *
- *
threadPool.minThreads
- *
int
- *
8
- *
Thread pool minimum threads
- *
- *
- *
threadPool.maxThreads
- *
int
- *
200
- *
Thread pool max threads
- *
- *
- *
threadPool.threadsPriority
- *
int
- *
{@link Thread#NORM_PRIORITY}
- *
Thread pool threads priority
- *
- *
- *
threadPool.idleTimeout
- *
int
- *
60000
- *
Thread pool idle timeout in milliseconds; threads that are idle for
- * longer than this period may be stopped
- *
- *
- *
threadPool.stopTimeout
- *
long
- *
5000
- *
Thread pool stop timeout in milliseconds; the maximum time allowed for
- * the service to shutdown
- *
- *
- *
connector.acceptors
- *
int
- *
-1
- *
Connector acceptor thread count; when -1, Jetty will default to 1
- *
- *
- *
connector.selectors
- *
int
- *
-1
- *
Connector selector thread count; When less or equal than 0, Jetty
- * computes a default value derived from a heuristic over available CPUs and
- * thread pool size.
- *
- *
- *
connector.acceptQueueSize
- *
int
- *
0
- *
Connector accept queue size; also known as accept backlog
- *
- *
- *
connector.idleTimeout
- *
int
- *
30000
- *
Connector idle timeout in milliseconds; see
- * {@link Socket#setSoTimeout(int)}; this value is interpreted as the maximum
- * time between some progress being made on the connection; so if a single byte
- * is read or written, then the timeout is reset
- *
- *
- *
connector.stopTimeout
- *
long
- *
30000
- *
Connector stop timeout in milliseconds; the maximum time allowed for the
- * service to shutdown
- *
- *
- *
http.headerCacheSize
- *
int
- *
512
- *
HTTP header cache size in bytes
- *
- *
- *
http.requestHeaderSize
- *
int
- *
8*1024
- *
HTTP request header size in bytes; larger headers will allow for more
- * and/or larger cookies plus larger form content encoded in a URL; however,
- * larger headers consume more memory and can make a server more vulnerable to
- * denial of service attacks
- *
- *
- *
http.responseHeaderSize
- *
int
- *
8*1024
- *
HTTP response header size in bytes; larger headers will allow for more
- * and/or larger cookies and longer HTTP headers (e.g., for redirection);
- * however, larger headers will also consume more memory
- *
- *
- *
http.outputBufferSize
- *
int
- *
32*1024
- *
HTTP output buffer size in bytes; a larger buffer can improve performance
- * by allowing a content producer to run without blocking, however larger
- * buffers consume more memory and may induce some latency before a client
- * starts processing the content
- *
- *
- *
lowResource.period
- *
int
- *
1000
- *
Low-resource monitor period in milliseconds; when 0, low-resource
- * monitoring is disabled
- *
- *
- *
lowResource.threads
- *
boolean
- *
true
- *
Low-resource monitor, whether to check if we're low on threads
- *
- *
- *
lowResource.maxMemory
- *
int
- *
0
- *
Low-resource monitor max memory in bytes; when 0, the check disabled;
- * memory used is calculated as (totalMemory-freeMemory)
- *
- *
- *
lowResource.idleTimeout
- *
int
- *
1000
- *
Low-resource monitor idle timeout in milliseconds; applied to EndPoints
- * when in the low-resources state
- *
- *
- *
server.maxConnections
- *
int
- *
0
- *
Server max connections; when 0, there is no limit
- *
- *
- *
server.maxConnections.idleTimeout
- *
long
- *
0
- *
The endpoint idle timeout in milliseconds to apply when the connection
- * limit is reached; when 0, there is no idle timeout.
- *
- *
- *
shutdown.gracefully
- *
boolean
- *
true
- *
When true, upon JVM shutdown, the Jetty server will block incoming
- * requests and wait for pending requests to end before shutting down.
- * Otherwise, incoming requests are not blocked and all requests are
- * aborted.
- *
- *
- *
shutdown.timeout
- *
long
- *
30000
- *
Server shutdown timeout in milliseconds. Defaults to 30000.
- *
- *
- *
- * @see Jetty 12 documentation
- * @author Jerome Louvel
- * @author Tal Liron
- */
-public abstract class JettyServerHelper extends org.restlet.engine.adapter.HttpServerHelper {
-
- /** The wrapped Jetty server. */
- private volatile org.eclipse.jetty.server.Server wrappedServer;
-
- /**
- * Constructor.
- *
- * @param server The server to help.
- */
- protected JettyServerHelper(Server server) {
- super(server);
- }
-
- /**
- * Creates a Jetty HTTP configuration.
- *
- * @return A Jetty HTTP configuration.
- */
- protected HttpConfiguration createHttpConfiguration() {
- final HttpConfiguration configuration = new HttpConfiguration();
- configuration.setHeaderCacheSize(getHttpHeaderCacheSize());
- configuration.setRequestHeaderSize(getHttpRequestHeaderSize());
- configuration.setResponseHeaderSize(getHttpResponseHeaderSize());
- configuration.setOutputBufferSize(getHttpOutputBufferSize());
-
- // ask Jetty connector to let us handling the Date header,
- // otherwise two Date headers are generated in the request
- configuration.setSendDateHeader(false);
-
- return configuration;
- }
-
- /**
- * Creates new internal Jetty connection factories.
- *
- * @param configuration The HTTP configuration.
- * @return New internal Jetty connection factories.
- */
- protected abstract ConnectionFactory[] createConnectionFactories(
- final HttpConfiguration configuration);
-
- /**
- * Creates the Jetty connectors.
- *
- * @param server The Jetty server.
- * @return The Jetty connectors.
- */
- protected abstract List createConnectors(org.eclipse.jetty.server.Server server);
-
- /**
- * Creates a Jetty connector based on a classical TCP type of transport.
- *
- * @param server The Jetty server.
- * @return A Jetty connector.
- */
- protected ServerConnector createServerConnector(
- final org.eclipse.jetty.server.Server server, final HttpConfiguration configuration) {
- final int acceptors = getConnectorAcceptors();
- final int selectors = getConnectorSelectors();
- final Executor executor = getConnectorExecutor();
- final Scheduler scheduler = getConnectorScheduler();
- final ByteBufferPool byteBufferPool = getConnectorByteBufferPool();
-
- final ConnectionFactory[] connectionFactories = createConnectionFactories(configuration);
-
- final ServerConnector connector =
- new ServerConnector(
- server,
- executor,
- scheduler,
- byteBufferPool,
- acceptors,
- selectors,
- connectionFactories);
-
- final String address = getHelped().getAddress();
- if (address != null) {
- connector.setHost(address);
- }
- connector.setPort(getHelped().getPort());
- connector.setIdleTimeout(getConnectorIdleTimeout());
- connector.setShutdownIdleTimeout(getShutdownTimeout());
-
- connector.setAcceptQueueSize(getConnectorAcceptQueueSize());
- return connector;
- }
-
- /**
- * Creates a Jetty low-resource monitor.
- *
- * @param server A Jetty server.
- * @return A Jetty low-resource monitor or null.
- */
- private LowResourceMonitor createLowResourceMonitor(org.eclipse.jetty.server.Server server) {
- final LowResourceMonitor result;
-
- final int period = getLowResourceMonitorPeriod();
- if (period > 0) {
- result = new LowResourceMonitor(server);
- result.setMonitoredConnectors(Arrays.asList(server.getConnectors()));
- result.setPeriod(period);
- result.setMonitorThreads(getLowResourceMonitorThreads());
- result.setMaxMemory(getLowResourceMonitorMaxMemory());
- result.setLowResourcesIdleTimeout(getLowResourceMonitorIdleTimeout());
- } else {
- result = null;
- }
-
- return result;
- }
-
- /**
- * Creates a Jetty server.
- *
- * @return A Jetty server.
- */
- private org.eclipse.jetty.server.Server createServer() {
- // Thread pool
- final ThreadPool threadPool = createThreadPool();
-
- // Server
- final org.eclipse.jetty.server.Server jettyServer =
- new org.eclipse.jetty.server.Server(threadPool);
-
- int serverMaxConnections = getServerMaxConnections();
- if (serverMaxConnections > 0) {
- NetworkConnectionLimit connectionLimit =
- new NetworkConnectionLimit(serverMaxConnections, jettyServer);
- connectionLimit.setEndPointIdleTimeout(getServerMaxConnectionsIdleTimeout());
- jettyServer.addBean(connectionLimit);
- }
-
- jettyServer.setStopAtShutdown(getShutdownGracefully());
- if (getShutdownGracefully()) {
- jettyServer.setStopTimeout(getShutdownTimeout());
- } else {
- jettyServer.setStopTimeout(0);
- }
-
- jettyServer.setHandler(createJettyHandler());
-
- // Connectors
- createConnectors(jettyServer).forEach(jettyServer::addConnector);
-
- // Low-resource monitor (must be created after connectors have been added)
- LowResourceMonitor lowResourceMonitor = createLowResourceMonitor(jettyServer);
- jettyServer.addBean(lowResourceMonitor);
-
- return jettyServer;
- }
-
- /**
- * Creates the Jetty handler that wraps the {@link JettyServerHelper}.
- *
- * @return the Jetty handler that wraps the {@link JettyServerHelper}.
- */
- private Handler.Abstract createJettyHandler() {
- final Handler.Abstract result;
-
- final JettyServerHelper jettyServerHelper = this;
- Handler.Abstract jettyServerHelperWrapperHandler =
- new Handler.Abstract() {
- @Override
- public boolean handle(Request request, Response response, Callback callback) {
- JettyServerCall httpCall =
- new JettyServerCall(
- jettyServerHelper.getHelped(), request, response, callback);
- jettyServerHelper.handle(httpCall);
- return true; // Indicates that the request is accepted
- }
- };
-
- if (getShutdownGracefully()) {
- // StatisticsHandler for graceful shutdown
- final StatisticsHandler statisticsHandler = new StatisticsHandler();
- statisticsHandler.setHandler(jettyServerHelperWrapperHandler);
- result = statisticsHandler;
- } else {
- result = jettyServerHelperWrapperHandler;
- }
-
- return result;
- }
-
- /**
- * Creates a Jetty thread pool.
- *
- * @return A Jetty thread pool.
- */
- private ThreadPool createThreadPool() {
- final QueuedThreadPool threadPool = new QueuedThreadPool();
- threadPool.setMinThreads(getThreadPoolMinThreads());
- threadPool.setMaxThreads(getThreadPoolMaxThreads());
- threadPool.setThreadsPriority(getThreadPoolThreadsPriority());
- threadPool.setIdleTimeout(getThreadPoolIdleTimeout());
- if (getShutdownGracefully()) {
- threadPool.setStopTimeout(getThreadPoolStopTimeout());
- } else {
- threadPool.setStopTimeout(0); // The thread pool stops immediately.
- }
-
- return threadPool;
- }
-
- /**
- * Connector acceptor thread count. Defaults to -1. When -1, Jetty will default to 1.
- *
- * @return Connector acceptor thread count.
- */
- public int getConnectorAcceptors() {
- return Integer.parseInt(getHelpedParameters().getFirstValue("connector.acceptors", "-1"));
- }
-
- /**
- * Connector "accept" queue size. Defaults to 0.
- *
- *
Also known as "accept" backlog.
- *
- * @return Connector accept queue size.
- */
- public int getConnectorAcceptQueueSize() {
- return Integer.parseInt(
- getHelpedParameters().getFirstValue("connector.acceptQueueSize", "0"));
- }
-
- /**
- * Connector byte buffer pool. Defaults to null. When null, will use a new {@link
- * ArrayByteBufferPool}.
- *
- * @return Connector byte buffer pool or null.
- */
- public ByteBufferPool getConnectorByteBufferPool() {
- return null;
- }
-
- /**
- * Connector executor. Defaults to null. When null, will use the server's thread pool.
- *
- * @return Connector executor or null.
- */
- public Executor getConnectorExecutor() {
- return null;
- }
-
- /**
- * Connector idle timeout in milliseconds. Defaults to 30000.
- *
- *
See {@link Socket#setSoTimeout(int)}.
- *
- *
This value is interpreted as the maximum time between some progress being made on the
- * connection. So if a single byte is read or written, then the timeout is reset.
- *
- * @return Connector idle timeout.
- */
- public int getConnectorIdleTimeout() {
- return Integer.parseInt(
- getHelpedParameters().getFirstValue("connector.idleTimeout", "30000"));
- }
-
- /**
- * Connector scheduler. Defaults to null. When null, will use a new {@link
- * ScheduledExecutorScheduler}.
- *
- * @return Connector scheduler or null.
- */
- public Scheduler getConnectorScheduler() {
- return null;
- }
-
- /**
- * Connector selector thread count. Defaults to -1. When less or equal than 0, Jetty computes a
- * default value derived from a heuristic over available CPUs and thread pool size.
- *
- * @return Connector acceptor thread count.
- */
- public int getConnectorSelectors() {
- return Integer.parseInt(getHelpedParameters().getFirstValue("connector.selectors", "-1"));
- }
-
- /**
- * HTTP header cache size in bytes. Defaults to 512.
- *
- * @return HTTP header cache size.
- */
- public int getHttpHeaderCacheSize() {
- return Integer.parseInt(
- getHelpedParameters().getFirstValue("http.headerCacheSize", "1024"));
- }
-
- /**
- * HTTP output buffer size in bytes. Defaults to 32*1024.
- *
- *
A larger buffer can improve performance by allowing a content producer to run without
- * blocking, however, larger buffers consume more memory and may induce some latency before a
- * client starts processing the content.
- *
- * @return HTTP output buffer size.
- */
- public int getHttpOutputBufferSize() {
- return Integer.parseInt(
- getHelpedParameters().getFirstValue("http.outputBufferSize", "32768"));
- }
-
- /**
- * HTTP request header size in bytes. Defaults to 8*1024.
- *
- *
Larger headers will allow for more and/or larger cookies plus larger form content encoded
- * in a URL. However, larger headers consume more memory and can make a server more vulnerable
- * to denial of service attacks.
- *
- * @return HTTP request header size.
- */
- public int getHttpRequestHeaderSize() {
- return Integer.parseInt(
- getHelpedParameters().getFirstValue("http.requestHeaderSize", "8192"));
- }
-
- /**
- * HTTP response header size in bytes. Defaults to 8*1024.
- *
- *
Larger headers will allow for more and/or larger cookies and longer HTTP headers (e.g.,
- * for redirection). However, larger headers will also consume more memory.
- *
- * @return HTTP response header size.
- */
- public int getHttpResponseHeaderSize() {
- return Integer.parseInt(
- getHelpedParameters().getFirstValue("http.responseHeaderSize", "8192"));
- }
-
- /**
- * Low-resource monitor idle timeout in milliseconds. Defaults to 1000.
- *
- *
Applied to EndPoints when in the low-resources state.
- *
- * @return Low-resource monitor idle timeout.
- */
- public int getLowResourceMonitorIdleTimeout() {
- return Integer.parseInt(
- getHelpedParameters().getFirstValue("lowResource.idleTimeout", "1000"));
- }
-
- /**
- * Low-resource monitor max memory in bytes. Defaults to 0. When 0, the check is disabled.
- *
- *
Memory used is calculated as (totalMemory-freeMemory).
- *
- * @return Low-resource monitor max memory.
- */
- public long getLowResourceMonitorMaxMemory() {
- return Long.parseLong(getHelpedParameters().getFirstValue("lowResource.maxMemory", "0"));
- }
-
- /**
- * Low-resource monitor period in milliseconds. Defaults to 1000. When 0, low-resource
- * monitoring is disabled.
- *
- * @return Low-resource monitor period.
- */
- public int getLowResourceMonitorPeriod() {
- return Integer.parseInt(getHelpedParameters().getFirstValue("lowResource.period", "1000"));
- }
-
- /**
- * Low-resource monitor, whether to check if we're low on threads. Defaults to true.
- *
- * @return Low-resource monitor threads.
- */
- public boolean getLowResourceMonitorThreads() {
- return Boolean.parseBoolean(
- getHelpedParameters().getFirstValue("lowResource.threads", "true"));
- }
-
- /**
- * Server max connections. Defaults to 0. When 0, the check is disabled.
- *
- * @return Low-resource monitor max connections.
- */
- public int getServerMaxConnections() {
- return Integer.parseInt(getHelpedParameters().getFirstValue("server.maxConnections", "0"));
- }
-
- /**
- * The endpoint idle timeout in milliseconds to apply when the connection limit is reached.
- * Defaults to 0. When 0, there is no idle timeout.
- *
- *
The maximum time allowed for the endpoint to close when the connection limit is reached.
- *
- * @return The endpoint idle timeout in milliseconds to apply when the connection limit is
- * reached.
- */
- public long getServerMaxConnectionsIdleTimeout() {
- return Long.parseLong(
- getHelpedParameters().getFirstValue("server.maxConnections.idleTimeout", "0"));
- }
-
- /**
- * When true, upon JVM shutdown, the Jetty server will block incoming requests and wait for
- * pending requests to end before shutting down. Otherwise, incoming requests are not blocked
- * and all requests are aborted. Defaults to true.
- *
- * @return True if upon JVM shutdown, the Jetty server will block incoming requests and wait for
- * pending requests to end before shutting down. Otherwise, incoming requests are not
- * blocked and all requests are aborted.
- */
- public boolean getShutdownGracefully() {
- return Boolean.parseBoolean(
- getHelpedParameters().getFirstValue("shutdown.gracefully", "true"));
- }
-
- /**
- * Server shutdown timeout in milliseconds. Defaults to 30000.
- *
- * @return Server shutdown timeout.
- */
- public long getShutdownTimeout() {
- return Long.parseLong(getHelpedParameters().getFirstValue("shutdown.timeout", "0"));
- }
-
- /**
- * Thread pool idle timeout in milliseconds. Defaults to 60000.
- *
- *
Threads that are idle for longer than this period may be stopped.
- *
- * @return Thread pool idle timeout.
- */
- public int getThreadPoolIdleTimeout() {
- return Integer.parseInt(
- getHelpedParameters().getFirstValue("threadPool.idleTimeout", "60000"));
- }
-
- /**
- * Thread pool maximum threads. Defaults to 200.
- *
- * @return Thread pool maximum threads.
- */
- public int getThreadPoolMaxThreads() {
- return Integer.parseInt(
- getHelpedParameters().getFirstValue("threadPool.maxThreads", "200"));
- }
-
- /**
- * Thread pool minimum threads. Defaults to 8.
- *
- * @return Thread pool minimum threads.
- */
- public int getThreadPoolMinThreads() {
- return Integer.parseInt(getHelpedParameters().getFirstValue("threadPool.minThreads", "8"));
- }
-
- /**
- * Thread pool stop timeout in milliseconds. Defaults to 5000.
- *
- *
The maximum time allowed for the service to shut down.
- *
- * @return Thread pool stop timeout.
- */
- public long getThreadPoolStopTimeout() {
- return Long.parseLong(
- getHelpedParameters().getFirstValue("threadPool.stopTimeout", "5000"));
- }
-
- /**
- * Thread pool threads priority. Defaults to {@link Thread#NORM_PRIORITY}.
- *
- * @return Thread pool maximum threads.
- */
- public int getThreadPoolThreadsPriority() {
- return Integer.parseInt(
- getHelpedParameters()
- .getFirstValue(
- "threadPool.threadsPriority",
- String.valueOf(Thread.NORM_PRIORITY)));
- }
-
- /**
- * Returns the wrapped Jetty server.
- *
- * @return The wrapped Jetty server.
- */
- protected org.eclipse.jetty.server.Server getWrappedServer() {
- if (this.wrappedServer == null) {
- this.wrappedServer = createServer();
- }
- return this.wrappedServer;
- }
-
- /**
- * Sets the wrapped Jetty server.
- *
- * @param wrappedServer The wrapped Jetty server.
- */
- protected void setWrappedServer(org.eclipse.jetty.server.Server wrappedServer) {
- Objects.requireNonNull(wrappedServer);
- this.wrappedServer = wrappedServer;
- }
-
- @Override
- public void start() throws Exception {
- super.start();
- org.eclipse.jetty.server.Server server = getWrappedServer();
- AbstractNetworkConnector connector = (AbstractNetworkConnector) server.getConnectors()[0];
-
- getLogger()
- .info(
- "Starting the Jetty "
- + getProtocols()
- + " server on port "
- + getHelped().getPort());
- try {
- server.start();
- // We won't know the local port until after the server starts
- setEphemeralPort(connector.getLocalPort());
- } catch (Exception e) {
- // Make sure that all resources are released, otherwise thread-pool
- // may still be running.
- server.stop();
- throw e;
- }
- }
-
- @Override
- public synchronized void stop() throws Exception {
- getLogger()
- .info(
- "Stopping the Jetty "
- + getProtocols()
- + " server on port "
- + getHelped().getPort());
- if (this.wrappedServer != null) {
- getWrappedServer().stop();
- }
- super.stop();
- }
-}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/CallExecutors.java b/org.restlet/src/main/java/org/restlet/engine/netty/CallExecutors.java
new file mode 100644
index 0000000000..35599296b5
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/CallExecutors.java
@@ -0,0 +1,76 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Creates the executors that run Restlet calls off the Netty event loops. Restlet's programming
+ * model is blocking, so a call must run on a thread that may block: by default one virtual thread
+ * per call, or a bounded pool of platform threads when virtual threads are disabled.
+ *
+ *
This class is internal to the Netty connectors.
+ */
+final class CallExecutors {
+
+ private CallExecutors() {}
+
+ /**
+ * Creates an executor for Restlet calls.
+ *
+ * @param name The thread name prefix.
+ * @param virtual True for one virtual thread per task, false for a platform thread pool.
+ * @param minThreads Core size of the platform pool, ignored for virtual threads.
+ * @param maxThreads Maximum size of the platform pool, ignored for virtual threads.
+ * @param idleTimeoutMs Idle timeout of platform threads, ignored for virtual threads.
+ * @param priority Priority of platform threads, ignored for virtual threads.
+ * @return A new executor.
+ */
+ public static ExecutorService create(
+ String name,
+ boolean virtual,
+ int minThreads,
+ int maxThreads,
+ long idleTimeoutMs,
+ int priority) {
+ if (virtual) {
+ ThreadFactory factory = Thread.ofVirtual().name(name + "-vt-", 0).factory();
+ return Executors.newThreadPerTaskExecutor(factory);
+ }
+
+ int max = Math.max(1, maxThreads);
+ int core = Math.clamp(minThreads, 1, max);
+ AtomicInteger counter = new AtomicInteger();
+ ThreadFactory factory =
+ runnable -> {
+ Thread thread = new Thread(runnable, name + "-" + counter.incrementAndGet());
+ thread.setDaemon(true);
+ thread.setPriority(priority);
+ return thread;
+ };
+ // Grow to "max" threads before queueing: use a pool that never queues while it can grow,
+ // then queues the overflow.
+ ThreadPoolExecutor executor =
+ new ThreadPoolExecutor(
+ max,
+ max,
+ Math.max(1, idleTimeoutMs),
+ TimeUnit.MILLISECONDS,
+ new LinkedBlockingQueue<>(),
+ factory);
+ executor.allowCoreThreadTimeOut(core < max);
+ return executor;
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/ChannelInputStream.java b/org.restlet/src/main/java/org/restlet/engine/netty/ChannelInputStream.java
new file mode 100644
index 0000000000..5323a4f659
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/ChannelInputStream.java
@@ -0,0 +1,259 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.channel.Channel;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * Blocking input stream fed by a Netty event loop. The event loop offers content buffers; a
+ * blocking reader (typically a virtual thread running a Restlet call) consumes them.
+ *
+ *
Back-pressure: the channel is expected to run with auto-read disabled. {@link #offer(ByteBuf)}
+ * tells the event loop whether to read more; when more than {@link #HIGH_WATER_MARK} bytes are
+ * buffered, reading stops until the consumer drains below {@link #LOW_WATER_MARK}, at which point
+ * this stream requests the next read itself.
+ *
+ *
This class is internal to the Netty connectors.
+ */
+final class ChannelInputStream extends InputStream {
+
+ /** Buffered bytes above which channel reads are paused. */
+ static final int HIGH_WATER_MARK = 256 * 1024;
+
+ /** Buffered bytes below which channel reads are resumed. */
+ static final int LOW_WATER_MARK = 64 * 1024;
+
+ private final Channel channel;
+
+ private final long readTimeoutMs;
+
+ private final ReentrantLock lock = new ReentrantLock();
+
+ private final Condition available = lock.newCondition();
+
+ private final Deque buffers = new ArrayDeque<>();
+
+ private long bufferedBytes;
+
+ private boolean endOfStream;
+
+ private boolean closed;
+
+ private IOException failure;
+
+ private boolean readPaused;
+
+ /**
+ * Constructor.
+ *
+ * @param channel The channel to pause and resume for back-pressure, may be null.
+ * @param readTimeoutMs The maximum time to wait for data, 0 for no limit.
+ */
+ public ChannelInputStream(Channel channel, long readTimeoutMs) {
+ this.channel = channel;
+ this.readTimeoutMs = readTimeoutMs;
+ }
+
+ /**
+ * Offers a content buffer. Called on the event loop. Ownership of the buffer is transferred.
+ *
+ * @param buf The content buffer.
+ * @return True if the event loop should read more content now, false if reading must wait until
+ * the consumer catches up (this stream will then request the read).
+ */
+ public boolean offer(ByteBuf buf) {
+ lock.lock();
+ try {
+ if (closed || endOfStream || !buf.isReadable()) {
+ buf.release();
+ return !closed;
+ }
+ buffers.addLast(buf);
+ bufferedBytes += buf.readableBytes();
+ available.signalAll();
+ if (bufferedBytes > HIGH_WATER_MARK) {
+ readPaused = true;
+ return false;
+ }
+ return true;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /** Signals the end of the content. Called on the event loop. */
+ public void end() {
+ lock.lock();
+ try {
+ endOfStream = true;
+ available.signalAll();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Signals a failure, for example a closed connection. Called on the event loop.
+ *
+ * @param cause The failure cause.
+ */
+ public void fail(Throwable cause) {
+ lock.lock();
+ try {
+ if (!endOfStream) {
+ failure =
+ (cause instanceof IOException ioe)
+ ? ioe
+ : new IOException("Connection failure", cause);
+ available.signalAll();
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Indicates whether the whole content has been received and consumed.
+ *
+ * @return True if the whole content has been received and consumed.
+ */
+ public boolean isFullyConsumed() {
+ lock.lock();
+ try {
+ return endOfStream && buffers.isEmpty();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @Override
+ public int read() throws IOException {
+ byte[] one = new byte[1];
+ int n = read(one, 0, 1);
+ return (n == -1) ? -1 : (one[0] & 0xFF);
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ if (len == 0) {
+ return 0;
+ }
+ lock.lock();
+ try {
+ ByteBuf head = awaitData();
+ if (head == null) {
+ return -1;
+ }
+ int n = Math.min(len, head.readableBytes());
+ head.readBytes(b, off, n);
+ bufferedBytes -= n;
+ if (!head.isReadable()) {
+ buffers.removeFirst().release();
+ }
+ maybeResumeReads();
+ return n;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @Override
+ public int available() {
+ lock.lock();
+ try {
+ return (int) Math.min(Integer.MAX_VALUE, bufferedBytes);
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @Override
+ public void close() {
+ lock.lock();
+ try {
+ if (!closed) {
+ closed = true;
+ releaseAll();
+ maybeResumeReads();
+ available.signalAll();
+ }
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /** Must be called with the lock held. Returns the head buffer, or null at end of stream. */
+ private ByteBuf awaitData() throws IOException {
+ long remainingNanos =
+ (readTimeoutMs > 0) ? TimeUnit.MILLISECONDS.toNanos(readTimeoutMs) : Long.MAX_VALUE;
+ try {
+ // Condition waits stay in this loop, which rechecks the state after each wake-up
+ // (spurious wake-ups included).
+ while (buffers.isEmpty()) {
+ if (isEndOfStream()) {
+ return null;
+ }
+ if (readTimeoutMs <= 0) {
+ available.await();
+ } else if (remainingNanos > 0) {
+ remainingNanos = available.awaitNanos(remainingNanos);
+ } else {
+ throw new InterruptedIOException("Read timed out");
+ }
+ }
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ throw new InterruptedIOException("Interrupted while reading");
+ }
+ return buffers.peekFirst();
+ }
+
+ /**
+ * Must be called with the lock held, when no buffer is available. Checks whether no more data
+ * will come.
+ *
+ * @return True at the end of the stream.
+ * @throws IOException If the stream is closed or failed.
+ */
+ private boolean isEndOfStream() throws IOException {
+ if (closed) {
+ throw new IOException("Stream closed");
+ }
+ if (failure != null) {
+ throw failure;
+ }
+ return endOfStream;
+ }
+
+ /** Must be called with the lock held. */
+ private void maybeResumeReads() {
+ if (readPaused && bufferedBytes < LOW_WATER_MARK && channel != null) {
+ readPaused = false;
+ channel.read();
+ }
+ }
+
+ /** Must be called with the lock held. */
+ private void releaseAll() {
+ ByteBuf buf;
+ while ((buf = buffers.pollFirst()) != null) {
+ buf.release();
+ }
+ bufferedBytes = 0;
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/ChannelOutputStream.java b/org.restlet/src/main/java/org/restlet/engine/netty/ChannelOutputStream.java
new file mode 100644
index 0000000000..3945ee057a
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/ChannelOutputStream.java
@@ -0,0 +1,149 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelFuture;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.function.Function;
+
+/**
+ * Blocking output stream writing to a Netty channel. Bytes are buffered locally and written as
+ * content messages when the buffer is full or on {@link #flush()}.
+ *
+ *
Back-pressure: before each write, the calling thread waits until the channel is writable, so a
+ * slow peer slows down the producer instead of filling the memory.
+ *
+ *
Calling {@link #flush()} sends the buffered bytes immediately, which is what server-sent
+ * events rely on. A write to a closed channel fails with an {@link IOException}, so a producer
+ * notices when the peer has gone.
+ *
+ *
This class is internal to the Netty connectors.
+ */
+final class ChannelOutputStream extends OutputStream {
+
+ private final Channel channel;
+
+ private final Function contentFactory;
+
+ private final int bufferSize;
+
+ private final long writeTimeoutMs;
+
+ private ByteBuf buffer;
+
+ private boolean closed;
+
+ /**
+ * Constructor.
+ *
+ * @param channel The channel to write to.
+ * @param contentFactory Wraps a buffer into the message type the pipeline expects (for example
+ * an HTTP content message).
+ * @param bufferSize The local buffer size in bytes.
+ * @param writeTimeoutMs The maximum time to wait for the channel to become writable, 0 for no
+ * limit.
+ */
+ public ChannelOutputStream(
+ Channel channel,
+ Function contentFactory,
+ int bufferSize,
+ long writeTimeoutMs) {
+ this.channel = channel;
+ this.contentFactory = contentFactory;
+ this.bufferSize = Math.max(512, bufferSize);
+ this.writeTimeoutMs = writeTimeoutMs;
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ ensureOpen();
+ ensureBuffer();
+ buffer.writeByte(b);
+ if (buffer.writableBytes() == 0) {
+ send(false);
+ }
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ ensureOpen();
+ while (len > 0) {
+ ensureBuffer();
+ int n = Math.min(len, buffer.writableBytes());
+ buffer.writeBytes(b, off, n);
+ off += n;
+ len -= n;
+ if (buffer.writableBytes() == 0) {
+ send(false);
+ }
+ }
+ }
+
+ @Override
+ public void flush() throws IOException {
+ ensureOpen();
+ if (buffer != null && buffer.isReadable()) {
+ send(true);
+ } else {
+ channel.flush();
+ }
+ }
+
+ /**
+ * Writes the remaining buffered bytes without closing the underlying exchange. The caller is
+ * responsible for writing the last content message.
+ *
+ * @throws IOException If the channel is closed.
+ */
+ @Override
+ public void close() throws IOException {
+ if (!closed) {
+ try {
+ if (buffer != null && buffer.isReadable()) {
+ send(true);
+ }
+ } finally {
+ closed = true;
+ if (buffer != null) {
+ buffer.release();
+ buffer = null;
+ }
+ }
+ }
+ }
+
+ private void ensureBuffer() {
+ if (buffer == null) {
+ buffer = channel.alloc().buffer(bufferSize, bufferSize);
+ }
+ }
+
+ private void ensureOpen() throws IOException {
+ if (closed) {
+ throw new IOException("Stream closed");
+ }
+ if (!channel.isActive()) {
+ throw new IOException("Connection closed by peer");
+ }
+ }
+
+ private void send(boolean flush) throws IOException {
+ Writability.await(channel, writeTimeoutMs);
+ ByteBuf chunk = buffer;
+ buffer = null;
+ Object msg = contentFactory.apply(chunk);
+ ChannelFuture future = flush ? channel.writeAndFlush(msg) : channel.write(msg);
+ if (future.isDone() && !future.isSuccess()) {
+ throw new IOException("Unable to write to the connection", future.cause());
+ }
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/Http3Availability.java b/org.restlet/src/main/java/org/restlet/engine/netty/Http3Availability.java
new file mode 100644
index 0000000000..c9e2383083
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/Http3Availability.java
@@ -0,0 +1,38 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+/**
+ * Checks that the experimental HTTP/3 support can be used. This class doesn't reference the
+ * optional Netty QUIC and HTTP/3 classes, so it can report their absence with a clear message
+ * instead of a {@link NoClassDefFoundError}.
+ */
+final class Http3Availability {
+
+ private Http3Availability() {}
+
+ /**
+ * Checks that the optional Netty HTTP/3 classes are present and that the native QUIC library
+ * can be loaded.
+ *
+ * @throws IllegalStateException If HTTP/3 can't be used.
+ */
+ static void ensureAvailable() {
+ try {
+ Class.forName(
+ "io.netty.handler.codec.http3.Http3",
+ false,
+ Http3Availability.class.getClassLoader());
+ } catch (ClassNotFoundException | LinkageError e) {
+ throw new IllegalStateException(
+ "HTTP/3 requires the io.netty:netty-codec-http3 dependency", e);
+ }
+ Http3Support.ensureNativeLibrary();
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/Http3Support.java b/org.restlet/src/main/java/org/restlet/engine/netty/Http3Support.java
new file mode 100644
index 0000000000..3e94d2a1a0
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/Http3Support.java
@@ -0,0 +1,420 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+import io.netty.bootstrap.Bootstrap;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandler;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.ChannelOption;
+import io.netty.channel.EventLoopGroup;
+import io.netty.channel.socket.nio.NioDatagramChannel;
+import io.netty.handler.codec.http3.Http3;
+import io.netty.handler.codec.http3.Http3ClientConnectionHandler;
+import io.netty.handler.codec.http3.Http3FrameToHttpObjectCodec;
+import io.netty.handler.codec.http3.Http3RequestStreamInitializer;
+import io.netty.handler.codec.http3.Http3ServerConnectionHandler;
+import io.netty.handler.codec.quic.InsecureQuicTokenHandler;
+import io.netty.handler.codec.quic.Quic;
+import io.netty.handler.codec.quic.QuicChannel;
+import io.netty.handler.codec.quic.QuicSslContext;
+import io.netty.handler.codec.quic.QuicSslContextBuilder;
+import io.netty.handler.codec.quic.QuicStreamChannel;
+import io.netty.handler.ssl.ClientAuth;
+import io.netty.util.AttributeKey;
+import io.netty.util.concurrent.Future;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.TrustManagerFactory;
+import org.restlet.engine.ssl.DefaultSslContextFactory;
+import org.restlet.engine.ssl.SslContextFactory;
+
+/**
+ * Experimental HTTP/3 support, over QUIC. All the references to the Netty QUIC and HTTP/3 classes
+ * are kept in this class, so that the optional {@code io.netty:netty-codec-http3} dependency and
+ * its native QUIC library are only needed when HTTP/3 is enabled.
+ *
+ *
Limits of this experimental support:
+ *
+ *
+ *
It requires the native QUIC library of Netty, available for Linux, macOS (x86_64 and
+ * aarch64) and Windows (x86_64).
+ *
TLS is provided by BoringSSL, configured from the key and trust stores of the {@link
+ * DefaultSslContextFactory}. Custom {@link SslContextFactory} implementations, and the
+ * enabled cipher suites and protocols settings, are not supported.
+ *
The client only uses HTTP/3 when asked to ("httpClientTransportMode" set to HTTP3), for
+ * HTTPS URIs and without proxy. It doesn't discover HTTP/3 with the "Alt-Svc" header.
+ *
Server push, 0-RTT and connection migration are not supported.
+ *
QUIC address validation tokens are not cryptographically protected (Netty's insecure token
+ * handler), so the server doesn't resist address spoofing as well as a production QUIC server
+ * would.
+ *
HTTP/3 shares the TCP connector's limits ("server.maxConnections", idle timeout), but not
+ * its accept queue or "connector.acceptQueueSize".
+ *
+ */
+final class Http3Support {
+
+ /** The origin of a client connection, used for SNI and the server certificate check. */
+ private static final AttributeKey PEER =
+ AttributeKey.valueOf(Http3Support.class, "peer");
+
+ /** Default QUIC flow control windows. */
+ private static final long MAX_DATA = 16L * 1024 * 1024;
+
+ private static final long MAX_STREAM_DATA = 1024L * 1024;
+
+ private static final long MAX_STREAMS = 100;
+
+ private Http3Support() {}
+
+ /**
+ * Checks that the native QUIC library can be loaded. Call {@link
+ * Http3Availability#ensureAvailable()} instead, which first checks the presence of the optional
+ * classes.
+ *
+ * @throws IllegalStateException If the native QUIC library is not available.
+ */
+ static void ensureNativeLibrary() {
+ if (!Quic.isAvailable()) {
+ throw new IllegalStateException(
+ "HTTP/3 requires the native QUIC library of Netty, which is not available on"
+ + " this platform",
+ Quic.unavailabilityCause());
+ }
+ }
+
+ // ------------------------------------------------------------------ server
+
+ /**
+ * Binds an HTTP/3 server on a UDP port.
+ *
+ * @param group The event loop group.
+ * @param address The address to bind.
+ * @param factory The SSL context factory of the connector.
+ * @param idleTimeoutMs The connection idle timeout, 0 for none.
+ * @param admit Called with each new QUIC connection; returning false closes it.
+ * @param streamHandler Supplies the handler of each request stream.
+ * @return The bound UDP channel.
+ * @throws GeneralSecurityException If the TLS settings are invalid.
+ * @throws IOException If the key or trust store can't be read.
+ * @throws InterruptedException If interrupted while binding.
+ */
+ static Channel bindServer(
+ EventLoopGroup group,
+ InetSocketAddress address,
+ SslContextFactory factory,
+ long idleTimeoutMs,
+ final Predicate admit,
+ final Supplier streamHandler)
+ throws GeneralSecurityException, IOException, InterruptedException {
+ DefaultSslContextFactory defaultFactory = toDefaultFactory(factory);
+ KeyManagerFactory kmf = keyManagerFactory(defaultFactory);
+ if (kmf == null) {
+ throw new IllegalArgumentException("HTTP/3 requires a key store");
+ }
+
+ QuicSslContextBuilder builder =
+ QuicSslContextBuilder.forServer(kmf, null)
+ .applicationProtocols(Http3.supportedApplicationProtocols());
+ TrustManagerFactory tmf = trustManagerFactory(defaultFactory);
+ if (tmf != null) {
+ builder.trustManager(tmf);
+ }
+ if (defaultFactory.isNeedClientAuthentication()) {
+ builder.clientAuth(ClientAuth.REQUIRE);
+ } else if (defaultFactory.isWantClientAuthentication()) {
+ builder.clientAuth(ClientAuth.OPTIONAL);
+ }
+ QuicSslContext sslContext = builder.build();
+
+ final ChannelInitializer requestStreamInitializer =
+ new ChannelInitializer<>() {
+ @Override
+ protected void initChannel(QuicStreamChannel stream) {
+ stream.pipeline().addLast(new Http3FrameToHttpObjectCodec(true));
+ stream.pipeline().addLast(Writability.HANDLER);
+ stream.pipeline().addLast(streamHandler.get());
+ }
+ };
+
+ ChannelHandler codec =
+ configure(Http3.newQuicServerCodecBuilder(), idleTimeoutMs)
+ .sslContext(sslContext)
+ .tokenHandler(InsecureQuicTokenHandler.INSTANCE)
+ .handler(
+ new ChannelInitializer() {
+ @Override
+ protected void initChannel(QuicChannel connection) {
+ if (!admit.test(connection)) {
+ connection.close();
+ return;
+ }
+ connection
+ .pipeline()
+ .addLast(
+ new Http3ServerConnectionHandler(
+ requestStreamInitializer));
+ }
+ })
+ .build();
+
+ return new Bootstrap()
+ .group(group)
+ .channel(NioDatagramChannel.class)
+ .handler(codec)
+ .bind(address)
+ .sync()
+ .channel();
+ }
+
+ private static > B configure(
+ B builder, long idleTimeoutMs) {
+ if (idleTimeoutMs > 0) {
+ builder.maxIdleTimeout(idleTimeoutMs, TimeUnit.MILLISECONDS);
+ }
+ return builder.initialMaxData(MAX_DATA)
+ .initialMaxStreamDataBidirectionalLocal(MAX_STREAM_DATA)
+ .initialMaxStreamDataBidirectionalRemote(MAX_STREAM_DATA)
+ .initialMaxStreamsBidirectional(MAX_STREAMS);
+ }
+
+ /**
+ * Returns the UDP address of the peer of a QUIC connection.
+ *
+ * @param connection The QUIC connection.
+ * @return The UDP address of the peer.
+ */
+ static SocketAddress remoteSocketAddress(Channel connection) {
+ return ((QuicChannel) connection).remoteSocketAddress();
+ }
+
+ /**
+ * Returns the local UDP address of a QUIC connection.
+ *
+ * @param connection The QUIC connection.
+ * @return The local UDP address.
+ */
+ static SocketAddress localSocketAddress(Channel connection) {
+ return ((QuicChannel) connection).localSocketAddress();
+ }
+
+ /**
+ * Returns the TLS session of a QUIC connection.
+ *
+ * @param connection The QUIC connection.
+ * @return The TLS session, or null.
+ */
+ static SSLSession sslSession(Channel connection) {
+ return (connection instanceof QuicChannel quic && quic.sslEngine() != null)
+ ? quic.sslEngine().getSession()
+ : null;
+ }
+
+ // ------------------------------------------------------------------ client
+
+ /** HTTP/3 client: one UDP channel shared by the QUIC connections of a client connector. */
+ static final class Client {
+
+ private final Channel datagramChannel;
+
+ private Client(Channel datagramChannel) {
+ this.datagramChannel = datagramChannel;
+ }
+
+ /**
+ * Creates an HTTP/3 client.
+ *
+ * @param group The event loop group.
+ * @param factory The SSL context factory of the connector.
+ * @param hostnameVerification Whether to check the server certificate host name.
+ * @param idleTimeoutMs The connection idle timeout, 0 for none.
+ * @return The client.
+ * @throws GeneralSecurityException If the TLS settings are invalid.
+ * @throws IOException If the key or trust store can't be read.
+ * @throws InterruptedException If interrupted while binding.
+ */
+ static Client create(
+ EventLoopGroup group,
+ SslContextFactory factory,
+ boolean hostnameVerification,
+ long idleTimeoutMs)
+ throws GeneralSecurityException, IOException, InterruptedException {
+ DefaultSslContextFactory defaultFactory = toDefaultFactory(factory);
+ TrustManagerFactory tmf = trustManagerFactory(defaultFactory);
+ if (tmf == null) {
+ tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
+ tmf.init((KeyStore) null);
+ }
+
+ QuicSslContextBuilder builder =
+ QuicSslContextBuilder.forClient()
+ .trustManager(tmf)
+ .applicationProtocols(Http3.supportedApplicationProtocols());
+ KeyManagerFactory kmf = keyManagerFactory(defaultFactory);
+ if (kmf != null) {
+ builder.keyManager(kmf, null);
+ }
+ if (hostnameVerification) {
+ builder.endpointIdentificationAlgorithm("HTTPS");
+ }
+ final QuicSslContext sslContext = builder.build();
+
+ ChannelHandler codec =
+ configure(Http3.newQuicClientCodecBuilder(), idleTimeoutMs)
+ .sslEngineProvider(
+ connection -> {
+ InetSocketAddress peer = connection.attr(PEER).get();
+ return (peer == null)
+ ? sslContext.newEngine(connection.alloc())
+ : sslContext.newEngine(
+ connection.alloc(),
+ peer.getHostString(),
+ peer.getPort());
+ })
+ .build();
+
+ Channel channel =
+ new Bootstrap()
+ .group(group)
+ .channel(NioDatagramChannel.class)
+ .handler(codec)
+ .bind(0)
+ .sync()
+ .channel();
+ return new Client(channel);
+ }
+
+ /**
+ * Returns the UDP channel, so that the connector can close it on stop.
+ *
+ * @return The UDP channel.
+ */
+ Channel channel() {
+ return datagramChannel;
+ }
+
+ /**
+ * Opens a QUIC connection to an origin.
+ *
+ * @param host The host name.
+ * @param port The port.
+ * @param timeoutMs The connect timeout.
+ * @return The QUIC connection.
+ * @throws IOException If the connection fails.
+ */
+ Channel connect(String host, int port, long timeoutMs) throws IOException {
+ InetSocketAddress remote = new InetSocketAddress(host, port);
+ if (remote.isUnresolved()) {
+ throw new java.net.UnknownHostException(host);
+ }
+ Future future =
+ QuicChannel.newBootstrap(datagramChannel)
+ .attr(PEER, InetSocketAddress.createUnresolved(host, port))
+ .handler(new Http3ClientConnectionHandler())
+ .remoteAddress(remote)
+ .connect();
+ return NettyFutures.await(
+ future, timeoutMs, "Unable to connect with HTTP/3 to " + host + ":" + port);
+ }
+
+ /**
+ * Opens a request stream on a QUIC connection.
+ *
+ * @param connection The QUIC connection.
+ * @param handler The handler of the stream, added after the HTTP/3 to HTTP/1 codec.
+ * @param decompressor The response content decompressor, or null.
+ * @param timeoutMs The timeout.
+ * @return The request stream.
+ * @throws IOException If the stream can't be opened.
+ */
+ Channel openStream(
+ Channel connection,
+ final ChannelHandler handler,
+ final ChannelHandler decompressor,
+ long timeoutMs)
+ throws IOException {
+ Future future =
+ Http3.newRequestStreamBootstrap(
+ (QuicChannel) connection,
+ new Http3RequestStreamInitializer() {
+ @Override
+ protected void initRequestStream(QuicStreamChannel ch) {
+ ch.pipeline()
+ .addLast(
+ new Http3FrameToHttpObjectCodec(false));
+ if (decompressor != null) {
+ ch.pipeline().addLast(decompressor);
+ }
+ ch.pipeline().addLast(Writability.HANDLER);
+ ch.pipeline().addLast(handler);
+ }
+ })
+ .option(ChannelOption.AUTO_READ, false)
+ .create();
+ return NettyFutures.await(future, timeoutMs, "Unable to open an HTTP/3 stream");
+ }
+ }
+
+ private static DefaultSslContextFactory toDefaultFactory(SslContextFactory factory) {
+ if (factory instanceof DefaultSslContextFactory defaultFactory) {
+ return defaultFactory;
+ }
+ throw new IllegalArgumentException(
+ "HTTP/3 only supports the DefaultSslContextFactory, not "
+ + factory.getClass().getName());
+ }
+
+ private static KeyManagerFactory keyManagerFactory(DefaultSslContextFactory factory)
+ throws GeneralSecurityException, IOException {
+ try {
+ return factory.createKeyManagerFactory();
+ } catch (Exception e) {
+ throw asSslException(e, "Unable to load the key store");
+ }
+ }
+
+ private static TrustManagerFactory trustManagerFactory(DefaultSslContextFactory factory)
+ throws GeneralSecurityException, IOException {
+ try {
+ return factory.createTrustManagerFactory();
+ } catch (Exception e) {
+ throw asSslException(e, "Unable to load the trust store");
+ }
+ }
+
+ /**
+ * Rethrows the specific exceptions of the key store loading, and wraps the others.
+ *
+ * @param e The exception.
+ * @param message The message of the wrapping exception.
+ * @return The IOException wrapping an unexpected checked exception.
+ * @throws GeneralSecurityException If e is one.
+ */
+ private static IOException asSslException(Exception e, String message)
+ throws GeneralSecurityException {
+ if (e instanceof RuntimeException re) {
+ throw re;
+ }
+ if (e instanceof GeneralSecurityException gse) {
+ throw gse;
+ }
+ if (e instanceof IOException ioe) {
+ return ioe;
+ }
+ return new IOException(message, e);
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientCall.java
new file mode 100644
index 0000000000..731f05ff02
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientCall.java
@@ -0,0 +1,503 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelFuture;
+import io.netty.handler.codec.http.DefaultHttpContent;
+import io.netty.handler.codec.http.DefaultHttpRequest;
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpHeaderValues;
+import io.netty.handler.codec.http.HttpHeaders;
+import io.netty.handler.codec.http.HttpMethod;
+import io.netty.handler.codec.http.HttpRequest;
+import io.netty.handler.codec.http.HttpResponse;
+import io.netty.handler.codec.http.HttpUtil;
+import io.netty.handler.codec.http.HttpVersion;
+import io.netty.handler.codec.http.LastHttpContent;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.io.OutputStream;
+import java.net.ConnectException;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.ReentrantLock;
+import org.restlet.Uniform;
+import org.restlet.data.Header;
+import org.restlet.data.Reference;
+import org.restlet.data.Status;
+import org.restlet.engine.adapter.ClientCall;
+import org.restlet.engine.connector.ConnectorHelper;
+import org.restlet.engine.header.HeaderConstants;
+import org.restlet.representation.Representation;
+import org.restlet.service.ConnectorService;
+
+/**
+ * HTTP client call based on Netty. The calling thread blocks while the request is written and until
+ * the response head is received; the response body is then streamed from the connection.
+ *
+ *
This class is internal to the Netty connectors.
+ */
+class NettyClientCall extends ClientCall {
+
+ /** The parent helper. */
+ private final NettyClientHelper helper;
+
+ /** The request target (origin form, or absolute form for a forward proxy). */
+ private final String target;
+
+ /** The target host. */
+ private final String targetHost;
+
+ /** The target port. */
+ private final int targetPort;
+
+ private final ReentrantLock lock = new ReentrantLock();
+
+ private final Condition headReceived = lock.newCondition();
+
+ /** The kind of channel carrying the exchange, set by the helper when it is acquired. */
+ private volatile StreamKind kind = StreamKind.HTTP1;
+
+ /** The channel carrying the exchange. */
+ private volatile Channel channel;
+
+ /** The response body stream. */
+ private volatile ChannelInputStream body;
+
+ /** Indicates whether the response head was received. */
+ private boolean head;
+
+ /** The failure before the response head, if any. */
+ private Throwable failure;
+
+ /** Indicates whether the request was fully sent. */
+ private boolean requestSent;
+
+ /** Indicates whether the response was fully received. */
+ private boolean responseComplete;
+
+ /** Indicates whether the connection can carry another exchange. */
+ private boolean reusable = true;
+
+ /** Indicates whether the channel was released. */
+ private boolean released;
+
+ /**
+ * Constructor.
+ *
+ * @param helper The parent HTTP client helper.
+ * @param method The method name.
+ * @param resourceRef The absolute target resource reference.
+ * @param target The request target sent on the wire.
+ * @param secure True for HTTPS.
+ */
+ NettyClientCall(
+ NettyClientHelper helper,
+ String method,
+ Reference resourceRef,
+ String target,
+ boolean secure) {
+ super(helper, method, resourceRef.toString());
+ this.helper = helper;
+ this.target = target;
+ this.targetHost = resourceRef.getHostDomain();
+ int port = resourceRef.getHostPort();
+ int defaultPort = secure ? 443 : 80;
+ this.targetPort = (port == -1) ? defaultPort : port;
+ setConfidential(secure);
+ setServerAddress(this.targetHost);
+ setServerPort(this.targetPort);
+ }
+
+ /**
+ * Sets the kind of channel carrying the exchange.
+ *
+ * @param kind The kind of channel.
+ */
+ void setStreamKind(StreamKind kind) {
+ this.kind = kind;
+ }
+
+ String getTargetHost() {
+ return targetHost;
+ }
+
+ int getTargetPort() {
+ return targetPort;
+ }
+
+ @Override
+ public OutputStream getRequestEntityStream() {
+ return null;
+ }
+
+ @Override
+ public OutputStream getRequestHeadStream() {
+ return null;
+ }
+
+ @Override
+ public InputStream getResponseEntityStream(long size) {
+ final ChannelInputStream stream = this.body;
+ if (stream == null) {
+ return null;
+ }
+ return new FilterInputStream(stream) {
+ @Override
+ public void close() throws IOException {
+ super.close();
+ onBodyClosed();
+ }
+ };
+ }
+
+ @Override
+ public Status sendRequest(org.restlet.Request request) {
+ Status result;
+ Representation entity = request.isEntityAvailable() ? request.getEntity() : null;
+ ConnectorService connectorService = ConnectorHelper.getConnectorService();
+ if (connectorService != null) {
+ connectorService.beforeSend(entity);
+ }
+
+ try {
+ this.channel = helper.acquire(this);
+ writeRequest(entity);
+ awaitHead();
+ result = new Status(getStatusCode(), getReasonPhrase());
+ } catch (ConnectException e) {
+ helper.logFailure("Unable to connect to the remote HTTP server", e);
+ abort();
+ result = new Status(Status.CONNECTOR_ERROR_CONNECTION, e);
+ } catch (IOException e) {
+ helper.logFailure(
+ "An error occurred during the communication with the remote HTTP server", e);
+ abort();
+ result = new Status(Status.CONNECTOR_ERROR_COMMUNICATION, e);
+ } catch (RuntimeException e) {
+ helper.logFailure("An unexpected error occurred while sending the request", e);
+ abort();
+ result = new Status(Status.CONNECTOR_ERROR_INTERNAL, e);
+ } finally {
+ // A repeatable entity is kept for a possible redirect (307/308) and released by the
+ // helper once the redirects are followed.
+ if (entity != null
+ && (entity.isTransient()
+ || !helper.isFollowRedirects()
+ || request.isAsynchronous())) {
+ entity.release();
+ }
+ if (connectorService != null) {
+ connectorService.afterSend(entity);
+ }
+ }
+
+ return result;
+ }
+
+ @Override
+ public void sendRequest(
+ org.restlet.Request request, org.restlet.Response response, Uniform callback) {
+ sendRequest(request);
+
+ final Uniform onSent = request.getOnSent();
+ if (onSent != null) {
+ onSent.handle(request, response);
+ }
+
+ if (callback != null) {
+ callback.handle(request, response);
+ }
+ }
+
+ private void writeRequest(Representation entity) throws IOException {
+ Channel ch = this.channel;
+ HttpRequest nettyRequest = newRequestHead();
+ boolean hasBody = setBodyHeaders(nettyRequest, entity);
+
+ long timeout = helper.getIdleTimeout();
+ Writability.await(ch, timeout);
+ check(ch.writeAndFlush(nettyRequest));
+ // Read now, so that an early response (for example an error) is not missed.
+ ch.read();
+
+ if (hasBody) {
+ ChannelOutputStream out =
+ new ChannelOutputStream(
+ ch, DefaultHttpContent::new, helper.getRequestBufferSize(), timeout);
+ entity.write(out);
+ out.close();
+ }
+
+ Writability.await(ch, timeout);
+ check(ch.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT));
+
+ lock.lock();
+ try {
+ requestSent = true;
+ } finally {
+ lock.unlock();
+ }
+ maybeRelease();
+ }
+
+ /**
+ * Creates the request head with the Restlet request headers, adapted to the transport.
+ *
+ * @return The request head.
+ */
+ private HttpRequest newRequestHead() {
+ HttpRequest result =
+ new DefaultHttpRequest(
+ HttpVersion.HTTP_1_1, HttpMethod.valueOf(getMethod()), target);
+ HttpHeaders headers = result.headers();
+
+ for (Header header : getRequestHeaders()) {
+ String name = header.getName();
+ if (!HeaderConstants.HEADER_CONTENT_LENGTH.equalsIgnoreCase(name)
+ && !HeaderConstants.HEADER_TRANSFER_ENCODING.equalsIgnoreCase(name)) {
+ headers.add(name, header.getValue());
+ }
+ }
+
+ String userAgent = helper.getUserAgentField();
+ if (userAgent != null) {
+ headers.set(HttpHeaderNames.USER_AGENT, userAgent);
+ }
+
+ if (kind.isStream()) {
+ headers.remove(HttpHeaderNames.CONNECTION);
+ headers.remove("Keep-Alive");
+ // The HTTP/2 and HTTP/3 codecs read the ":scheme" pseudo-header from their extension
+ // header.
+ headers.set(
+ (kind == StreamKind.HTTP3) ? "x-http3-scheme" : "x-http2-scheme",
+ isConfidential() ? "https" : "http");
+ } else if (!headers.contains(HttpHeaderNames.CONNECTION)) {
+ headers.set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
+ }
+ return result;
+ }
+
+ /**
+ * Sets the headers describing the request body.
+ *
+ * @param request The request head.
+ * @param entity The request entity, or null.
+ * @return True if a body must be written.
+ */
+ private boolean setBodyHeaders(HttpRequest request, Representation entity) {
+ if (entity == null) {
+ if (requiresContentLength(getMethod())) {
+ HttpUtil.setContentLength(request, 0);
+ }
+ return false;
+ }
+ if (entity.getSize() >= 0) {
+ HttpUtil.setContentLength(request, entity.getSize());
+ return entity.getSize() > 0;
+ }
+ if (!kind.isStream()) {
+ HttpUtil.setTransferEncodingChunked(request, true);
+ }
+ return true;
+ }
+
+ private static boolean requiresContentLength(String method) {
+ return "POST".equals(method) || "PUT".equals(method) || "PATCH".equals(method);
+ }
+
+ private static void check(ChannelFuture future) throws IOException {
+ if (future.isDone() && !future.isSuccess()) {
+ throw new IOException("Unable to write the request", future.cause());
+ }
+ }
+
+ private void awaitHead() throws IOException {
+ long timeout = helper.getIdleTimeout();
+ lock.lock();
+ try {
+ long remaining =
+ (timeout > 0) ? TimeUnit.MILLISECONDS.toNanos(timeout) : Long.MAX_VALUE;
+ while (!head && failure == null) {
+ if (timeout > 0) {
+ if (remaining <= 0) {
+ throw new InterruptedIOException("Timed out waiting for the response");
+ }
+ remaining = headReceived.awaitNanos(remaining);
+ } else {
+ headReceived.await();
+ }
+ }
+ if (!head) {
+ throw (failure instanceof IOException ioe)
+ ? ioe
+ : new IOException("Unable to receive the response", failure);
+ }
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ throw new InterruptedIOException("Interrupted while waiting for the response");
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ // ------------------------------------------------------------------ event loop callbacks
+
+ /**
+ * Called on the event loop when the response head is received.
+ *
+ * @param response The response head.
+ */
+ void onResponseHead(HttpResponse response) {
+ for (Map.Entry entry : response.headers()) {
+ String name = entry.getKey();
+ if (!name.regionMatches(true, 0, "x-http2-", 0, 8)
+ && !name.regionMatches(true, 0, "x-http3-", 0, 8)) {
+ getResponseHeaders().add(name, entry.getValue());
+ }
+ }
+ if (!HttpUtil.isKeepAlive(response)) {
+ lock.lock();
+ try {
+ reusable = false;
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ setStatusCode(response.status().code());
+ setReasonPhrase(response.status().reasonPhrase());
+ setVersion(response.protocolVersion().text());
+ this.body = new ChannelInputStream(channel, helper.getIdleTimeout());
+
+ lock.lock();
+ try {
+ head = true;
+ headReceived.signalAll();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ /**
+ * Called on the event loop with a response content buffer, whose ownership is transferred.
+ *
+ * @param content The content buffer.
+ * @return True if the event loop should read more content now.
+ */
+ boolean onResponseContent(ByteBuf content) {
+ ChannelInputStream stream = this.body;
+ if (stream == null) {
+ content.release();
+ return true;
+ }
+ return stream.offer(content);
+ }
+
+ /** Called on the event loop when the response is fully received. */
+ void onResponseComplete() {
+ ChannelInputStream stream = this.body;
+ if (stream != null) {
+ stream.end();
+ }
+ lock.lock();
+ try {
+ responseComplete = true;
+ } finally {
+ lock.unlock();
+ }
+ maybeRelease();
+ }
+
+ /**
+ * Called on the event loop when the exchange fails.
+ *
+ * @param cause The failure cause.
+ */
+ void onFailure(Throwable cause) {
+ lock.lock();
+ try {
+ if (responseComplete) {
+ return;
+ }
+ reusable = false;
+ if (!head) {
+ failure = cause;
+ headReceived.signalAll();
+ }
+ } finally {
+ lock.unlock();
+ }
+ ChannelInputStream stream = this.body;
+ if (stream != null) {
+ stream.fail(cause);
+ }
+ abort();
+ }
+
+ // ------------------------------------------------------------------ connection release
+
+ /** Called when the consumer closes the response body. */
+ private void onBodyClosed() {
+ lock.lock();
+ try {
+ if (!responseComplete) {
+ // The rest of the body would have to be drained.
+ reusable = false;
+ responseComplete = true;
+ requestSent = true;
+ }
+ } finally {
+ lock.unlock();
+ }
+ maybeRelease();
+ }
+
+ private void maybeRelease() {
+ boolean reuse;
+ lock.lock();
+ try {
+ if (released || !requestSent || !responseComplete) {
+ return;
+ }
+ released = true;
+ reuse = reusable;
+ } finally {
+ lock.unlock();
+ }
+ Channel ch = this.channel;
+ if (ch != null) {
+ helper.release(ch, reuse);
+ }
+ }
+
+ /** Releases the channel without reusing it. */
+ private void abort() {
+ lock.lock();
+ try {
+ if (released) {
+ return;
+ }
+ released = true;
+ reusable = false;
+ } finally {
+ lock.unlock();
+ }
+ Channel ch = this.channel;
+ if (ch != null) {
+ helper.release(ch, false);
+ }
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientHandler.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientHandler.java
new file mode 100644
index 0000000000..359001d387
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientHandler.java
@@ -0,0 +1,137 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.handler.codec.DecoderResultProvider;
+import io.netty.handler.codec.http.HttpContent;
+import io.netty.handler.codec.http.HttpResponse;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.netty.handler.codec.http.HttpStatusClass;
+import io.netty.handler.codec.http.LastHttpContent;
+import io.netty.util.ReferenceCountUtil;
+import java.io.IOException;
+
+/**
+ * Inbound handler feeding the response of the bound {@link NettyClientCall}. One instance per
+ * HTTP/1.1 connection, bound to one call at a time, or one instance per HTTP/2 stream.
+ *
+ *
Reads happen on demand: the call requests the first read after sending its request, then this
+ * handler keeps reading as long as the response body consumer keeps up.
+ */
+class NettyClientHandler extends ChannelInboundHandlerAdapter {
+
+ /** The call in progress, if any. */
+ private volatile NettyClientCall call;
+
+ /** Skips the content of an interim (1xx) response. */
+ private boolean skippingInterim;
+
+ /**
+ * Binds a call to this handler.
+ *
+ * @param call The call.
+ */
+ void bind(NettyClientCall call) {
+ this.skippingInterim = false;
+ this.call = call;
+ }
+
+ /** Unbinds the current call. */
+ void unbind() {
+ this.call = null;
+ }
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) {
+ NettyClientCall current = this.call;
+ if (current == null) {
+ // Unsolicited data on an idle connection: it can't be reused.
+ ReferenceCountUtil.release(msg);
+ ctx.close();
+ return;
+ }
+
+ if (msg instanceof DecoderResultProvider provider && provider.decoderResult().isFailure()) {
+ Throwable cause = provider.decoderResult().cause();
+ ReferenceCountUtil.release(msg);
+ current.onFailure(
+ new IOException("Invalid HTTP response: " + cause.getMessage(), cause));
+ ctx.close();
+ return;
+ }
+
+ if (msg instanceof HttpResponse response) {
+ onHead(current, response);
+ }
+
+ boolean readMore = true;
+ if (msg instanceof HttpContent content) {
+ readMore = onContent(current, content);
+ } else if (!(msg instanceof HttpResponse)) {
+ ReferenceCountUtil.release(msg);
+ }
+
+ if (readMore) {
+ ctx.read();
+ }
+ }
+
+ /** Handles a response head, skipping interim (1xx) responses. */
+ private void onHead(NettyClientCall current, HttpResponse response) {
+ HttpResponseStatus status = response.status();
+ skippingInterim =
+ status.codeClass() == HttpStatusClass.INFORMATIONAL
+ && status.code() != HttpResponseStatus.SWITCHING_PROTOCOLS.code();
+ if (!skippingInterim) {
+ current.onResponseHead(response);
+ }
+ }
+
+ /**
+ * Handles response content, whose ownership is transferred.
+ *
+ * @return True if the event loop should read more content now.
+ */
+ private boolean onContent(NettyClientCall current, HttpContent content) {
+ boolean last = content instanceof LastHttpContent;
+ if (skippingInterim) {
+ content.release();
+ if (last) {
+ skippingInterim = false;
+ }
+ return true;
+ }
+ boolean readMore = current.onResponseContent(content.content());
+ if (last) {
+ current.onResponseComplete();
+ return false;
+ }
+ return readMore;
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ NettyClientCall current = this.call;
+ if (current != null) {
+ current.onFailure(new IOException("Connection closed by peer"));
+ }
+ super.channelInactive(ctx);
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+ NettyClientCall current = this.call;
+ if (current != null) {
+ current.onFailure(cause);
+ }
+ ctx.close();
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientHelper.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientHelper.java
new file mode 100644
index 0000000000..0a17db61a5
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientHelper.java
@@ -0,0 +1,993 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+import io.netty.bootstrap.Bootstrap;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelFuture;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.ChannelOption;
+import io.netty.channel.ChannelPipeline;
+import io.netty.channel.EventLoopGroup;
+import io.netty.channel.MultiThreadIoEventLoopGroup;
+import io.netty.channel.group.ChannelGroup;
+import io.netty.channel.group.DefaultChannelGroup;
+import io.netty.channel.nio.NioIoHandler;
+import io.netty.channel.socket.SocketChannel;
+import io.netty.channel.socket.nio.NioSocketChannel;
+import io.netty.handler.codec.http.HttpClientCodec;
+import io.netty.handler.codec.http.HttpContentDecompressor;
+import io.netty.handler.codec.http2.Http2FrameCodecBuilder;
+import io.netty.handler.codec.http2.Http2MultiplexHandler;
+import io.netty.handler.codec.http2.Http2StreamChannel;
+import io.netty.handler.codec.http2.Http2StreamChannelBootstrap;
+import io.netty.handler.codec.http2.Http2StreamFrameToHttpObjectCodec;
+import io.netty.handler.proxy.HttpProxyHandler;
+import io.netty.handler.ssl.ApplicationProtocolConfig;
+import io.netty.handler.ssl.ApplicationProtocolNames;
+import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler;
+import io.netty.handler.ssl.ClientAuth;
+import io.netty.handler.ssl.SslContext;
+import io.netty.handler.ssl.SslHandler;
+import io.netty.handler.timeout.IdleStateHandler;
+import io.netty.resolver.NoopAddressResolverGroup;
+import io.netty.util.AttributeKey;
+import io.netty.util.concurrent.Future;
+import io.netty.util.concurrent.GlobalEventExecutor;
+import io.netty.util.concurrent.Promise;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.net.ConnectException;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.security.GeneralSecurityException;
+import java.util.Deque;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedDeque;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.logging.Level;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLParameters;
+import org.restlet.Client;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.data.Method;
+import org.restlet.data.Reference;
+import org.restlet.data.Status;
+import org.restlet.engine.adapter.ClientCall;
+import org.restlet.engine.adapter.HttpClientHelper;
+import org.restlet.engine.ssl.SslUtils;
+import org.restlet.engine.util.ReferenceUtils;
+import org.restlet.representation.Representation;
+
+/**
+ * Base class for the Netty HTTP client connector. Connections are pooled per origin (scheme, host
+ * and port). HTTP/2 connections are shared by concurrent calls, one stream per call. Here is the
+ * list of parameters that are supported. They should be set in the Client's context before it is
+ * started:
+ *
+ *
+ *
list of supported parameters
+ *
+ *
Parameter name
+ *
Value type
+ *
Default value
+ *
Description
+ *
+ *
+ *
bindAddress
+ *
String
+ *
null
+ *
The address to bind socket channels to. You must set both this and bindPort
+ *
+ *
+ *
bindPort
+ *
int
+ *
null
+ *
The port to bind socket channels to. You must set both this and bindAddress
+ *
+ *
+ *
connectTimeout
+ *
long
+ *
15000
+ *
The max time in milliseconds a connection can take to connect to destinations, including
+ * the TLS handshake
+ *
+ *
+ *
eventLoop.workerThreads
+ *
int
+ *
0
+ *
Number of event loop threads doing network I/O. When 0, Netty uses twice the number of
+ * available processors.
+ *
+ *
+ *
followRedirects
+ *
boolean
+ *
true
+ *
Whether to follow HTTP redirects (301, 302, 303, 307 and 308) of synchronous calls
+ *
+ *
+ *
hostnameVerification
+ *
boolean
+ *
true
+ *
Whether to check that the server certificate matches the host name (HTTPS endpoint
+ * identification)
+ *
+ *
+ *
httpClientTransportMode
+ *
String
+ *
HTTP1_1
+ *
Indicate the HTTP client transport mode among the following options: "HTTP1_1", "HTTP2"
+ * (HTTP/2 only: negotiated with ALPN over TLS, prior knowledge in cleartext), "DYNAMIC" (HTTP/2
+ * when negotiated with ALPN over TLS, HTTP/1.1 otherwise), "HTTP3" (experimental: HTTP/3
+ * over QUIC for HTTPS URIs without proxy, HTTP/1.1 otherwise; requires the optional
+ * {@code io.netty:netty-codec-http3} dependency with its native QUIC library, and the {@link
+ * org.restlet.engine.ssl.DefaultSslContextFactory}).
+ *
+ *
+ *
idleTimeout
+ *
long
+ *
30000
+ *
The max time in milliseconds a connection can be idle (that is, without traffic of bytes in
+ * either direction). Also the max time to wait for a response.
+ *
+ *
+ *
maxConnectionsPerDestination
+ *
int
+ *
64
+ *
The max number of HTTP/1.1 connections to open to each destination
+ *
+ *
+ *
maxRedirects
+ *
int
+ *
8
+ *
The max number of HTTP redirects that are followed
+ *
+ *
+ *
maxResponseHeaderSize
+ *
int
+ *
8192
+ *
The max size in bytes of the response status line and headers.
+ *
+ *
+ *
proxyHost
+ *
String
+ *
System property "http.proxyHost"
+ *
The host name of the HTTP proxy.
+ *
+ *
+ *
proxyPort
+ *
int
+ *
System property "http.proxyPort" or "3128"
+ *
The port of the HTTP proxy.
+ *
+ *
+ *
requestBufferSize
+ *
int
+ *
8192
+ *
The size in bytes of the buffer used to write request entities
+ *
+ *
+ *
userAgentField
+ *
String
+ *
null
+ *
The "User-Agent" HTTP header string; when null, uses the Restlet default
+ *
+ *
+ *
sslContextFactory
+ *
String
+ *
org.restlet.engine.ssl.DefaultSslContextFactory
+ *
Let you specify a {@link org.restlet.engine.ssl.SslContextFactory} qualified class name as a
+ * parameter, or an instance as an attribute for a more complete and flexible SSL context
+ * setting
+ *
+ *
+ *
+ * @author Jerome Louvel
+ */
+public abstract class NettyClientHelper extends HttpClientHelper {
+
+ /** The negotiated application protocol of a new connection. */
+ private static final AttributeKey> READY =
+ AttributeKey.valueOf(NettyClientHelper.class, "ready");
+
+ /** The pool owning a connection. */
+ private static final AttributeKey POOL =
+ AttributeKey.valueOf(NettyClientHelper.class, "pool");
+
+ /** Supported transport modes. */
+ protected enum TransportMode {
+ HTTP1_1,
+ HTTP2,
+ HTTP3,
+ DYNAMIC
+ }
+
+ /** Connection pool of an origin. */
+ static final class OriginPool {
+
+ final boolean secure;
+
+ final String host;
+
+ final int port;
+
+ final Deque idle = new ConcurrentLinkedDeque<>();
+
+ final Semaphore permits;
+
+ /**
+ * Serializes connection establishment when HTTP/2 may be negotiated, so that concurrent
+ * first calls share a single HTTP/2 connection instead of opening one each.
+ */
+ final ReentrantLock connectLock = new ReentrantLock();
+
+ volatile Channel http2;
+
+ OriginPool(boolean secure, String host, int port, int maxConnections) {
+ this.secure = secure;
+ this.host = host;
+ this.port = port;
+ this.permits = new Semaphore(Math.max(1, maxConnections));
+ }
+ }
+
+ /** The event loop group doing network I/O. */
+ private volatile EventLoopGroup group;
+
+ /** All open connections. */
+ private volatile ChannelGroup connections;
+
+ /** The Netty SSL context, created on first use. */
+ private volatile SslContext sslContext;
+
+ /** The experimental HTTP/3 client, created on first use, or null. */
+ private volatile Http3Support.Client http3Client;
+
+ /** The QUIC connections, per origin, for the experimental HTTP/3 support. */
+ private final Map http3Connections = new ConcurrentHashMap<>();
+
+ /** The connection pools, per origin. */
+ private final Map pools = new ConcurrentHashMap<>();
+
+ /**
+ * Constructor.
+ *
+ * @param client The client to help.
+ */
+ protected NettyClientHelper(Client client) {
+ super(client);
+ }
+
+ @Override
+ public ClientCall create(Request request) {
+ Reference ref = ReferenceUtils.update(request.getResourceRef(), request);
+ String scheme = ref.getSchemeProtocol() == null ? null : ref.getScheme();
+
+ if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
+ throw new IllegalArgumentException("Only HTTP or HTTPS resource URIs are allowed here");
+ }
+
+ boolean secure = "https".equalsIgnoreCase(scheme);
+ boolean forwardProxy = !secure && (getProxyHost() != null);
+ String target = ReferenceUtils.format(request.getResourceRef(), forwardProxy, request);
+ return new NettyClientCall(this, request.getMethod().toString(), ref, target, secure);
+ }
+
+ @Override
+ public void handle(Request request, Response response) {
+ if (!isFollowRedirects() || request.isAsynchronous()) {
+ super.handle(request, response);
+ return;
+ }
+
+ Request current = request;
+ int redirects = 0;
+
+ try {
+ Request next = current;
+ while (next != null) {
+ current = next;
+ super.handle(current, response);
+ next = (redirects < getMaxRedirects()) ? nextRedirect(current, response) : null;
+ if (next != null) {
+ if (response.getEntity() != null) {
+ response.getEntity().release();
+ }
+ resetResponse(response, next);
+ redirects++;
+ }
+ }
+ } finally {
+ // The request entity was kept available for redirects, see NettyClientCall.
+ if (request.getEntity() != null) {
+ request.getEntity().release();
+ }
+ }
+
+ response.setRequest(request);
+ }
+
+ /**
+ * Returns the request following a redirect response, or null if the response is not a redirect
+ * that can be followed.
+ */
+ private static Request nextRedirect(Request current, Response response) {
+ Status status = response.getStatus();
+ Reference location = response.getLocationRef();
+ if (!isRedirect(status) || location == null) {
+ return null;
+ }
+ return newRedirectRequest(current, status, location);
+ }
+
+ private static boolean isRedirect(Status status) {
+ return status.equals(Status.REDIRECTION_PERMANENT)
+ || status.equals(Status.REDIRECTION_FOUND)
+ || status.equals(Status.REDIRECTION_SEE_OTHER)
+ || status.equals(Status.REDIRECTION_TEMPORARY)
+ || status.getCode() == 308;
+ }
+
+ private static Request newRedirectRequest(Request current, Status status, Reference location) {
+ Reference target =
+ location.isRelative()
+ ? new Reference(current.getResourceRef(), location).getTargetRef()
+ : location;
+ Request next = new Request(current);
+ next.setResourceRef(target);
+
+ boolean keepMethod = (status.getCode() == 307) || (status.getCode() == 308);
+ boolean toGet =
+ status.equals(Status.REDIRECTION_SEE_OTHER)
+ || (!keepMethod && Method.POST.equals(current.getMethod()));
+
+ if (toGet) {
+ if (!Method.HEAD.equals(current.getMethod())) {
+ next.setMethod(Method.GET);
+ }
+ next.setEntity((Representation) null);
+ } else if (current.getEntity() != null
+ && (current.getEntity().isTransient() || !current.getEntity().isAvailable())) {
+ // The entity was consumed by the first request and can't be sent again.
+ return null;
+ }
+
+ // Never leak credentials to another origin.
+ Reference from = current.getResourceRef();
+ boolean sameOrigin =
+ equalsIgnoreCase(from.getScheme(), target.getScheme())
+ && equalsIgnoreCase(from.getHostDomain(), target.getHostDomain())
+ && (from.getHostPort() == target.getHostPort());
+ if (!sameOrigin) {
+ next.setChallengeResponse(null);
+ next.setProxyChallengeResponse(null);
+ }
+
+ next.setHostRef((Reference) null);
+ return next;
+ }
+
+ private static boolean equalsIgnoreCase(String a, String b) {
+ return (a == null) ? (b == null) : a.equalsIgnoreCase(b);
+ }
+
+ private static void resetResponse(Response response, Request next) {
+ response.setRequest(next);
+ response.setEntity(null);
+ response.setLocationRef((Reference) null);
+ response.setAuthenticationInfo(null);
+ response.setRetryAfter(null);
+ response.getChallengeRequests().clear();
+ response.getProxyChallengeRequests().clear();
+ response.getCookieSettings().clear();
+ response.getAllowedMethods().clear();
+ response.getDimensions().clear();
+ }
+
+ // ------------------------------------------------------------------ connections
+
+ /**
+ * Acquires a channel ready to carry one exchange: an idle or new HTTP/1.1 connection, or a new
+ * stream of an HTTP/2 connection.
+ *
+ * @param call The call that will use the channel.
+ * @return The channel.
+ * @throws IOException If no connection could be established.
+ */
+ Channel acquire(NettyClientCall call) throws IOException {
+ if (call.isConfidential()
+ && getTransportMode() == TransportMode.HTTP3
+ && getProxyHost() == null) {
+ return acquireHttp3(call);
+ }
+
+ OriginPool pool =
+ pools.computeIfAbsent(
+ (call.isConfidential() ? "https://" : "http://")
+ + call.getTargetHost()
+ + ":"
+ + call.getTargetPort(),
+ k ->
+ new OriginPool(
+ call.isConfidential(),
+ call.getTargetHost(),
+ call.getTargetPort(),
+ getMaxConnectionsPerDestination()));
+
+ Channel http2 = pool.http2;
+ if (http2 != null && http2.isActive()) {
+ return openStream(http2, call);
+ }
+
+ Channel channel;
+ while ((channel = pool.idle.pollFirst()) != null) {
+ if (channel.isActive()) {
+ bindHttp1(channel, call);
+ return channel;
+ }
+ }
+
+ if (getTransportMode() == TransportMode.HTTP1_1) {
+ channel = connect(pool);
+ } else {
+ pool.connectLock.lock();
+ try {
+ http2 = pool.http2;
+ if (http2 != null && http2.isActive()) {
+ return openStream(http2, call);
+ }
+ channel = connect(pool);
+ if (channel.pipeline().get(Http2MultiplexHandler.class) != null) {
+ pool.http2 = channel;
+ return openStream(channel, call);
+ }
+ } finally {
+ pool.connectLock.unlock();
+ }
+ }
+
+ bindHttp1(channel, call);
+ return channel;
+ }
+
+ /**
+ * Opens an HTTP/3 request stream, on the shared QUIC connection of the origin.
+ *
+ * @param call The call that will use the stream.
+ * @return The request stream.
+ * @throws IOException If no connection or stream could be established.
+ */
+ private Channel acquireHttp3(NettyClientCall call) throws IOException {
+ Http3Support.Client client = getHttp3Client();
+ String key = call.getTargetHost() + ":" + call.getTargetPort();
+ Channel connection = http3Connections.get(key);
+ if (connection == null || !connection.isActive()) {
+ synchronized (http3Connections) {
+ connection = http3Connections.get(key);
+ if (connection == null || !connection.isActive()) {
+ connection =
+ client.connect(
+ call.getTargetHost(),
+ call.getTargetPort(),
+ getConnectTimeout());
+ connections.add(connection);
+ http3Connections.put(key, connection);
+ }
+ }
+ }
+
+ NettyClientHandler handler = new NettyClientHandler();
+ handler.bind(call);
+ call.setStreamKind(StreamKind.HTTP3);
+ return client.openStream(
+ connection, handler, new HttpContentDecompressor(false, 0), getConnectTimeout());
+ }
+
+ private Http3Support.Client getHttp3Client() throws IOException {
+ Http3Support.Client result = this.http3Client;
+ if (result == null) {
+ synchronized (this) {
+ result = this.http3Client;
+ if (result == null) {
+ try {
+ result =
+ Http3Support.Client.create(
+ group,
+ SslUtils.getSslContextFactory(this),
+ isHostnameVerification(),
+ getIdleTimeout());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new InterruptedIOException(
+ "Interrupted while creating the HTTP/3 client");
+ } catch (GeneralSecurityException e) {
+ throw new IOException("Unable to create the HTTP/3 client", e);
+ }
+ connections.add(result.channel());
+ this.http3Client = result;
+ }
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Releases a channel after an exchange.
+ *
+ * @param channel The channel.
+ * @param reusable True if the connection can carry another exchange.
+ */
+ void release(Channel channel, boolean reusable) {
+ if (channel.attr(POOL).get() == null) {
+ // A stream of a multiplexed connection (HTTP/2 or HTTP/3).
+ channel.close();
+ return;
+ }
+
+ NettyClientHandler handler = channel.pipeline().get(NettyClientHandler.class);
+ if (handler != null) {
+ handler.unbind();
+ }
+
+ OriginPool pool = channel.attr(POOL).get();
+ if (reusable && channel.isActive() && pool != null && group != null) {
+ pool.idle.offerFirst(channel);
+ } else {
+ channel.close();
+ }
+ }
+
+ private void bindHttp1(Channel channel, NettyClientCall call) throws IOException {
+ NettyClientHandler handler = channel.pipeline().get(NettyClientHandler.class);
+ if (handler == null) {
+ throw new IOException("Connection not ready");
+ }
+ handler.bind(call);
+ }
+
+ private Channel openStream(Channel parent, NettyClientCall call) throws IOException {
+ final NettyClientHandler handler = new NettyClientHandler();
+ handler.bind(call);
+ call.setStreamKind(StreamKind.HTTP2);
+ Future future =
+ new Http2StreamChannelBootstrap(parent)
+ .option(ChannelOption.AUTO_READ, false)
+ .handler(
+ new ChannelInitializer() {
+ @Override
+ protected void initChannel(Channel stream) {
+ ChannelPipeline p = stream.pipeline();
+ p.addLast(new Http2StreamFrameToHttpObjectCodec(false));
+ p.addLast(new HttpContentDecompressor(false, 0));
+ p.addLast(Writability.HANDLER);
+ p.addLast(handler);
+ }
+ })
+ .open();
+ return NettyFutures.await(future, getConnectTimeout(), "Unable to open an HTTP/2 stream");
+ }
+
+ private Channel connect(final OriginPool pool) throws IOException {
+ long timeout = getConnectTimeout();
+ try {
+ if (!pool.permits.tryAcquire(timeout, TimeUnit.MILLISECONDS)) {
+ throw new ConnectException(
+ "Too many connections to " + pool.host + ":" + pool.port);
+ }
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ throw new InterruptedIOException("Interrupted while waiting for a connection");
+ }
+
+ boolean acquired = true;
+ try {
+ final SslContext ssl = pool.secure ? getSslContext() : null;
+ final String proxyHost = getProxyHost();
+ final InetSocketAddress proxy =
+ (proxyHost == null) ? null : new InetSocketAddress(proxyHost, getProxyPort());
+
+ Bootstrap bootstrap =
+ new Bootstrap()
+ .group(group)
+ .channel(NioSocketChannel.class)
+ .option(ChannelOption.AUTO_READ, false)
+ .option(ChannelOption.TCP_NODELAY, true)
+ .option(
+ ChannelOption.CONNECT_TIMEOUT_MILLIS,
+ (int) Math.min(Integer.MAX_VALUE, timeout))
+ .handler(
+ new ChannelInitializer() {
+ @Override
+ protected void initChannel(SocketChannel ch) {
+ initConnection(
+ ch, pool, ssl, pool.secure ? proxy : null);
+ }
+ });
+
+ SocketAddress bindAddress = getBindAddress();
+ if (bindAddress != null) {
+ bootstrap.localAddress(bindAddress);
+ }
+
+ SocketAddress remote;
+ if (proxy != null && pool.secure) {
+ // Tunnel with CONNECT, the proxy resolves the host name.
+ bootstrap.resolver(NoopAddressResolverGroup.INSTANCE);
+ remote = InetSocketAddress.createUnresolved(pool.host, pool.port);
+ } else if (proxy != null) {
+ // Forward proxy: absolute request URIs are sent to the proxy.
+ remote = proxy;
+ } else {
+ remote = InetSocketAddress.createUnresolved(pool.host, pool.port);
+ }
+
+ ChannelFuture connectFuture = bootstrap.connect(remote);
+ Channel channel =
+ NettyFutures.await(
+ connectFuture,
+ timeout,
+ "Unable to connect to " + pool.host + ":" + pool.port);
+ connections.add(channel);
+ channel.closeFuture().addListener(f -> pool.permits.release());
+ acquired = false;
+
+ Promise ready = channel.attr(READY).get();
+ try {
+ NettyFutures.await(ready, timeout, "Unable to establish the connection");
+ } catch (IOException e) {
+ channel.close();
+ throw e;
+ }
+ return channel;
+ } finally {
+ if (acquired) {
+ pool.permits.release();
+ }
+ }
+ }
+
+ private void initConnection(
+ SocketChannel ch, OriginPool pool, SslContext ssl, InetSocketAddress tunnelProxy) {
+ final Promise ready = ch.eventLoop().newPromise();
+ ch.attr(READY).set(ready);
+ ch.attr(POOL).set(pool);
+ ch.closeFuture()
+ .addListener(f -> ready.tryFailure(new IOException("Connection closed by peer")));
+
+ ChannelPipeline p = ch.pipeline();
+ long idleTimeout = getIdleTimeout();
+ if (idleTimeout > 0) {
+ p.addLast("idle", new IdleStateHandler(0, 0, idleTimeout, TimeUnit.MILLISECONDS));
+ }
+ p.addLast("writability", Writability.HANDLER);
+
+ if (tunnelProxy != null) {
+ HttpProxyHandler proxyHandler = new HttpProxyHandler(tunnelProxy);
+ proxyHandler.setConnectTimeoutMillis(getConnectTimeout());
+ p.addLast("proxy", proxyHandler);
+ }
+
+ final TransportMode mode = getTransportMode();
+ if (ssl != null) {
+ SSLEngine engine = ssl.newEngine(ch.alloc(), pool.host, pool.port);
+ if (isHostnameVerification()) {
+ SSLParameters parameters = engine.getSSLParameters();
+ parameters.setEndpointIdentificationAlgorithm("HTTPS");
+ engine.setSSLParameters(parameters);
+ }
+ SslHandler sslHandler = new SslHandler(engine);
+ sslHandler.setHandshakeTimeoutMillis(getConnectTimeout());
+ p.addLast("ssl", sslHandler);
+ p.addLast(
+ "alpn",
+ new ApplicationProtocolNegotiationHandler(ApplicationProtocolNames.HTTP_1_1) {
+ @Override
+ protected void configurePipeline(
+ ChannelHandlerContext ctx, String protocol) {
+ if (ApplicationProtocolNames.HTTP_2.equals(protocol)
+ && (mode == TransportMode.HTTP2
+ || mode == TransportMode.DYNAMIC)) {
+ addHttp2Handlers(ctx.pipeline());
+ } else if (mode == TransportMode.HTTP2) {
+ ready.tryFailure(
+ new IOException("The server doesn't support HTTP/2"));
+ ctx.close();
+ return;
+ } else {
+ addHttp1Handlers(ctx.pipeline());
+ }
+ ready.trySuccess(protocol);
+ }
+
+ @Override
+ protected void handshakeFailure(
+ ChannelHandlerContext ctx, Throwable cause) {
+ ready.tryFailure(cause);
+ ctx.close();
+ }
+ });
+ } else if (mode == TransportMode.HTTP2) {
+ addHttp2Handlers(p);
+ ready.trySuccess(ApplicationProtocolNames.HTTP_2);
+ } else {
+ addHttp1Handlers(p);
+ ready.trySuccess(ApplicationProtocolNames.HTTP_1_1);
+ }
+ }
+
+ private void addHttp1Handlers(ChannelPipeline p) {
+ p.addLast("http-codec", new HttpClientCodec(4096, getMaxResponseHeaderSize(), 8192));
+ p.addLast("http-decompressor", new HttpContentDecompressor(false, 0));
+ p.addLast("restlet", new NettyClientHandler());
+ }
+
+ private void addHttp2Handlers(ChannelPipeline p) {
+ p.addLast("h2-frame-codec", Http2FrameCodecBuilder.forClient().build());
+ p.addLast(
+ "h2-multiplex",
+ new Http2MultiplexHandler(
+ new ChannelInboundHandlerAdapter() {
+ @Override
+ public boolean isSharable() {
+ return true;
+ }
+ }));
+ // Reads are not driven by a call on the parent connection.
+ p.channel().config().setAutoRead(true);
+ }
+
+ private SslContext getSslContext() throws IOException {
+ SslContext result = this.sslContext;
+ if (result == null) {
+ synchronized (this) {
+ result = this.sslContext;
+ if (result == null) {
+ try {
+ ApplicationProtocolConfig alpn =
+ switch (getTransportMode()) {
+ // HTTP/3 doesn't use TCP: its fallback is HTTP/1.1.
+ case HTTP1_1, HTTP3 -> ApplicationProtocolConfig.DISABLED;
+ case HTTP2 -> alpn(ApplicationProtocolNames.HTTP_2);
+ case DYNAMIC ->
+ alpn(
+ ApplicationProtocolNames.HTTP_2,
+ ApplicationProtocolNames.HTTP_1_1);
+ };
+ result =
+ NettySsl.newContext(
+ SslUtils.getSslContextFactory(this).createSslContext(),
+ true,
+ alpn,
+ ClientAuth.NONE);
+ this.sslContext = result;
+ } catch (Exception e) {
+ throw new IOException("Unable to create the SSL context", e);
+ }
+ }
+ }
+ }
+ return result;
+ }
+
+ private static ApplicationProtocolConfig alpn(String... protocols) {
+ return NettySsl.alpn(List.of(protocols));
+ }
+
+ @Override
+ public synchronized void start() throws Exception {
+ if (getTransportMode() == TransportMode.HTTP3) {
+ Http3Availability.ensureAvailable();
+ getLogger().warning("HTTP/3 support is experimental");
+ }
+ super.start();
+ group =
+ new MultiThreadIoEventLoopGroup(
+ Math.max(0, getEventLoopWorkerThreads()), NioIoHandler.newFactory());
+ connections = new DefaultChannelGroup("restlet-client", GlobalEventExecutor.INSTANCE);
+ getLogger().info("Starting the Netty " + getProtocols() + " client");
+ }
+
+ @Override
+ public synchronized void stop() throws Exception {
+ getLogger().info("Stopping the Netty " + getProtocols() + " client");
+ EventLoopGroup g = group;
+ group = null;
+ if (connections != null) {
+ connections.close().awaitUninterruptibly(5, TimeUnit.SECONDS);
+ }
+ pools.clear();
+ http3Connections.clear();
+ http3Client = null;
+ if (g != null) {
+ g.shutdownGracefully(0, 0, TimeUnit.MILLISECONDS);
+ }
+ sslContext = null;
+ super.stop();
+ }
+
+ /**
+ * Logs a failure.
+ *
+ * @param message The message.
+ * @param e The failure.
+ */
+ void logFailure(String message, Throwable e) {
+ getLogger().log(Level.FINE, message, e);
+ }
+
+ // ------------------------------------------------------------------ parameters
+
+ /**
+ * Returns the transport mode, validating the "httpClientTransportMode" parameter.
+ *
+ * @return The transport mode.
+ */
+ protected TransportMode getTransportMode() {
+ String name = getHttpClientTransportMode();
+ try {
+ return TransportMode.valueOf(name);
+ } catch (IllegalArgumentException | NullPointerException _) {
+ throw new IllegalArgumentException(
+ String.format(
+ "'%s' is not one of the supported values: %s",
+ name, java.util.Arrays.toString(TransportMode.values())));
+ }
+ }
+
+ /**
+ * The address to bind socket channels to. Default to null.
+ *
+ * @return The bind address or null.
+ */
+ public SocketAddress getBindAddress() {
+ final String bindAddress = getHelpedParameters().getFirstValue("bindAddress", null);
+ final String bindPort = getHelpedParameters().getFirstValue("bindPort", null);
+
+ if ((bindAddress != null) && (bindPort != null)) {
+ return new InetSocketAddress(bindAddress, Integer.parseInt(bindPort));
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * The max time in milliseconds a connection can take to connect to destinations. Defaults to
+ * 15_000.
+ *
+ * @return The connect timeout.
+ */
+ public long getConnectTimeout() {
+ return Long.parseLong(getHelpedParameters().getFirstValue("connectTimeout", "15000"));
+ }
+
+ /**
+ * Number of event loop threads doing network I/O. Defaults to 0, letting Netty decide.
+ *
+ * @return Number of event loop threads.
+ */
+ public int getEventLoopWorkerThreads() {
+ return Integer.parseInt(
+ getHelpedParameters().getFirstValue("eventLoop.workerThreads", "0"));
+ }
+
+ /**
+ * Returns the HTTP client transport mode among the following options: "HTTP1_1", "HTTP2",
+ * "HTTP3" (experimental) and "DYNAMIC". Default to "HTTP1_1".
+ *
+ * @return The HTTP client transport mode.
+ */
+ public String getHttpClientTransportMode() {
+ return getHelpedParameters()
+ .getFirstValue("httpClientTransportMode", TransportMode.HTTP1_1.name());
+ }
+
+ /**
+ * The max time in milliseconds a connection can be idle (that is, without traffic of bytes in
+ * either direction). Defaults to 30_000.
+ *
+ * @return The idle timeout.
+ */
+ public long getIdleTimeout() {
+ return Long.parseLong(getHelpedParameters().getFirstValue("idleTimeout", "30000"));
+ }
+
+ /**
+ * The max number of HTTP/1.1 connections to open to each destination. Defaults to 64.
+ *
+ * @return The maximum connections per destination.
+ */
+ public int getMaxConnectionsPerDestination() {
+ return Integer.parseInt(
+ getHelpedParameters().getFirstValue("maxConnectionsPerDestination", "64"));
+ }
+
+ /**
+ * The max number of HTTP redirects that are followed. Defaults to 8.
+ *
+ * @return The maximum redirects.
+ */
+ public int getMaxRedirects() {
+ return Integer.parseInt(getHelpedParameters().getFirstValue("maxRedirects", "8"));
+ }
+
+ /**
+ * Returns the max size in bytes of the response status line and headers. Defaults to 8192.
+ *
+ * @return The max size in bytes of the response headers.
+ */
+ public int getMaxResponseHeaderSize() {
+ return Integer.parseInt(
+ getHelpedParameters().getFirstValue("maxResponseHeaderSize", "8192"));
+ }
+
+ /**
+ * Returns the host name of the HTTP proxy, if specified.
+ *
+ * @return the host name of the HTTP proxy, if specified.
+ */
+ public String getProxyHost() {
+ return getHelpedParameters()
+ .getFirstValue("proxyHost", System.getProperty("http.proxyHost"));
+ }
+
+ /**
+ * Returns the port of the HTTP proxy, if specified, 3128 otherwise.
+ *
+ * @return the port of the HTTP proxy.
+ */
+ public int getProxyPort() {
+ return Integer.parseInt(
+ getHelpedParameters()
+ .getFirstValue("proxyPort", System.getProperty("http.proxyPort", "3128")));
+ }
+
+ /**
+ * The size in bytes of the buffer used to write request entities. Defaults to 8192.
+ *
+ * @return The request buffer size.
+ */
+ public int getRequestBufferSize() {
+ return Integer.parseInt(getHelpedParameters().getFirstValue("requestBufferSize", "8192"));
+ }
+
+ /**
+ * The "User-Agent" HTTP header string. When null, uses the Restlet default. Default to null.
+ *
+ * @return The user agent field or null.
+ */
+ public String getUserAgentField() {
+ return getHelpedParameters().getFirstValue("userAgentField", null);
+ }
+
+ /**
+ * Whether to follow HTTP redirects. Defaults to true.
+ *
+ * @return Whether to follow redirects.
+ */
+ public boolean isFollowRedirects() {
+ return Boolean.parseBoolean(getHelpedParameters().getFirstValue("followRedirects", "true"));
+ }
+
+ /**
+ * Whether to check that the server certificate matches the host name. Defaults to true.
+ *
+ * @return Whether to verify host names.
+ */
+ public boolean isHostnameVerification() {
+ return Boolean.parseBoolean(
+ getHelpedParameters().getFirstValue("hostnameVerification", "true"));
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyFutures.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyFutures.java
new file mode 100644
index 0000000000..dfb81085ca
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyFutures.java
@@ -0,0 +1,92 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelFuture;
+import io.netty.util.concurrent.Future;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.net.ConnectException;
+import java.util.concurrent.TimeUnit;
+
+/** Blocking waits on Netty futures, for the calling (virtual) thread of a client call. */
+final class NettyFutures {
+
+ private NettyFutures() {}
+
+ /**
+ * Waits for a future and returns its result.
+ *
+ * @param future The future.
+ * @param timeoutMs The timeout, 0 for none.
+ * @param message The error message prefix.
+ * @return The result.
+ * @throws IOException If the future fails, times out or the wait is interrupted.
+ */
+ static T await(Future future, long timeoutMs, String message) throws IOException {
+ try {
+ boolean done =
+ (timeoutMs > 0)
+ ? future.await(timeoutMs, TimeUnit.MILLISECONDS)
+ : future.await().isDone();
+ if (!done) {
+ future.cancel(false);
+ throw new ConnectException(message + ": timed out");
+ }
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ throw new InterruptedIOException(message + ": interrupted");
+ }
+ if (!future.isSuccess()) {
+ Throwable cause = future.cause();
+ if (cause instanceof ConnectException ce) {
+ throw ce;
+ }
+ ConnectException ce = new ConnectException(message + ": " + cause);
+ ce.initCause(cause);
+ throw ce;
+ }
+ return future.getNow();
+ }
+
+ /**
+ * Waits for a channel future, closing the channel on failure.
+ *
+ * @param future The channel future.
+ * @param timeoutMs The timeout, 0 for none.
+ * @param message The error message prefix.
+ * @return The channel.
+ * @throws IOException If the future fails, times out or the wait is interrupted.
+ */
+ static Channel await(ChannelFuture future, long timeoutMs, String message) throws IOException {
+ try {
+ boolean done =
+ (timeoutMs > 0)
+ ? future.await(timeoutMs, TimeUnit.MILLISECONDS)
+ : future.await().isDone();
+ if (!done) {
+ future.cancel(false);
+ future.channel().close();
+ throw new ConnectException(message + ": timed out");
+ }
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ future.channel().close();
+ throw new InterruptedIOException(message + ": interrupted");
+ }
+ if (!future.isSuccess()) {
+ Throwable cause = future.cause();
+ ConnectException ce = new ConnectException(message + ": " + cause);
+ ce.initCause(cause);
+ throw ce;
+ }
+ return future.channel();
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java
new file mode 100644
index 0000000000..e631df936d
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java
@@ -0,0 +1,369 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelFuture;
+import io.netty.channel.ChannelFutureListener;
+import io.netty.handler.codec.http.DefaultHttpContent;
+import io.netty.handler.codec.http.DefaultHttpResponse;
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpHeaderValues;
+import io.netty.handler.codec.http.HttpRequest;
+import io.netty.handler.codec.http.HttpResponse;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.netty.handler.codec.http.HttpUtil;
+import io.netty.handler.codec.http.HttpVersion;
+import io.netty.handler.codec.http.LastHttpContent;
+import io.netty.handler.ssl.SslHandler;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.nio.channels.ClosedChannelException;
+import java.security.cert.Certificate;
+import java.util.Arrays;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Map;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+import org.restlet.data.Header;
+import org.restlet.engine.adapter.ServerCall;
+import org.restlet.engine.header.HeaderConstants;
+import org.restlet.engine.ssl.SslUtils;
+import org.restlet.util.Series;
+
+/**
+ * Server call bridging a Netty HTTP exchange (HTTP/1.1, or one HTTP/2 stream converted to HTTP/1
+ * objects) to the Restlet engine. The request body is exposed as a blocking stream fed by the event
+ * loop; the response body is written through a blocking stream with back-pressure.
+ *
+ * @author Jerome Louvel
+ */
+class NettyServerCall extends ServerCall {
+
+ /** The channel carrying this exchange (a child stream channel for HTTP/2 and HTTP/3). */
+ private final Channel channel;
+
+ /** The parent connection channel, used for addresses and TLS information. */
+ private final Channel connection;
+
+ /** The kind of channel carrying the exchange. */
+ private final StreamKind kind;
+
+ /** Indicates whether the exchange is carried by a stream of a multiplexed connection. */
+ private final boolean http2;
+
+ /** The Netty request head. */
+ private final HttpRequest request;
+
+ /** The request body stream fed by the event loop. */
+ private final ChannelInputStream requestBody;
+
+ /** The response output buffer size. */
+ private final int outputBufferSize;
+
+ /** The write timeout in milliseconds. */
+ private final long writeTimeoutMs;
+
+ /** Indicates whether the request headers were copied. */
+ private volatile boolean requestHeadersAdded;
+
+ /** Indicates whether the response head was written. */
+ private volatile boolean responseHeadWritten;
+
+ /** Indicates whether the exchange was completed. */
+ private volatile boolean completed;
+
+ /** The response body stream, if any. */
+ private volatile ChannelOutputStream responseBody;
+
+ /** The "Alt-Svc" header value advertising HTTP/3, or null. */
+ private volatile String altSvc;
+
+ /**
+ * Constructor.
+ *
+ * @param helper The parent server helper, providing the server and the output settings.
+ * @param channel The channel carrying the exchange.
+ * @param kind The kind of channel carrying the exchange.
+ * @param request The Netty request head.
+ * @param requestBody The request body stream.
+ * @param confidential Whether the connection is secured with TLS.
+ */
+ NettyServerCall(
+ NettyServerHelper helper,
+ Channel channel,
+ StreamKind kind,
+ HttpRequest request,
+ ChannelInputStream requestBody,
+ boolean confidential) {
+ super(helper.getHelped());
+ this.channel = channel;
+ this.kind = kind;
+ this.http2 = kind.isStream();
+ this.connection = this.http2 ? channel.parent() : channel;
+ this.request = request;
+ this.requestBody = requestBody;
+ this.outputBufferSize = helper.getHttpOutputBufferSize();
+ this.writeTimeoutMs = helper.getConnectorIdleTimeout();
+ setConfidential(confidential);
+ setMethod(request.method().name());
+ setRequestUri(request.uri());
+ setVersion(kind.version());
+
+ InetSocketAddress remote = toInet(remoteAddress());
+ if (remote != null) {
+ setClientAddress(remote.getAddress().getHostAddress());
+ setClientPort(remote.getPort());
+ }
+ InetSocketAddress local = toInet(localAddress());
+ if (local != null) {
+ setServerAddress(local.getAddress().getHostAddress());
+ setServerPort(local.getPort());
+ }
+ }
+
+ /** The UDP address of a QUIC connection, or the TCP address of other connections. */
+ private SocketAddress remoteAddress() {
+ return (kind == StreamKind.HTTP3)
+ ? Http3Support.remoteSocketAddress(connection)
+ : connection.remoteAddress();
+ }
+
+ private SocketAddress localAddress() {
+ return (kind == StreamKind.HTTP3)
+ ? Http3Support.localSocketAddress(connection)
+ : connection.localAddress();
+ }
+
+ private static InetSocketAddress toInet(SocketAddress address) {
+ return (address instanceof InetSocketAddress inet) ? inet : null;
+ }
+
+ /**
+ * Sets the "Alt-Svc" header value advertising HTTP/3, added to the response unless the
+ * application sets one.
+ *
+ * @param altSvc The "Alt-Svc" header value, or null.
+ */
+ void setAltSvc(String altSvc) {
+ this.altSvc = altSvc;
+ }
+
+ @Override
+ public boolean abort() {
+ connection.close();
+ return true;
+ }
+
+ @Override
+ public void complete() {
+ if (completed) {
+ return;
+ }
+ completed = true;
+
+ try {
+ if (!responseHeadWritten) {
+ writeHead(true);
+ }
+ if (responseBody != null) {
+ responseBody.close();
+ }
+ } catch (IOException e) {
+ getLogger().fine("Unable to complete the response: " + e.getMessage());
+ }
+
+ if (!channel.isActive()) {
+ requestBody.close();
+ return;
+ }
+
+ boolean keepAlive = HttpUtil.isKeepAlive(request) && requestBody.isFullyConsumed();
+ ChannelFuture future = channel.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT);
+ requestBody.close();
+
+ if (!http2 && !keepAlive) {
+ future.addListener(ChannelFutureListener.CLOSE);
+ } else if (!http2) {
+ // Ready for the next request on this connection.
+ future.addListener(f -> channel.read());
+ }
+ }
+
+ @Override
+ public void flushBuffers() throws IOException {
+ if (responseBody != null) {
+ responseBody.flush();
+ } else {
+ channel.flush();
+ }
+ }
+
+ @Override
+ public List getCertificates() {
+ SSLSession session = sslSession();
+ if (session != null) {
+ try {
+ return Arrays.asList(session.getPeerCertificates());
+ } catch (SSLPeerUnverifiedException _) {
+ return List.of();
+ }
+ }
+ return List.of();
+ }
+
+ @Override
+ public String getCipherSuite() {
+ SSLSession session = sslSession();
+ return (session != null) ? session.getCipherSuite() : null;
+ }
+
+ @Override
+ public InputStream getRequestEntityStream(long size) {
+ return requestBody;
+ }
+
+ @Override
+ public Series getRequestHeaders() {
+ final Series result = super.getRequestHeaders();
+
+ if (!this.requestHeadersAdded) {
+ for (Map.Entry header : request.headers()) {
+ String name = header.getKey();
+ // HTTP/2 pseudo-headers are not HTTP headers.
+ if (!name.startsWith(":")) {
+ result.add(name, header.getValue());
+ }
+ }
+ // HTTP/2 carries the host in the :authority pseudo-header, which the codec maps to a
+ // regular "host" header only when absent; make sure Restlet sees a Host header.
+ if (result.getFirstValue(HeaderConstants.HEADER_HOST, true) == null) {
+ String authority = request.headers().get(":authority");
+ if (authority != null) {
+ result.add(HeaderConstants.HEADER_HOST, authority);
+ }
+ }
+ this.requestHeadersAdded = true;
+ }
+
+ return result;
+ }
+
+ @Override
+ public InputStream getRequestHeadStream() {
+ return null;
+ }
+
+ @Override
+ public OutputStream getResponseEntityStream() {
+ if (responseBody == null) {
+ responseBody =
+ new ChannelOutputStream(
+ channel, DefaultHttpContent::new, outputBufferSize, writeTimeoutMs);
+ }
+ return responseBody;
+ }
+
+ @Override
+ public Integer getSslKeySize() {
+ String cipherSuite = getCipherSuite();
+ return (cipherSuite != null) ? SslUtils.extractKeySize(cipherSuite) : null;
+ }
+
+ @Override
+ protected byte[] getSslSessionIdBytes() {
+ SSLSession session = sslSession();
+ return (session != null) ? session.getId() : null;
+ }
+
+ @Override
+ public String getSslSessionId() {
+ byte[] id = getSslSessionIdBytes();
+ return (id != null && id.length > 0) ? HexFormat.of().formatHex(id) : null;
+ }
+
+ @Override
+ public boolean isConnectionBroken(Throwable exception) {
+ return (exception instanceof ClosedChannelException)
+ || !channel.isActive()
+ || super.isConnectionBroken(exception);
+ }
+
+ @Override
+ public void sendResponse(org.restlet.Response response) throws IOException {
+ if (!channel.isActive()) {
+ throw new IOException("Connection closed by peer");
+ }
+
+ boolean hasEntity = (response != null) && (response.getEntity() != null);
+ writeHead(!hasEntity);
+ super.sendResponse(response);
+ }
+
+ /**
+ * Writes the response head. Must be called once, before any body byte.
+ *
+ * @param emptyBody True if no body follows.
+ */
+ private void writeHead(boolean emptyBody) {
+ if (responseHeadWritten) {
+ return;
+ }
+ responseHeadWritten = true;
+
+ HttpResponseStatus status =
+ (getReasonPhrase() != null && !getReasonPhrase().isEmpty())
+ ? new HttpResponseStatus(getStatusCode(), getReasonPhrase())
+ : HttpResponseStatus.valueOf(getStatusCode());
+ HttpResponse head = new DefaultHttpResponse(HttpVersion.HTTP_1_1, status);
+
+ for (Header header : getResponseHeaders()) {
+ head.headers().add(header.getName(), header.getValue());
+ }
+
+ if (altSvc != null && !head.headers().contains("Alt-Svc")) {
+ head.headers().set("Alt-Svc", altSvc);
+ }
+
+ boolean hasLength = head.headers().contains(HttpHeaderNames.CONTENT_LENGTH);
+ boolean noBodyStatus =
+ getStatusCode() == 204 || getStatusCode() == 304 || getStatusCode() < 200;
+
+ if (http2) {
+ // Connection-specific headers are forbidden in HTTP/2.
+ head.headers().remove(HttpHeaderNames.CONNECTION);
+ head.headers().remove(HttpHeaderNames.TRANSFER_ENCODING);
+ head.headers().remove("Keep-Alive");
+ } else if (emptyBody) {
+ if (!hasLength && !noBodyStatus && !"HEAD".equals(getMethod())) {
+ head.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0);
+ }
+ } else if (!hasLength) {
+ head.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED);
+ }
+
+ if (!http2 && !HttpUtil.isKeepAlive(request)) {
+ head.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
+ }
+
+ channel.write(head);
+ }
+
+ private SSLSession sslSession() {
+ if (kind == StreamKind.HTTP3) {
+ return Http3Support.sslSession(connection);
+ }
+ SslHandler ssl = connection.pipeline().get(SslHandler.class);
+ return (ssl != null) ? ssl.engine().getSession() : null;
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHandler.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHandler.java
new file mode 100644
index 0000000000..661fa85cca
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHandler.java
@@ -0,0 +1,219 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+import io.netty.handler.codec.DecoderResult;
+import io.netty.handler.codec.http.DefaultFullHttpResponse;
+import io.netty.handler.codec.http.HttpContent;
+import io.netty.handler.codec.http.HttpHeaderNames;
+import io.netty.handler.codec.http.HttpHeaderValues;
+import io.netty.handler.codec.http.HttpRequest;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.netty.handler.codec.http.HttpUtil;
+import io.netty.handler.codec.http.HttpVersion;
+import io.netty.handler.codec.http.LastHttpContent;
+import io.netty.handler.timeout.IdleStateEvent;
+import io.netty.util.ReferenceCountUtil;
+import java.io.IOException;
+import java.nio.channels.ClosedChannelException;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.logging.Level;
+import org.restlet.engine.Engine;
+
+/**
+ * Inbound handler that converts Netty HTTP messages into {@link NettyServerCall}s and runs them on
+ * the call executor. One instance per HTTP/1.1 connection, or per HTTP/2 or HTTP/3 stream.
+ *
+ *
HTTP/1.1 requests on a connection are processed one at a time: reading is suspended while a
+ * call is in progress and resumed when its response is complete. This keeps pipelined requests in
+ * order without buffering them.
+ */
+class NettyServerHandler extends ChannelInboundHandlerAdapter {
+
+ private final NettyServerHelper helper;
+
+ private final boolean confidential;
+
+ /** The kind of channel this handler is attached to. */
+ private final StreamKind kind;
+
+ /** The body stream of the request in progress, if any. */
+ private ChannelInputStream currentBody;
+
+ NettyServerHandler(NettyServerHelper helper, boolean confidential, StreamKind kind) {
+ this.helper = helper;
+ this.confidential = confidential;
+ this.kind = kind;
+ }
+
+ @Override
+ public void channelActive(ChannelHandlerContext ctx) throws Exception {
+ // Read on demand: one request at a time per HTTP/1.1 connection, and body back-pressure
+ // for both HTTP/1.1 connections and HTTP/2 streams.
+ ctx.channel().config().setAutoRead(false);
+ ctx.read();
+ super.channelActive(ctx);
+ }
+
+ @Override
+ public void handlerAdded(ChannelHandlerContext ctx) throws Exception {
+ // Stream channels (HTTP/2, HTTP/3) are already active when the handler is added.
+ if (kind.isStream() && ctx.channel().isActive()) {
+ ctx.channel().config().setAutoRead(false);
+ ctx.read();
+ }
+ super.handlerAdded(ctx);
+ }
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) {
+ try {
+ if (msg instanceof HttpRequest request) {
+ onRequest(ctx, request);
+ }
+ if (msg instanceof HttpContent content) {
+ onContent(ctx, content);
+ }
+ } finally {
+ if (!(msg instanceof HttpContent)) {
+ ReferenceCountUtil.release(msg);
+ }
+ }
+ }
+
+ private void onRequest(ChannelHandlerContext ctx, HttpRequest request) {
+ DecoderResult result = request.decoderResult();
+ if (result.isFailure()) {
+ sendError(ctx, HttpResponseStatus.BAD_REQUEST);
+ return;
+ }
+
+ if (HttpUtil.is100ContinueExpected(request)) {
+ ctx.writeAndFlush(
+ new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.CONTINUE));
+ request.headers().remove(HttpHeaderNames.EXPECT);
+ }
+
+ ChannelInputStream body =
+ new ChannelInputStream(ctx.channel(), helper.getConnectorIdleTimeout());
+ currentBody = body;
+
+ NettyServerCall call =
+ new NettyServerCall(helper, ctx.channel(), kind, request, body, confidential);
+ if (kind != StreamKind.HTTP3) {
+ call.setAltSvc(helper.getAltSvc());
+ }
+
+ if (!helper.tryAcquireCall()) {
+ body.close();
+ currentBody = null;
+ sendError(ctx, HttpResponseStatus.SERVICE_UNAVAILABLE);
+ return;
+ }
+
+ try {
+ helper.getCallExecutor().execute(() -> runCall(call));
+ } catch (RejectedExecutionException _) {
+ helper.releaseCall();
+ body.close();
+ currentBody = null;
+ sendError(ctx, HttpResponseStatus.SERVICE_UNAVAILABLE);
+ }
+
+ // Keep reading the body of this request (if any) without waiting for the call.
+ ctx.read();
+ }
+
+ private void onContent(ChannelHandlerContext ctx, HttpContent content) {
+ ChannelInputStream body = currentBody;
+ if (body == null) {
+ content.release();
+ return;
+ }
+
+ boolean readMore = true;
+ if (content.content().isReadable()) {
+ readMore = body.offer(content.content().retain());
+ }
+ boolean last = content instanceof LastHttpContent;
+ content.release();
+
+ if (last) {
+ body.end();
+ currentBody = null;
+ // Do not read the next HTTP/1.1 request until the current response is complete; the
+ // call requests the next read when it completes.
+ } else if (readMore) {
+ ctx.read();
+ }
+ // Otherwise the body stream requests the next read once the consumer catches up.
+ }
+
+ private void runCall(NettyServerCall call) {
+ try {
+ helper.handle(call);
+ } catch (RuntimeException | LinkageError | AssertionError e) {
+ // Keep the connection consistent whatever the application throws; other errors
+ // (VirtualMachineError, ...) are left to propagate after the cleanup below.
+ helper.getLogger().log(Level.WARNING, "Error while handling an HTTP server call", e);
+ call.abort();
+ } finally {
+ try {
+ call.complete();
+ } finally {
+ helper.releaseCall();
+ Engine.clearThreadLocalVariables();
+ }
+ }
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ failBody(new ClosedChannelException());
+ super.channelInactive(ctx);
+ }
+
+ @Override
+ public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
+ if (evt instanceof IdleStateEvent) {
+ ctx.close();
+ } else {
+ super.userEventTriggered(ctx, evt);
+ }
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+ if (cause instanceof IOException) {
+ helper.getLogger().log(Level.FINE, "Connection error", cause);
+ } else {
+ helper.getLogger().log(Level.WARNING, "Unexpected error on HTTP connection", cause);
+ }
+ failBody(cause);
+ ctx.close();
+ }
+
+ private void failBody(Throwable cause) {
+ ChannelInputStream body = currentBody;
+ if (body != null) {
+ body.fail(cause);
+ currentBody = null;
+ }
+ }
+
+ private static void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {
+ DefaultFullHttpResponse response =
+ new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status);
+ response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0);
+ response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
+ ctx.writeAndFlush(response).addListener(f -> ctx.close());
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHelper.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHelper.java
new file mode 100644
index 0000000000..44e1b7b151
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerHelper.java
@@ -0,0 +1,907 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandler;
+import io.netty.channel.ChannelHandlerAdapter;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInitializer;
+import io.netty.channel.ChannelOption;
+import io.netty.channel.ChannelPipeline;
+import io.netty.channel.EventLoopGroup;
+import io.netty.channel.MultiThreadIoEventLoopGroup;
+import io.netty.channel.group.ChannelGroup;
+import io.netty.channel.group.DefaultChannelGroup;
+import io.netty.channel.nio.NioIoHandler;
+import io.netty.channel.socket.SocketChannel;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import io.netty.handler.codec.http.HttpServerCodec;
+import io.netty.handler.codec.http.HttpServerKeepAliveHandler;
+import io.netty.handler.codec.http.HttpServerUpgradeHandler;
+import io.netty.handler.codec.http2.CleartextHttp2ServerUpgradeHandler;
+import io.netty.handler.codec.http2.Http2CodecUtil;
+import io.netty.handler.codec.http2.Http2FrameCodecBuilder;
+import io.netty.handler.codec.http2.Http2MultiplexHandler;
+import io.netty.handler.codec.http2.Http2ServerUpgradeCodec;
+import io.netty.handler.codec.http2.Http2StreamFrameToHttpObjectCodec;
+import io.netty.handler.ssl.ApplicationProtocolConfig;
+import io.netty.handler.ssl.ApplicationProtocolNegotiationHandler;
+import io.netty.handler.ssl.ClientAuth;
+import io.netty.handler.ssl.SslContext;
+import io.netty.handler.timeout.IdleStateHandler;
+import io.netty.util.AsciiString;
+import io.netty.util.concurrent.GlobalEventExecutor;
+import java.io.IOException;
+import java.net.BindException;
+import java.net.InetSocketAddress;
+import java.security.GeneralSecurityException;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.logging.Level;
+import javax.net.ssl.SSLContext;
+import org.restlet.Server;
+import org.restlet.engine.adapter.HttpServerHelper;
+import org.restlet.engine.ssl.DefaultSslContextFactory;
+import org.restlet.engine.ssl.SslContextFactory;
+import org.restlet.engine.ssl.SslUtils;
+
+/**
+ * Base class for the Netty HTTP and HTTPS server connectors. Here is the list of parameters that
+ * are supported. They should be set in the Server's context before it is started:
+ *
+ *
+ *
list of supported parameters
+ *
+ *
Parameter name
+ *
Value type
+ *
Default value
+ *
Description
+ *
+ *
+ *
threadPool.virtual
+ *
boolean
+ *
true
+ *
When true, each call runs on its own virtual thread. When false, calls run on a pool of
+ * platform threads configured by the other "threadPool.*" parameters.
+ *
+ *
+ *
threadPool.minThreads
+ *
int
+ *
8
+ *
Platform thread pool minimum threads. Ignored with virtual threads.
+ *
+ *
+ *
threadPool.maxThreads
+ *
int
+ *
200
+ *
Platform thread pool maximum threads. Ignored with virtual threads.
+ *
+ *
+ *
threadPool.threadsPriority
+ *
int
+ *
{@link Thread#NORM_PRIORITY}
+ *
Platform thread pool threads priority. Ignored with virtual threads.
+ *
+ *
+ *
threadPool.idleTimeout
+ *
int
+ *
60000
+ *
Platform thread pool idle timeout in milliseconds. Ignored with virtual threads.
+ *
+ *
+ *
server.maxConcurrentCalls
+ *
int
+ *
0
+ *
Maximum number of calls processed concurrently; extra calls receive a 503 response. When
+ * 0, there is no limit.
+ *
+ *
+ *
eventLoop.bossThreads
+ *
int
+ *
1
+ *
Number of event loop threads accepting connections.
+ *
+ *
+ *
eventLoop.workerThreads
+ *
int
+ *
0
+ *
Number of event loop threads doing network I/O. When 0, Netty uses twice the number of
+ * available processors.
+ *
+ *
+ *
connector.acceptQueueSize
+ *
int
+ *
0
+ *
Accept queue size (backlog). When 0, the operating system default is used.
+ *
+ *
+ *
connector.idleTimeout
+ *
int
+ *
30000
+ *
Connection idle timeout in milliseconds; idle connections are closed. When 0, there is no
+ * timeout.
+ *
+ *
+ *
http.requestHeaderSize
+ *
int
+ *
8192
+ *
Maximum size in bytes of the request line and headers.
+ *
+ *
+ *
http.outputBufferSize
+ *
int
+ *
32768
+ *
Size in bytes of the response output buffer.
+ *
+ *
+ *
server.maxConnections
+ *
int
+ *
0
+ *
Maximum number of open connections; extra connections are closed on accept. When 0, there
+ * is no limit.
+ *
+ *
+ *
shutdown.gracefully
+ *
boolean
+ *
true
+ *
When true, on stop the server stops accepting connections and waits up to
+ * "shutdown.timeout" for calls in progress to complete. Otherwise, connections are closed
+ * immediately.
+ *
+ *
+ *
shutdown.timeout
+ *
long
+ *
0
+ *
Graceful shutdown timeout in milliseconds.
+ *
+ *
+ *
+ * @author Jerome Louvel
+ */
+public abstract class NettyServerHelper extends HttpServerHelper {
+
+ /** The event loop group accepting connections. */
+ private volatile EventLoopGroup bossGroup;
+
+ /** The event loop group doing network I/O. */
+ private volatile EventLoopGroup workerGroup;
+
+ /** The executor running Restlet calls. */
+ private volatile ExecutorService callExecutor;
+
+ /** The server channel. */
+ private volatile Channel serverChannel;
+
+ /** All open connections. */
+ private volatile ChannelGroup connections;
+
+ /** Limits concurrent calls, or null when unlimited. */
+ private volatile Semaphore callPermits;
+
+ /** The number of calls in progress. */
+ private final AtomicInteger activeCalls = new AtomicInteger();
+
+ /** Guards the wait for the calls in progress to complete, on graceful stop. */
+ private final ReentrantLock activeCallsLock = new ReentrantLock();
+
+ /** Signaled when the last call in progress completes. */
+ private final Condition noActiveCalls = activeCallsLock.newCondition();
+
+ /** The application protocols, set on start. */
+ private volatile List applicationProtocols = List.of(HTTP_1_1);
+
+ /** The Netty SSL context, set on start when confidential. */
+ private volatile SslContext sslContext;
+
+ /** The UDP channel of the experimental HTTP/3 server, or null. */
+ private volatile Channel http3Channel;
+
+ /** The "Alt-Svc" header value advertising HTTP/3, or null. */
+ private volatile String altSvc;
+
+ /** Indicates whether the server is stopping. */
+ private volatile boolean stopping;
+
+ /**
+ * Constructor.
+ *
+ * @param server The server to help.
+ */
+ protected NettyServerHelper(Server server) {
+ super(server);
+ }
+
+ /** Name of the Restlet handler in a connection pipeline. */
+ private static final String RESTLET_HANDLER = "restlet";
+
+ /** Name of the HTTP/1.1 keep-alive handler in a connection pipeline. */
+ private static final String KEEPALIVE_HANDLER = "http-keepalive";
+
+ /** HTTP/1.1 application protocol identifier. */
+ protected static final String HTTP_1_1 = "http/1.1";
+
+ /** HTTP/2 application protocol identifier. */
+ protected static final String HTTP_2 = "h2";
+
+ /**
+ * HTTP/3 application protocol identifier. Experimental: HTTP/3 is served over QUIC on the UDP
+ * port having the same number as the TCP port, and advertised with the "Alt-Svc" header.
+ */
+ protected static final String HTTP_3 = "h3";
+
+ /**
+ * Returns the supported application protocols, in preference order, using their ALPN
+ * identifiers: {@link #HTTP_1_1}, {@link #HTTP_2} and {@link #HTTP_3}. Over TLS, HTTP/1.1 and
+ * HTTP/2 are negotiated with ALPN, and HTTP/3 is served over QUIC. Without TLS, {@link #HTTP_2}
+ * enables cleartext HTTP/2 (h2c), with the HTTP/1.1 "Upgrade" mechanism or with prior
+ * knowledge, in addition to HTTP/1.1.
+ *
+ * @return The supported application protocols.
+ */
+ protected abstract List getApplicationProtocols();
+
+ /**
+ * Indicates whether connections are secured with TLS. The TLS settings come from the {@link
+ * org.restlet.engine.ssl.SslContextFactory} of the connector.
+ *
+ * @return True if connections are secured with TLS.
+ */
+ protected abstract boolean isConfidential();
+
+ /**
+ * Validates the configuration before starting. Called by {@link #start()}.
+ *
+ * @throws IllegalArgumentException If the configuration is invalid.
+ */
+ protected void validateConfiguration() {}
+
+ /**
+ * Configures the pipeline of a new connection.
+ *
+ * @param channel The new connection.
+ */
+ private void initConnection(SocketChannel channel) {
+ ChannelPipeline pipeline = channel.pipeline();
+ final List protocols = this.applicationProtocols;
+ boolean h2 = protocols.contains(HTTP_2);
+ SslContext ssl = this.sslContext;
+
+ if (ssl != null) {
+ pipeline.addLast("ssl", ssl.newHandler(channel.alloc()));
+ if (h2) {
+ pipeline.addLast("alpn", newAlpnHandler(protocols.contains(HTTP_1_1)));
+ } else {
+ addHttp1Handlers(pipeline);
+ }
+ } else if (h2) {
+ addCleartextHttp2Handlers(pipeline);
+ } else {
+ addHttp1Handlers(pipeline);
+ }
+ }
+
+ /**
+ * Creates the handler that configures the pipeline once ALPN selected HTTP/2 or HTTP/1.1.
+ *
+ * @param http11 True if HTTP/1.1 is also supported.
+ * @return The ALPN handler.
+ */
+ private ApplicationProtocolNegotiationHandler newAlpnHandler(final boolean http11) {
+ return new ApplicationProtocolNegotiationHandler(http11 ? HTTP_1_1 : HTTP_2) {
+ @Override
+ protected void configurePipeline(ChannelHandlerContext ctx, String protocol) {
+ if (HTTP_2.equals(protocol)) {
+ addHttp2Handlers(ctx.pipeline());
+ } else if (http11 && HTTP_1_1.equals(protocol)) {
+ addHttp1Handlers(ctx.pipeline());
+ } else {
+ throw new IllegalStateException(
+ "Unsupported application protocol: " + protocol);
+ }
+ }
+ };
+ }
+
+ /**
+ * Creates the Netty SSL context from the Restlet SSL context factory.
+ *
+ * @param protocols The application protocols to negotiate.
+ * @return The Netty SSL context.
+ * @throws Exception If the SSL context can't be created.
+ */
+ private SslContext createSslContext(List protocols) throws Exception {
+ SslContextFactory factory = SslUtils.getSslContextFactory(this);
+ SSLContext jdkContext = factory.createSslContext();
+
+ ClientAuth clientAuth = ClientAuth.NONE;
+ if (factory instanceof DefaultSslContextFactory defaultFactory) {
+ if (defaultFactory.isNeedClientAuthentication()) {
+ clientAuth = ClientAuth.REQUIRE;
+ } else if (defaultFactory.isWantClientAuthentication()) {
+ clientAuth = ClientAuth.OPTIONAL;
+ }
+ }
+
+ ApplicationProtocolConfig alpn =
+ protocols.contains(HTTP_2)
+ ? NettySsl.alpn(protocols)
+ : ApplicationProtocolConfig.DISABLED;
+ return NettySsl.newContext(jdkContext, false, alpn, clientAuth);
+ }
+
+ /**
+ * Adds the HTTP/1.1 handlers to a connection pipeline.
+ *
+ * @param pipeline The pipeline.
+ */
+ private void addHttp1Handlers(ChannelPipeline pipeline) {
+ pipeline.addLast("http-codec", newHttpServerCodec());
+ pipeline.addLast(KEEPALIVE_HANDLER, new HttpServerKeepAliveHandler());
+ pipeline.addLast(
+ RESTLET_HANDLER, new NettyServerHandler(this, isConfidential(), StreamKind.HTTP1));
+ }
+
+ /**
+ * Adds HTTP/2 handlers to a connection pipeline, after protocol negotiation (ALPN or prior
+ * knowledge).
+ *
+ * @param pipeline The pipeline.
+ */
+ private void addHttp2Handlers(ChannelPipeline pipeline) {
+ pipeline.addLast("h2-frame-codec", Http2FrameCodecBuilder.forServer().build());
+ pipeline.addLast("h2-multiplex", new Http2MultiplexHandler(newHttp2StreamInitializer()));
+ }
+
+ /**
+ * Adds handlers supporting both HTTP/1.1 and cleartext HTTP/2 (h2c), either via the HTTP/1.1
+ * "Upgrade" mechanism or via prior knowledge.
+ *
+ * @param pipeline The pipeline.
+ */
+ private void addCleartextHttp2Handlers(ChannelPipeline pipeline) {
+ HttpServerCodec sourceCodec = newHttpServerCodec();
+ HttpServerUpgradeHandler.UpgradeCodecFactory upgradeCodecFactory =
+ protocol -> {
+ if (AsciiString.contentEquals(
+ Http2CodecUtil.HTTP_UPGRADE_PROTOCOL_NAME, protocol)) {
+ return new Http2ServerUpgradeCodec(
+ Http2FrameCodecBuilder.forServer().build(),
+ new Http2MultiplexHandler(newHttp2StreamInitializer()),
+ CLEARTEXT_HTTP2_SETUP);
+ }
+ return null;
+ };
+ HttpServerUpgradeHandler upgradeHandler =
+ new HttpServerUpgradeHandler(sourceCodec, upgradeCodecFactory);
+
+ // Prior knowledge: the connection starts directly with the HTTP/2 preface.
+ ChannelInitializer priorKnowledge =
+ new ChannelInitializer<>() {
+ @Override
+ protected void initChannel(Channel ch) {
+ ch.pipeline().addLast(Http2FrameCodecBuilder.forServer().build());
+ ch.pipeline()
+ .addLast(new Http2MultiplexHandler(newHttp2StreamInitializer()));
+ ch.pipeline().addLast(CLEARTEXT_HTTP2_SETUP);
+ }
+ };
+
+ pipeline.addLast(
+ "h2c",
+ new CleartextHttp2ServerUpgradeHandler(
+ sourceCodec, upgradeHandler, priorKnowledge));
+ // Requests that are not upgraded stay on HTTP/1.1.
+ pipeline.addLast(KEEPALIVE_HANDLER, new HttpServerKeepAliveHandler());
+ pipeline.addLast(
+ RESTLET_HANDLER, new NettyServerHandler(this, isConfidential(), StreamKind.HTTP1));
+ }
+
+ /**
+ * Switches a cleartext connection from HTTP/1.1 to HTTP/2: removes the HTTP/1.1 handlers and
+ * reads the connection continuously, the back-pressure being applied per stream.
+ */
+ private static final ChannelHandler CLEARTEXT_HTTP2_SETUP =
+ new ChannelHandlerAdapter() {
+ @Override
+ public boolean isSharable() {
+ return true;
+ }
+
+ @Override
+ public void handlerAdded(ChannelHandlerContext ctx) {
+ ChannelPipeline p = ctx.pipeline();
+ if (p.get(RESTLET_HANDLER) != null) {
+ p.remove(RESTLET_HANDLER);
+ }
+ if (p.get(KEEPALIVE_HANDLER) != null) {
+ p.remove(KEEPALIVE_HANDLER);
+ }
+ p.remove(this);
+ ctx.channel().config().setAutoRead(true);
+ }
+ };
+
+ private ChannelInitializer newHttp2StreamInitializer() {
+ return new ChannelInitializer<>() {
+ @Override
+ protected void initChannel(Channel stream) {
+ stream.pipeline().addLast(new Http2StreamFrameToHttpObjectCodec(true));
+ stream.pipeline().addLast(Writability.HANDLER);
+ stream.pipeline()
+ .addLast(
+ new NettyServerHandler(
+ NettyServerHelper.this,
+ isConfidential(),
+ StreamKind.HTTP2));
+ }
+ };
+ }
+
+ private HttpServerCodec newHttpServerCodec() {
+ int headerSize = getHttpRequestHeaderSize();
+ return new HttpServerCodec(4096, headerSize, 8192);
+ }
+
+ /**
+ * Tries to acquire a permit for a new call.
+ *
+ * @return True if the call may proceed.
+ */
+ boolean tryAcquireCall() {
+ if (stopping) {
+ return false;
+ }
+ Semaphore permits = callPermits;
+ if (permits != null && !permits.tryAcquire()) {
+ return false;
+ }
+ activeCalls.incrementAndGet();
+ return true;
+ }
+
+ /** Releases the permit of a completed call. */
+ void releaseCall() {
+ if (activeCalls.decrementAndGet() == 0) {
+ activeCallsLock.lock();
+ try {
+ noActiveCalls.signalAll();
+ } finally {
+ activeCallsLock.unlock();
+ }
+ }
+ Semaphore permits = callPermits;
+ if (permits != null) {
+ permits.release();
+ }
+ }
+
+ /**
+ * Returns the executor running Restlet calls.
+ *
+ * @return The executor running Restlet calls.
+ */
+ ExecutorService getCallExecutor() {
+ return callExecutor;
+ }
+
+ @Override
+ public synchronized void start() throws Exception {
+ validateConfiguration();
+ List protocols = getApplicationProtocols();
+ boolean http3 = protocols.contains(HTTP_3);
+ // HTTP/3 is not negotiated on TCP connections.
+ applicationProtocols = validateProtocols(protocols, http3);
+ sslContext = isConfidential() ? createSslContext(applicationProtocols) : null;
+ super.start();
+ stopping = false;
+
+ int maxCalls = getServerMaxConcurrentCalls();
+ callPermits = (maxCalls > 0) ? new Semaphore(maxCalls) : null;
+ callExecutor =
+ CallExecutors.create(
+ "restlet-server",
+ isThreadPoolVirtual(),
+ getThreadPoolMinThreads(),
+ getThreadPoolMaxThreads(),
+ getThreadPoolIdleTimeout(),
+ getThreadPoolThreadsPriority());
+
+ bossGroup =
+ new MultiThreadIoEventLoopGroup(
+ Math.max(1, getEventLoopBossThreads()), NioIoHandler.newFactory());
+ workerGroup =
+ new MultiThreadIoEventLoopGroup(
+ Math.max(0, getEventLoopWorkerThreads()), NioIoHandler.newFactory());
+ connections = new DefaultChannelGroup("restlet-connections", GlobalEventExecutor.INSTANCE);
+
+ final int maxConnections = getServerMaxConnections();
+ final int idleTimeout = getConnectorIdleTimeout();
+
+ ServerBootstrap bootstrap =
+ new ServerBootstrap()
+ .group(bossGroup, workerGroup)
+ .channel(NioServerSocketChannel.class)
+ .childOption(ChannelOption.TCP_NODELAY, true)
+ .childHandler(newConnectionInitializer(maxConnections, idleTimeout));
+
+ int backlog = getConnectorAcceptQueueSize();
+ if (backlog > 0) {
+ bootstrap.option(ChannelOption.SO_BACKLOG, backlog);
+ }
+
+ String address = getHelped().getAddress();
+ int port = getHelped().getPort();
+
+ getLogger()
+ .log(
+ Level.INFO,
+ "Starting the Netty {0} server on port {1}",
+ new Object[] {getProtocols(), port});
+ try {
+ bindTcpAndUdp(bootstrap, address, port, http3, maxConnections, idleTimeout);
+ } catch (Exception e) {
+ shutdownNow();
+ throw e;
+ }
+ }
+
+ /**
+ * Checks the HTTP/3 requirements and returns the protocols negotiated on TCP connections.
+ *
+ * @param protocols The configured application protocols.
+ * @param http3 True if HTTP/3 is configured.
+ * @return The application protocols of the TCP connections.
+ */
+ private List validateProtocols(List protocols, boolean http3) {
+ if (http3) {
+ if (!isConfidential()) {
+ throw new IllegalArgumentException("HTTP/3 requires TLS");
+ }
+ Http3Availability.ensureAvailable();
+ }
+ List result = protocols.stream().filter(p -> !HTTP_3.equals(p)).toList();
+ if (result.isEmpty()) {
+ throw new IllegalArgumentException("HTTP/3 must be combined with HTTP1_1 or HTTP2");
+ }
+ return result;
+ }
+
+ private static InetSocketAddress newAddress(String address, int port) {
+ return (address != null)
+ ? new InetSocketAddress(address, port)
+ : new InetSocketAddress(port);
+ }
+
+ /**
+ * Creates the initializer of accepted TCP connections, enforcing "server.maxConnections".
+ *
+ * @param maxConnections The maximum number of connections, 0 for no limit.
+ * @param idleTimeout The connection idle timeout in milliseconds, 0 for none.
+ * @return The initializer.
+ */
+ private ChannelInitializer newConnectionInitializer(
+ final int maxConnections, final int idleTimeout) {
+ return new ChannelInitializer<>() {
+ @Override
+ protected void initChannel(SocketChannel ch) {
+ if (!admitConnection(ch, maxConnections)) {
+ ch.close();
+ return;
+ }
+ ChannelPipeline p = ch.pipeline();
+ if (idleTimeout > 0) {
+ p.addLast(
+ "idle", new IdleStateHandler(0, 0, idleTimeout, TimeUnit.MILLISECONDS));
+ }
+ p.addLast("writability", Writability.HANDLER);
+ initConnection(ch);
+ }
+ };
+ }
+
+ /**
+ * Registers a new connection, unless the server is stopping or the connection limit is reached.
+ *
+ * @param connection The new connection.
+ * @param maxConnections The maximum number of connections, 0 for no limit.
+ * @return True if the connection is admitted.
+ */
+ private boolean admitConnection(Channel connection, int maxConnections) {
+ if (stopping || (maxConnections > 0 && connections.size() >= maxConnections)) {
+ return false;
+ }
+ connections.add(connection);
+ return true;
+ }
+
+ private void startHttp3(InetSocketAddress address, final int maxConnections, int idleTimeout)
+ throws GeneralSecurityException, IOException, InterruptedException {
+ getLogger()
+ .log(
+ Level.WARNING,
+ "HTTP/3 support is experimental. Serving HTTP/3 on UDP port {0}",
+ address.getPort());
+ http3Channel =
+ Http3Support.bindServer(
+ workerGroup,
+ address,
+ SslUtils.getSslContextFactory(this),
+ idleTimeout,
+ connection -> admitConnection(connection, maxConnections),
+ () -> new NettyServerHandler(this, true, StreamKind.HTTP3));
+ altSvc = "h3=\":" + address.getPort() + "\"; ma=86400";
+ }
+
+ /** Attempts to find a free port number for both TCP and UDP, when the port is ephemeral. */
+ private static final int HTTP3_EPHEMERAL_PORT_ATTEMPTS = 5;
+
+ /**
+ * Binds the TCP server channel, and the HTTP/3 UDP channel on the same port number. With an
+ * ephemeral port, the TCP port chosen by the system may already be used over UDP: the binding
+ * is then retried on another ephemeral port.
+ */
+ private void bindTcpAndUdp(
+ ServerBootstrap bootstrap,
+ String address,
+ int port,
+ boolean http3,
+ int maxConnections,
+ int idleTimeout)
+ throws GeneralSecurityException, IOException, InterruptedException {
+ int attempts = (http3 && port == 0) ? HTTP3_EPHEMERAL_PORT_ATTEMPTS : 1;
+ BindException failure = null;
+ for (int attempt = 1; attempt <= attempts; attempt++) {
+ serverChannel = bootstrap.bind(newAddress(address, port)).sync().channel();
+ int actualPort = ((InetSocketAddress) serverChannel.localAddress()).getPort();
+ try {
+ if (http3) {
+ startHttp3(newAddress(address, actualPort), maxConnections, idleTimeout);
+ }
+ setEphemeralPort(actualPort);
+ return;
+ } catch (BindException e) {
+ failure = e;
+ serverChannel.close().awaitUninterruptibly(5, TimeUnit.SECONDS);
+ serverChannel = null;
+ getLogger().log(Level.FINE, "UDP port {0} already in use", actualPort);
+ }
+ }
+ throw failure;
+ }
+
+ @Override
+ public synchronized void stop() throws Exception {
+ getLogger()
+ .log(
+ Level.INFO,
+ "Stopping the Netty {0} server on port {1}",
+ new Object[] {getProtocols(), getHelped().getPort()});
+ stopping = true;
+
+ if (serverChannel != null) {
+ serverChannel.close().awaitUninterruptibly(5, TimeUnit.SECONDS);
+ }
+
+ if (getShutdownGracefully()) {
+ awaitActiveCalls(TimeUnit.MILLISECONDS.toNanos(getShutdownTimeout()));
+ }
+
+ shutdownNow();
+ super.stop();
+ }
+
+ /**
+ * Waits for the calls in progress to complete.
+ *
+ * @param timeoutNanos The maximum time to wait.
+ * @throws InterruptedException If the wait is interrupted.
+ */
+ private void awaitActiveCalls(long timeoutNanos) throws InterruptedException {
+ long remaining = timeoutNanos;
+ activeCallsLock.lock();
+ try {
+ while (activeCalls.get() > 0 && remaining > 0) {
+ remaining = noActiveCalls.awaitNanos(remaining);
+ }
+ } finally {
+ activeCallsLock.unlock();
+ }
+ }
+
+ private void shutdownNow() {
+ if (serverChannel != null) {
+ serverChannel.close().awaitUninterruptibly(5, TimeUnit.SECONDS);
+ }
+ if (http3Channel != null) {
+ http3Channel.close().awaitUninterruptibly(5, TimeUnit.SECONDS);
+ http3Channel = null;
+ altSvc = null;
+ }
+ if (connections != null) {
+ connections.close().awaitUninterruptibly(5, TimeUnit.SECONDS);
+ }
+ if (callExecutor != null) {
+ callExecutor.shutdownNow();
+ }
+ if (bossGroup != null) {
+ bossGroup.shutdownGracefully(0, 0, TimeUnit.MILLISECONDS);
+ }
+ if (workerGroup != null) {
+ workerGroup.shutdownGracefully(0, 0, TimeUnit.MILLISECONDS);
+ }
+ serverChannel = null;
+ }
+
+ // ------------------------------------------------------------------ parameters
+
+ /**
+ * Whether calls run on virtual threads. Defaults to true.
+ *
+ * @return True if calls run on virtual threads.
+ */
+ public boolean isThreadPoolVirtual() {
+ return Boolean.parseBoolean(
+ getHelpedParameters().getFirstValue("threadPool.virtual", "true"));
+ }
+
+ /**
+ * Platform thread pool minimum threads. Defaults to 8.
+ *
+ * @return Platform thread pool minimum threads.
+ */
+ public int getThreadPoolMinThreads() {
+ return Integer.parseInt(getHelpedParameters().getFirstValue("threadPool.minThreads", "8"));
+ }
+
+ /**
+ * Platform thread pool maximum threads. Defaults to 200.
+ *
+ * @return Platform thread pool maximum threads.
+ */
+ public int getThreadPoolMaxThreads() {
+ return Integer.parseInt(
+ getHelpedParameters().getFirstValue("threadPool.maxThreads", "200"));
+ }
+
+ /**
+ * Platform thread pool idle timeout in milliseconds. Defaults to 60000.
+ *
+ * @return Platform thread pool idle timeout.
+ */
+ public int getThreadPoolIdleTimeout() {
+ return Integer.parseInt(
+ getHelpedParameters().getFirstValue("threadPool.idleTimeout", "60000"));
+ }
+
+ /**
+ * Platform thread pool threads priority. Defaults to {@link Thread#NORM_PRIORITY}.
+ *
+ * @return Platform thread pool threads priority.
+ */
+ public int getThreadPoolThreadsPriority() {
+ return Integer.parseInt(
+ getHelpedParameters()
+ .getFirstValue(
+ "threadPool.threadsPriority",
+ String.valueOf(Thread.NORM_PRIORITY)));
+ }
+
+ /**
+ * Maximum number of concurrent calls. Defaults to 0 (unlimited).
+ *
+ * @return Maximum number of concurrent calls.
+ */
+ public int getServerMaxConcurrentCalls() {
+ return Integer.parseInt(
+ getHelpedParameters().getFirstValue("server.maxConcurrentCalls", "0"));
+ }
+
+ /**
+ * Number of event loop threads accepting connections. Defaults to 1.
+ *
+ * @return Number of boss threads.
+ */
+ public int getEventLoopBossThreads() {
+ return Integer.parseInt(getHelpedParameters().getFirstValue("eventLoop.bossThreads", "1"));
+ }
+
+ /**
+ * Number of event loop threads doing network I/O. Defaults to 0 (Netty default).
+ *
+ * @return Number of worker threads.
+ */
+ public int getEventLoopWorkerThreads() {
+ return Integer.parseInt(
+ getHelpedParameters().getFirstValue("eventLoop.workerThreads", "0"));
+ }
+
+ /**
+ * Accept queue size. Defaults to 0 (operating system default).
+ *
+ * @return Accept queue size.
+ */
+ public int getConnectorAcceptQueueSize() {
+ return Integer.parseInt(
+ getHelpedParameters().getFirstValue("connector.acceptQueueSize", "0"));
+ }
+
+ /**
+ * Connection idle timeout in milliseconds. Defaults to 30000.
+ *
+ * @return Connection idle timeout.
+ */
+ public int getConnectorIdleTimeout() {
+ return Integer.parseInt(
+ getHelpedParameters().getFirstValue("connector.idleTimeout", "30000"));
+ }
+
+ /**
+ * Maximum size in bytes of the request line and headers. Defaults to 8192.
+ *
+ * @return Maximum request header size.
+ */
+ public int getHttpRequestHeaderSize() {
+ return Integer.parseInt(
+ getHelpedParameters().getFirstValue("http.requestHeaderSize", "8192"));
+ }
+
+ /**
+ * Size in bytes of the response output buffer. Defaults to 32768.
+ *
+ * @return Response output buffer size.
+ */
+ public int getHttpOutputBufferSize() {
+ return Integer.parseInt(
+ getHelpedParameters().getFirstValue("http.outputBufferSize", "32768"));
+ }
+
+ /**
+ * Maximum number of open connections. Defaults to 0 (unlimited).
+ *
+ * @return Maximum number of open connections.
+ */
+ public int getServerMaxConnections() {
+ return Integer.parseInt(getHelpedParameters().getFirstValue("server.maxConnections", "0"));
+ }
+
+ /**
+ * Whether the server shuts down gracefully. Defaults to true.
+ *
+ * @return True if the server shuts down gracefully.
+ */
+ public boolean getShutdownGracefully() {
+ return Boolean.parseBoolean(
+ getHelpedParameters().getFirstValue("shutdown.gracefully", "true"));
+ }
+
+ /**
+ * Graceful shutdown timeout in milliseconds. Defaults to 0.
+ *
+ * @return Graceful shutdown timeout.
+ */
+ public long getShutdownTimeout() {
+ return Long.parseLong(getHelpedParameters().getFirstValue("shutdown.timeout", "0"));
+ }
+
+ /**
+ * Returns the "Alt-Svc" header value advertising HTTP/3, or null if HTTP/3 is not served.
+ *
+ * @return The "Alt-Svc" header value or null.
+ */
+ String getAltSvc() {
+ return altSvc;
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettySsl.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettySsl.java
new file mode 100644
index 0000000000..2e8f4eed72
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettySsl.java
@@ -0,0 +1,77 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+import io.netty.handler.ssl.ApplicationProtocolConfig;
+import io.netty.handler.ssl.ClientAuth;
+import io.netty.handler.ssl.IdentityCipherSuiteFilter;
+import io.netty.handler.ssl.JdkSslContext;
+import io.netty.handler.ssl.SslContext;
+import java.util.Arrays;
+import java.util.List;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLEngine;
+
+/**
+ * Adapts a Restlet-configured JDK {@link SSLContext} to a Netty {@link SslContext}.
+ *
+ *
The Restlet SSL context applies the enabled protocols and cipher suites to the engines it
+ * creates. Netty would otherwise reset them to its own defaults, so they are read from a sample
+ * engine and passed explicitly.
+ *
+ *
This class is internal to the Netty connectors.
+ */
+final class NettySsl {
+
+ private NettySsl() {}
+
+ /**
+ * Creates a Netty SSL context.
+ *
+ * @param context The configured JDK SSL context.
+ * @param client True for a client context.
+ * @param alpn The application protocol negotiation configuration.
+ * @param clientAuth The client authentication mode, for server contexts.
+ * @return The Netty SSL context.
+ */
+ public static SslContext newContext(
+ SSLContext context,
+ boolean client,
+ ApplicationProtocolConfig alpn,
+ ClientAuth clientAuth) {
+ SSLEngine sample = context.createSSLEngine();
+ sample.setUseClientMode(client);
+ List ciphers = Arrays.asList(sample.getEnabledCipherSuites());
+ String[] protocols = sample.getEnabledProtocols();
+
+ return new JdkSslContext(
+ context,
+ client,
+ ciphers,
+ IdentityCipherSuiteFilter.INSTANCE,
+ alpn,
+ clientAuth,
+ protocols,
+ false);
+ }
+
+ /**
+ * Creates an ALPN configuration advertising the given protocols, in preference order.
+ *
+ * @param protocols The application protocol names.
+ * @return The ALPN configuration.
+ */
+ public static ApplicationProtocolConfig alpn(List protocols) {
+ return new ApplicationProtocolConfig(
+ ApplicationProtocolConfig.Protocol.ALPN,
+ ApplicationProtocolConfig.SelectorFailureBehavior.NO_ADVERTISE,
+ ApplicationProtocolConfig.SelectedListenerFailureBehavior.ACCEPT,
+ protocols);
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/StreamKind.java b/org.restlet/src/main/java/org/restlet/engine/netty/StreamKind.java
new file mode 100644
index 0000000000..af6388beb8
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/StreamKind.java
@@ -0,0 +1,50 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+/**
+ * The kind of channel carrying an HTTP exchange. It lets the shared code adapt to the protocol
+ * without referencing the HTTP/2 or QUIC classes, which keeps the optional HTTP/3 dependencies
+ * unloaded unless HTTP/3 is used.
+ */
+enum StreamKind {
+
+ /** An HTTP/1.1 connection, carrying one exchange at a time. */
+ HTTP1("HTTP/1.1"),
+
+ /** An HTTP/2 stream, child of a TCP connection. */
+ HTTP2("HTTP/2.0"),
+
+ /** An HTTP/3 request stream, child of a QUIC connection. */
+ HTTP3("HTTP/3.0");
+
+ private final String version;
+
+ StreamKind(String version) {
+ this.version = version;
+ }
+
+ /**
+ * Returns the protocol version, as reported to the application.
+ *
+ * @return The protocol version.
+ */
+ String version() {
+ return version;
+ }
+
+ /**
+ * Indicates whether the exchange is carried by a stream of a multiplexed connection.
+ *
+ * @return True for HTTP/2 and HTTP/3.
+ */
+ boolean isStream() {
+ return this != HTTP1;
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/Writability.java b/org.restlet/src/main/java/org/restlet/engine/netty/Writability.java
new file mode 100644
index 0000000000..2d4ce033a8
--- /dev/null
+++ b/org.restlet/src/main/java/org/restlet/engine/netty/Writability.java
@@ -0,0 +1,125 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.netty;
+
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelDuplexHandler;
+import io.netty.channel.ChannelHandler.Sharable;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.util.AttributeKey;
+import java.io.IOException;
+import java.io.InterruptedIOException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * Lets blocking writers wait until a channel becomes writable again, instead of polling. Install
+ * {@link #HANDLER} in the pipeline; it signals waiters on writability changes and on channel close.
+ *
+ *
This class is internal to the Netty connectors.
+ */
+final class Writability {
+
+ private static final AttributeKey KEY =
+ AttributeKey.valueOf(Writability.class, "writability");
+
+ /** The shared handler signalling writability changes. */
+ public static final ChannelDuplexHandler HANDLER = new SignalHandler();
+
+ private final ReentrantLock lock = new ReentrantLock();
+
+ private final Condition changed = lock.newCondition();
+
+ /**
+ * Returns the writability tracker of a channel, creating it if needed.
+ *
+ * @param channel The channel.
+ * @return The writability tracker.
+ */
+ public static Writability of(Channel channel) {
+ Writability result = channel.attr(KEY).get();
+ if (result == null) {
+ Writability created = new Writability();
+ result = channel.attr(KEY).setIfAbsent(created);
+ if (result == null) {
+ result = created;
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Blocks until the channel is writable.
+ *
+ * @param channel The channel.
+ * @param timeoutMs The maximum time to wait, 0 for no limit.
+ * @throws IOException If the channel closes, the wait times out or is interrupted.
+ */
+ public static void await(Channel channel, long timeoutMs) throws IOException {
+ if (channel.isWritable()) {
+ return;
+ }
+ channel.flush();
+ of(channel).awaitWritable(channel, timeoutMs);
+ }
+
+ private void awaitWritable(Channel channel, long timeoutMs) throws IOException {
+ long remaining =
+ (timeoutMs > 0) ? TimeUnit.MILLISECONDS.toNanos(timeoutMs) : Long.MAX_VALUE;
+ lock.lock();
+ try {
+ while (!channel.isWritable()) {
+ if (!channel.isActive()) {
+ throw new IOException("Connection closed by peer");
+ }
+ if (remaining <= 0) {
+ throw new InterruptedIOException("Write timed out");
+ }
+ // Wake up periodically in case a signal raced with the check above.
+ long slice = Math.min(remaining, TimeUnit.MILLISECONDS.toNanos(100));
+ long left = changed.awaitNanos(slice);
+ if (timeoutMs > 0) {
+ remaining -= (slice - Math.max(0, left));
+ }
+ }
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ throw new InterruptedIOException("Interrupted while writing");
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private void signal() {
+ lock.lock();
+ try {
+ changed.signalAll();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ @Sharable
+ private static final class SignalHandler extends ChannelDuplexHandler {
+ @Override
+ public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
+ if (ctx.channel().isWritable()) {
+ of(ctx.channel()).signal();
+ }
+ super.channelWritabilityChanged(ctx);
+ }
+
+ @Override
+ public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+ of(ctx.channel()).signal();
+ super.channelInactive(ctx);
+ }
+ }
+}
diff --git a/org.restlet/src/main/java/org/restlet/engine/security/RestletSslContextFactoryClient.java b/org.restlet/src/main/java/org/restlet/engine/security/RestletSslContextFactoryClient.java
deleted file mode 100644
index ab9e612d78..0000000000
--- a/org.restlet/src/main/java/org/restlet/engine/security/RestletSslContextFactoryClient.java
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * Copyright 2005-2026 Qlik
- *
- * 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
- *
- * 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.engine.security;
-
-import java.io.IOException;
-import java.net.InetAddress;
-import java.net.ServerSocket;
-import javax.net.ssl.SSLEngine;
-import javax.net.ssl.SSLServerSocket;
-import javax.net.ssl.SSLServerSocketFactory;
-import javax.net.ssl.SSLSocket;
-import org.eclipse.jetty.util.ssl.SslContextFactory;
-
-/**
- * Jetty SSL context factory based on a Restlet SSL context one.
- *
- * @author Jerome Louvel
- */
-public class RestletSslContextFactoryServer extends SslContextFactory.Server {
-
- /**
- * Constructor.
- *
- * @param restletSslContextFactory The Restlet SSL context factory to leverage.
- * @throws Exception
- */
- public RestletSslContextFactoryServer(
- org.restlet.engine.ssl.SslContextFactory restletSslContextFactory) throws Exception {
- setSslContext(restletSslContextFactory.createSslContext());
- }
-
- @Override
- public SSLEngine newSSLEngine() {
- return getSslContext().createSSLEngine();
- }
-
- @Override
- public SSLEngine newSSLEngine(String host, int port) {
- return getSslContext().createSSLEngine(host, port);
- }
-
- @Override
- public SSLServerSocket newSslServerSocket(String host, int port, int backlog)
- throws IOException {
- final SSLServerSocketFactory factory = getSslContext().getServerSocketFactory();
- final ServerSocket serverSocket =
- (host == null)
- ? factory.createServerSocket(port, backlog)
- : factory.createServerSocket(port, backlog, InetAddress.getByName(host));
- return (SSLServerSocket) serverSocket;
- }
-
- @Override
- public SSLSocket newSslSocket() throws IOException {
- return (SSLSocket) getSslContext().getSocketFactory().createSocket();
- }
-}
diff --git a/org.restlet/src/main/java/org/restlet/engine/ssl/DefaultSslContextFactory.java b/org.restlet/src/main/java/org/restlet/engine/ssl/DefaultSslContextFactory.java
index 04c96a0b20..767a8e5eac 100644
--- a/org.restlet/src/main/java/org/restlet/engine/ssl/DefaultSslContextFactory.java
+++ b/org.restlet/src/main/java/org/restlet/engine/ssl/DefaultSslContextFactory.java
@@ -280,42 +280,8 @@ protected final DefaultSslContextFactory clone() throws CloneNotSupportedExcepti
@Override
public javax.net.ssl.SSLContext createSslContext() throws Exception {
final javax.net.ssl.SSLContext result;
-
- final javax.net.ssl.KeyManagerFactory kmf;
- if ((this.keyStorePath != null)
- || (this.keyStoreProvider != null)
- || (this.keyStoreType != null)) {
- final KeyStore keyStore =
- loadKeyStore(
- this.keyStorePath,
- this.keyStoreProvider,
- this.keyStoreType,
- this.keyStorePassword);
-
- // Creates the key-manager factory.
- kmf = javax.net.ssl.KeyManagerFactory.getInstance(this.keyManagerAlgorithm);
- kmf.init(keyStore, this.keyStoreKeyPassword);
- } else {
- kmf = null;
- }
-
- final javax.net.ssl.TrustManagerFactory tmf;
- if ((this.trustStorePath != null)
- || (this.trustStoreProvider != null)
- || (this.trustStoreType != null)) {
- final KeyStore trustStore =
- loadKeyStore(
- this.trustStorePath,
- this.trustStoreProvider,
- this.trustStoreType,
- this.trustStorePassword);
-
- // Creates the trust-manager factory.
- tmf = javax.net.ssl.TrustManagerFactory.getInstance(this.trustManagerAlgorithm);
- tmf.init(trustStore);
- } else {
- tmf = null;
- }
+ final javax.net.ssl.KeyManagerFactory kmf = createKeyManagerFactory();
+ final javax.net.ssl.TrustManagerFactory tmf = createTrustManagerFactory();
final SecureRandom sr;
if (this.secureRandomAlgorithm != null) {
@@ -338,6 +304,60 @@ public javax.net.ssl.SSLContext createSslContext() throws Exception {
return result;
}
+ /**
+ * Creates the key manager factory from the key store settings, or returns null if no key store
+ * is configured. Used by {@link #createSslContext()}, and by the connectors that can't use a
+ * JDK {@link SSLContext}, such as the experimental HTTP/3 support.
+ *
+ * @return The key manager factory or null.
+ * @throws Exception If the key store can't be loaded.
+ */
+ public javax.net.ssl.KeyManagerFactory createKeyManagerFactory() throws Exception {
+ if ((this.keyStorePath == null)
+ && (this.keyStoreProvider == null)
+ && (this.keyStoreType == null)) {
+ return null;
+ }
+
+ final KeyStore keyStore =
+ loadKeyStore(
+ this.keyStorePath,
+ this.keyStoreProvider,
+ this.keyStoreType,
+ this.keyStorePassword);
+ final javax.net.ssl.KeyManagerFactory result =
+ javax.net.ssl.KeyManagerFactory.getInstance(this.keyManagerAlgorithm);
+ result.init(keyStore, this.keyStoreKeyPassword);
+ return result;
+ }
+
+ /**
+ * Creates the trust manager factory from the trust store settings, or returns null if no trust
+ * store is configured. Used by {@link #createSslContext()}, and by the connectors that can't
+ * use a JDK {@link SSLContext}, such as the experimental HTTP/3 support.
+ *
+ * @return The trust manager factory or null.
+ * @throws Exception If the trust store can't be loaded.
+ */
+ public javax.net.ssl.TrustManagerFactory createTrustManagerFactory() throws Exception {
+ if ((this.trustStorePath == null)
+ && (this.trustStoreProvider == null)
+ && (this.trustStoreType == null)) {
+ return null;
+ }
+
+ final KeyStore trustStore =
+ loadKeyStore(
+ this.trustStorePath,
+ this.trustStoreProvider,
+ this.trustStoreType,
+ this.trustStorePassword);
+ final javax.net.ssl.TrustManagerFactory result =
+ javax.net.ssl.TrustManagerFactory.getInstance(this.trustManagerAlgorithm);
+ result.init(trustStore);
+ return result;
+ }
+
/**
* Creates a new {@link SSLContext} wrapper. Necessary to properly initialize the {@link
* SSLEngine} or {@link SSLSocketFactory} or {@link javax.net.ssl.SSLServerSocketFactory}
diff --git a/org.restlet/src/main/java/org/restlet/representation/MultiPartRepresentation.java b/org.restlet/src/main/java/org/restlet/representation/MultiPartRepresentation.java
index 782dd2462e..bd93fdbde1 100644
--- a/org.restlet/src/main/java/org/restlet/representation/MultiPartRepresentation.java
+++ b/org.restlet/src/main/java/org/restlet/representation/MultiPartRepresentation.java
@@ -8,49 +8,178 @@
*/
package org.restlet.representation;
+import java.io.BufferedInputStream;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.SequenceInputStream;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
import java.nio.file.Path;
+import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
import java.util.List;
-import org.eclipse.jetty.http.HttpFields;
-import org.eclipse.jetty.http.MultiPart;
-import org.eclipse.jetty.http.MultiPart.Part;
-import org.eclipse.jetty.http.MultiPartConfig;
-import org.eclipse.jetty.http.MultiPartFormData;
-import org.eclipse.jetty.io.Content;
-import org.eclipse.jetty.io.content.InputStreamContentSource;
-import org.eclipse.jetty.util.Attributes;
-import org.eclipse.jetty.util.Promise;
+import java.util.Locale;
+import org.restlet.data.Header;
import org.restlet.data.MediaType;
+import org.restlet.util.Series;
/**
- * Input representation that can either parse or generate a multipart form data representation
- * depending on which constructor is invoked.
+ * Input representation that can either parse or generate a multipart form data representation (RFC
+ * 7578) depending on which constructor is invoked.
*
* @author Jerome Louvel
*/
public class MultiPartRepresentation extends InputRepresentation {
+ /**
+ * A part of a multipart form data representation: a name, an optional file name, optional
+ * headers and a content.
+ */
+ public static final class Part {
+
+ private final String name;
+
+ private final String fileName;
+
+ private final Series headers;
+
+ private final Representation content;
+
+ /**
+ * Constructor.
+ *
+ * @param name The name of the part (form field name).
+ * @param fileName The client's suggested file name for storing the part, or null.
+ * @param content The part content.
+ */
+ public Part(String name, String fileName, Representation content) {
+ this(name, fileName, null, content);
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param name The name of the part (form field name).
+ * @param fileName The client's suggested file name for storing the part, or null.
+ * @param headers Additional part headers, such as "Content-Type", or null. The
+ * "Content-Disposition" header is computed from the name and file name.
+ * @param content The part content.
+ */
+ public Part(String name, String fileName, Series headers, Representation content) {
+ this.name = name;
+ this.fileName = fileName;
+ this.headers = (headers == null) ? new Series<>(Header.class) : headers;
+ this.content = content;
+ }
+
+ /**
+ * Returns the name of the part (form field name).
+ *
+ * @return The name of the part.
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Returns the client's suggested file name for storing the part, or null.
+ *
+ * @return The file name or null.
+ */
+ public String getFileName() {
+ return fileName;
+ }
+
+ /**
+ * Returns the additional part headers, excluding "Content-Disposition".
+ *
+ * @return The additional part headers.
+ */
+ public Series getHeaders() {
+ return headers;
+ }
+
+ /**
+ * Returns the part content.
+ *
+ * @return The part content.
+ */
+ public Representation getContent() {
+ return content;
+ }
+
+ /**
+ * Returns the size in bytes of the content, or {@link Representation#UNKNOWN_SIZE}.
+ *
+ * @return The size in bytes of the content.
+ */
+ public long getLength() {
+ return (content == null) ? 0 : content.getSize();
+ }
+
+ /**
+ * Returns the content as a string.
+ *
+ * @param charset The character set, or null for UTF-8.
+ * @return The content as a string.
+ * @throws IOException If the content can't be read.
+ */
+ public String getContentAsString(Charset charset) throws IOException {
+ if (content == null) {
+ return null;
+ }
+ try (InputStream in = content.getStream()) {
+ return new String(
+ in.readAllBytes(), (charset == null) ? StandardCharsets.UTF_8 : charset);
+ }
+ }
+ }
+
private static final String PARAMETER_BOUNDARY = "boundary";
+ private static final String HEADER_CONTENT_DISPOSITION = "Content-Disposition";
+
+ /** Part content size above which parsed parts are stored in a file, when possible. */
+ private static final int MAX_MEMORY_PART_SIZE = 64 * 1024;
+
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private static final char[] BOUNDARY_CHARS =
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".toCharArray();
+
+ private static final byte[] CRLF = {'\r', '\n'};
+
/**
- * Creates a #{@link Part} object based on a {@link Representation} plus metadata.
+ * Creates a {@link Part} object based on a {@link Representation} plus metadata.
*
* @param name The name of the part.
* @param fileName The client's suggested file name for storing the part.
* @param partContent The part content.
- * @return The Jetty #{@link Part} object created.
- * @throws IOException
+ * @return The {@link Part} object created.
*/
- public static Part createPart(String name, String fileName, Representation partContent)
- throws IOException {
- return new MultiPart.ContentSourcePart(
- name,
- fileName,
- HttpFields.EMPTY,
- new InputStreamContentSource(partContent.getStream()));
+ public static Part createPart(String name, String fileName, Representation partContent) {
+ return new Part(name, fileName, partContent);
+ }
+
+ /**
+ * Generates a random boundary.
+ *
+ * @return A random boundary.
+ */
+ public static String generateBoundary() {
+ StringBuilder sb = new StringBuilder("RestletBoundary");
+ for (int i = 0; i < 24; i++) {
+ sb.append(BOUNDARY_CHARS[RANDOM.nextInt(BOUNDARY_CHARS.length)]);
+ }
+ return sb.toString();
}
/**
@@ -106,7 +235,7 @@ public static MediaType setBoundary(MediaType mediaType, String boundary) {
* @param parts The source parts to use when generating the representation.
*/
public MultiPartRepresentation(List parts) {
- this(MultiPart.generateBoundary(null, 24), parts);
+ this(generateBoundary(), parts);
}
/**
@@ -134,93 +263,73 @@ public MultiPartRepresentation(Part... parts) {
}
/**
- * Constructor that PARSES the content based on a given configuration into {@link #getParts()}.
+ * Constructor that PARSES the content into {@link #getParts()}. Part contents are kept in
+ * memory.
*
* @param multiPartEntity The multipart entity to parse which should have a media type based on
* {@link MediaType#MULTIPART_FORM_DATA}, with a "boundary" parameter.
- * @param config The multipart configuration.
- * @throws IOException
+ * @throws IOException If the entity can't be read or parsed.
*/
- public MultiPartRepresentation(Representation multiPartEntity, MultiPartConfig config)
- throws IOException {
- this(multiPartEntity.getMediaType(), multiPartEntity.getStream(), config);
+ public MultiPartRepresentation(Representation multiPartEntity) throws IOException {
+ this(multiPartEntity, null);
}
/**
- * Constructor that PARSES the content based on a given configuration into {@link #getParts()}.
- * Uses a default {@link MultiPartConfig}.
+ * Constructor that PARSES the content into {@link #getParts()}.
*
* @param multiPartEntity The multipart entity to parse which should have a media type based on
* {@link MediaType#MULTIPART_FORM_DATA}, with a "boundary" parameter.
- * @param storageLocation The location where parsed files are stored for easier access.
- * @throws IOException
+ * @param storageLocation The directory where large parsed parts are stored, or null to keep all
+ * part contents in memory.
+ * @throws IOException If the entity can't be read or parsed.
*/
public MultiPartRepresentation(Representation multiPartEntity, Path storageLocation)
throws IOException {
- this(multiPartEntity, new MultiPartConfig.Builder().location(storageLocation).build());
+ this(multiPartEntity.getMediaType(), multiPartEntity.getStream(), storageLocation);
}
/**
- * Constructor that PARSES the content based on a given configuration into {@link #getParts()}.
+ * Constructor that PARSES the content into {@link #getParts()}.
*
* @param mediaType The media type that should be based on {@link
* MediaType#MULTIPART_FORM_DATA}, with a "boundary" parameter.
* @param multiPartEntity The multipart entity to parse.
- * @param config The multipart configuration.
+ * @param storageLocation The directory where large parsed parts are stored, or null to keep all
+ * part contents in memory.
+ * @throws IOException If the entity can't be read or parsed.
*/
public MultiPartRepresentation(
- MediaType mediaType, InputStream multiPartEntity, MultiPartConfig config) {
- super(null, mediaType);
-
- if (MediaType.MULTIPART_FORM_DATA.equals(getMediaType(), true)) {
- this.boundary = getMediaType().getParameters().getFirstValue(PARAMETER_BOUNDARY);
-
- if (this.boundary != null) {
- if (multiPartEntity != null) {
- Content.Source contentSource = Content.Source.from(multiPartEntity);
- Attributes.Mapped attributes = new Attributes.Mapped();
-
- // Convert the request content into parts.
- MultiPartFormData.onParts(
- contentSource,
- attributes,
- mediaType.toString(),
- config,
- new Promise.Invocable<>() {
- @Override
- public void failed(Throwable failure) {
- throw new IllegalStateException(
- "Unable to parse the multipart form data representation",
- failure);
- }
-
- @Override
- public InvocationType getInvocationType() {
- return InvocationType.BLOCKING;
- }
-
- @Override
- public void succeeded(MultiPartFormData.Parts parts) {
- // Store the resulting parts
- MultiPartRepresentation.this.parts = new ArrayList<>();
- parts.iterator()
- .forEachRemaining(
- part ->
- MultiPartRepresentation.this.parts.add(
- part));
- }
- });
- } else {
- throw new IllegalArgumentException("The multipart entity can't be null");
- }
- } else {
- throw new IllegalArgumentException(
- "The content type must have a \"boundary\" parameter");
- }
- } else {
+ MediaType mediaType, InputStream multiPartEntity, Path storageLocation)
+ throws IOException {
+ super(null, checkMultipartMediaType(mediaType, multiPartEntity));
+ this.boundary = mediaType.getParameters().getFirstValue(PARAMETER_BOUNDARY);
+
+ try (InputStream in = multiPartEntity) {
+ this.parts = new Parser(in, this.boundary, storageLocation).parse();
+ }
+ }
+
+ /**
+ * Checks the arguments of the parsing constructor, before the representation is created.
+ *
+ * @param mediaType The media type, which must be "multipart/form-data" with a "boundary".
+ * @param multiPartEntity The multipart entity, which must not be null.
+ * @return The media type.
+ */
+ private static MediaType checkMultipartMediaType(
+ MediaType mediaType, InputStream multiPartEntity) {
+ if (!MediaType.MULTIPART_FORM_DATA.equals(mediaType, true)) {
throw new IllegalArgumentException(
"The content type must be \"multipart/form-data\" with a \"boundary\" parameter");
}
+ if (mediaType.getParameters().getFirstValue(PARAMETER_BOUNDARY) == null) {
+ throw new IllegalArgumentException(
+ "The content type must have a \"boundary\" parameter");
+ }
+ if (multiPartEntity == null) {
+ throw new IllegalArgumentException("The multipart entity can't be null");
+ }
+ return mediaType;
}
/**
@@ -238,6 +347,7 @@ public MultiPartRepresentation(String boundary, List parts) {
* Constructor that wraps multiple parts, set a boundary, then GENERATES the content via {@link
* #getStream()} as a {@link MediaType#MULTIPART_FORM_DATA}.
*
+ * @param boundary The boundary to add as a parameter.
* @param parts The source parts to use when generating the representation.
*/
public MultiPartRepresentation(String boundary, Part... parts) {
@@ -264,7 +374,8 @@ public List getParts() {
/**
* Returns an input stream that generates the multipart form data serialization for the wrapped
- * {@link #getParts()} object. The "boundary" must be non-null when invoking this method.
+ * {@link #getParts()} object. The "boundary" must be non-null when invoking this method. Part
+ * contents are streamed, not buffered.
*
* @return An input stream that generates the multipart form data.
*/
@@ -274,16 +385,55 @@ public InputStream getStream() throws IOException {
throw new IllegalArgumentException("The boundary can't be null");
}
- MultiPartFormData.ContentSource content =
- new MultiPartFormData.ContentSource(getBoundary());
-
- for (Part part : this.parts) {
- content.addPart(part);
+ final List source = (this.parts == null) ? List.of() : this.parts;
+ final byte[] delimiter = ("--" + getBoundary()).getBytes(StandardCharsets.US_ASCII);
+
+ List streams = new ArrayList<>();
+ for (Part part : source) {
+ ByteArrayOutputStream head = new ByteArrayOutputStream();
+ head.write(delimiter);
+ head.write(CRLF);
+ writeHeaders(part, head);
+ head.write(CRLF);
+ streams.add(new ByteArrayInputStream(head.toByteArray()));
+ streams.add(new LazyPartStream(part));
+ streams.add(new ByteArrayInputStream(CRLF));
}
- content.close();
+ ByteArrayOutputStream tail = new ByteArrayOutputStream();
+ tail.write(delimiter);
+ tail.write('-');
+ tail.write('-');
+ tail.write(CRLF);
+ streams.add(new ByteArrayInputStream(tail.toByteArray()));
+
setStream(null);
- return Content.Source.asInputStream(content);
+ return new SequenceInputStream(Collections.enumeration(streams));
+ }
+
+ private static void writeHeaders(Part part, OutputStream out) throws IOException {
+ StringBuilder sb = new StringBuilder(HEADER_CONTENT_DISPOSITION);
+ sb.append(": form-data; name=\"").append(escapeQuoted(part.getName())).append('"');
+ if (part.getFileName() != null) {
+ sb.append("; filename=\"").append(escapeQuoted(part.getFileName())).append('"');
+ }
+ sb.append("\r\n");
+
+ for (Header header : part.getHeaders()) {
+ if (!HEADER_CONTENT_DISPOSITION.equalsIgnoreCase(header.getName())) {
+ sb.append(header.getName()).append(": ").append(header.getValue()).append("\r\n");
+ }
+ }
+
+ out.write(sb.toString().getBytes(StandardCharsets.UTF_8));
+ }
+
+ /** Escapes a quoted parameter value as browsers do (HTML form submission algorithm). */
+ private static String escapeQuoted(String value) {
+ if (value == null) {
+ return "";
+ }
+ return value.replace("\"", "%22").replace("\r", "%0D").replace("\n", "%0A");
}
/**
@@ -302,4 +452,327 @@ public void setBoundary(String boundary) {
setMediaType(setBoundary(getMediaType(), boundary));
}
}
+
+ /** Opens the part content stream on first read, so that parts are streamed one at a time. */
+ private static final class LazyPartStream extends InputStream {
+
+ private final Part part;
+
+ private InputStream delegate;
+
+ LazyPartStream(Part part) {
+ this.part = part;
+ }
+
+ private InputStream delegate() throws IOException {
+ if (delegate == null) {
+ Representation content = part.getContent();
+ InputStream in = (content == null) ? null : content.getStream();
+ delegate = (in == null) ? InputStream.nullInputStream() : in;
+ }
+ return delegate;
+ }
+
+ @Override
+ public int read() throws IOException {
+ return delegate().read();
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ return delegate().read(b, off, len);
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (delegate != null) {
+ delegate.close();
+ }
+ }
+ }
+
+ /** Streaming multipart/form-data parser. */
+ private static final class Parser {
+
+ private final InputStream in;
+
+ /** The delimiter preceded by CRLF. Its first byte, CR, never recurs in it. */
+ private final byte[] delimiter;
+
+ private final Path storageLocation;
+
+ Parser(InputStream in, String boundary, Path storageLocation) {
+ // A virtual CRLF lets the first delimiter be matched like the others.
+ this.in =
+ new BufferedInputStream(
+ new SequenceInputStream(new ByteArrayInputStream(CRLF), in));
+ this.delimiter = ("\r\n--" + boundary).getBytes(StandardCharsets.US_ASCII);
+ this.storageLocation = storageLocation;
+ }
+
+ List parse() throws IOException {
+ List result = new ArrayList<>();
+
+ // Skip the preamble
+ if (!copyUntilDelimiter(OutputStream.nullOutputStream())) {
+ throw new IOException("No multipart delimiter found");
+ }
+
+ while (true) {
+ int c1 = in.read();
+ int c2 = in.read();
+ if (c1 == '-' && c2 == '-') {
+ // Close delimiter, ignore the epilogue
+ return result;
+ }
+ skipLineRest(c1, c2);
+
+ Series headers = readHeaders();
+ String disposition = headers.getFirstValue(HEADER_CONTENT_DISPOSITION, true);
+ if (disposition == null) {
+ throw new IOException("Missing Content-Disposition header in a part");
+ }
+ headers.removeAll(HEADER_CONTENT_DISPOSITION, true);
+ String name = getParameter(disposition, "name");
+ String fileName = getParameter(disposition, "filename");
+
+ SpillingOutputStream content = new SpillingOutputStream(storageLocation);
+ boolean found;
+ try (content) {
+ found = copyUntilDelimiter(content);
+ }
+ if (!found) {
+ content.discard();
+ throw new EOFException("Unexpected end of the multipart entity");
+ }
+
+ MediaType mediaType = null;
+ String contentType = headers.getFirstValue("Content-Type", true);
+ if (contentType != null) {
+ mediaType = MediaType.valueOf(contentType);
+ }
+ result.add(new Part(name, fileName, headers, content.toRepresentation(mediaType)));
+ }
+ }
+
+ /** Skips the transport padding after a delimiter, up to and including the CRLF. */
+ private void skipLineRest(int c1, int c2) throws IOException {
+ if (c1 == '\r' && c2 == '\n') {
+ return;
+ }
+ if (c1 == '\n') {
+ if (c2 != -1) {
+ throw new IOException("Malformed multipart delimiter line");
+ }
+ return;
+ }
+ int c = c2;
+ while (c != '\n') {
+ if (c == -1) {
+ throw new EOFException("Unexpected end of the multipart entity");
+ }
+ c = in.read();
+ }
+ }
+
+ private Series readHeaders() throws IOException {
+ Series headers = new Series<>(Header.class);
+ String line;
+ while (!(line = readLine()).isEmpty()) {
+ int colon = line.indexOf(':');
+ if (colon > 0) {
+ headers.add(line.substring(0, colon).trim(), line.substring(colon + 1).trim());
+ }
+ }
+ return headers;
+ }
+
+ private String readLine() throws IOException {
+ ByteArrayOutputStream line = new ByteArrayOutputStream();
+ int c;
+ while ((c = in.read()) != '\n') {
+ if (c == -1) {
+ throw new EOFException("Unexpected end of the multipart headers");
+ }
+ if (line.size() > 16 * 1024) {
+ throw new IOException("Multipart header line too long");
+ }
+ line.write(c);
+ }
+ byte[] bytes = line.toByteArray();
+ int length = bytes.length;
+ if (length > 0 && bytes[length - 1] == '\r') {
+ length--;
+ }
+ return new String(bytes, 0, length, StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Copies bytes until the CRLF-prefixed delimiter, which is consumed.
+ *
+ * @return True if the delimiter was found, false at end of stream.
+ */
+ private boolean copyUntilDelimiter(OutputStream out) throws IOException {
+ int matched = 0;
+ int c;
+ while ((c = in.read()) != -1) {
+ if (c == (delimiter[matched] & 0xFF)) {
+ matched++;
+ if (matched == delimiter.length) {
+ return true;
+ }
+ } else {
+ if (matched > 0) {
+ out.write(delimiter, 0, matched);
+ matched = 0;
+ }
+ if (c == (delimiter[0] & 0xFF)) {
+ matched = 1;
+ } else {
+ out.write(c);
+ }
+ }
+ }
+ return false;
+ }
+
+ /** Returns a Content-Disposition parameter value, unquoted. */
+ private static String getParameter(String disposition, String parameter) {
+ Iterator tokens = splitParameters(disposition).iterator();
+ if (tokens.hasNext()) {
+ tokens.next(); // disposition type
+ }
+ while (tokens.hasNext()) {
+ String token = tokens.next();
+ int eq = token.indexOf('=');
+ if (eq > 0
+ && token.substring(0, eq)
+ .trim()
+ .toLowerCase(Locale.ROOT)
+ .equals(parameter)) {
+ return unquote(token.substring(eq + 1).trim());
+ }
+ }
+ return null;
+ }
+
+ /** Splits on semicolons that are outside quoted strings. */
+ private static List splitParameters(String value) {
+ List result = new ArrayList<>();
+ StringBuilder current = new StringBuilder();
+ boolean quoted = false;
+ boolean escaped = false;
+ for (int i = 0; i < value.length(); i++) {
+ char ch = value.charAt(i);
+ if (escaped) {
+ escaped = false;
+ } else if (quoted && ch == '\\') {
+ escaped = true;
+ } else if (ch == '"') {
+ quoted = !quoted;
+ } else if (ch == ';' && !quoted) {
+ result.add(current.toString());
+ current.setLength(0);
+ continue;
+ }
+ current.append(ch);
+ }
+ result.add(current.toString());
+ return result;
+ }
+
+ private static String unquote(String value) {
+ if (value.length() < 2 || !value.startsWith("\"") || !value.endsWith("\"")) {
+ return value;
+ }
+ // Browsers don't escape backslashes in file names (Windows paths), so only quotes and
+ // backslashes are unescaped.
+ StringBuilder sb = new StringBuilder();
+ int end = value.length() - 1;
+ int i = 1;
+ while (i < end) {
+ char ch = value.charAt(i);
+ boolean escape =
+ ch == '\\'
+ && i + 1 < end
+ && (value.charAt(i + 1) == '"' || value.charAt(i + 1) == '\\');
+ sb.append(escape ? value.charAt(i + 1) : ch);
+ i += escape ? 2 : 1;
+ }
+ return sb.toString();
+ }
+ }
+
+ /** Buffers in memory, then spills to a temporary file above a threshold. */
+ private static final class SpillingOutputStream extends OutputStream {
+
+ private final Path storageLocation;
+
+ private ByteArrayOutputStream memory = new ByteArrayOutputStream();
+
+ private Path file;
+
+ private OutputStream fileOut;
+
+ private long size;
+
+ SpillingOutputStream(Path storageLocation) {
+ this.storageLocation = storageLocation;
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ prepare(1);
+ if (fileOut != null) {
+ fileOut.write(b);
+ } else {
+ memory.write(b);
+ }
+ size++;
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ prepare(len);
+ if (fileOut != null) {
+ fileOut.write(b, off, len);
+ } else {
+ memory.write(b, off, len);
+ }
+ size += len;
+ }
+
+ private void prepare(int len) throws IOException {
+ if (fileOut == null
+ && storageLocation != null
+ && memory.size() + (long) len > MAX_MEMORY_PART_SIZE) {
+ file = Files.createTempFile(storageLocation, "multipart", ".part");
+ fileOut = new java.io.BufferedOutputStream(Files.newOutputStream(file));
+ memory.writeTo(fileOut);
+ memory = null;
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (fileOut != null) {
+ fileOut.close();
+ }
+ }
+
+ void discard() throws IOException {
+ if (file != null) {
+ Files.deleteIfExists(file);
+ }
+ }
+
+ Representation toRepresentation(MediaType mediaType) {
+ if (file != null) {
+ FileRepresentation result = new FileRepresentation(file.toFile(), mediaType);
+ result.setSize(size);
+ return result;
+ }
+ return new ByteArrayRepresentation(memory.toByteArray(), mediaType, size);
+ }
+ }
}
diff --git a/org.restlet/src/test/java/org/restlet/data/ProtocolTestCase.java b/org.restlet/src/test/java/org/restlet/data/ProtocolTestCase.java
index 19adf39758..ccfe0691f3 100644
--- a/org.restlet/src/test/java/org/restlet/data/ProtocolTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/data/ProtocolTestCase.java
@@ -76,7 +76,6 @@ void valueOf_knownScheme_returnsSharedConstant() {
assertSame(Protocol.JAR, Protocol.valueOf("jar"));
assertSame(Protocol.JDBC, Protocol.valueOf("jdbc"));
assertSame(Protocol.RIAP, Protocol.valueOf("riap"));
- assertSame(Protocol.WAR, Protocol.valueOf("war"));
assertSame(Protocol.ZIP, Protocol.valueOf("zip"));
}
@@ -129,4 +128,11 @@ void toString_withVersion_includesVersion() {
void toString_withoutVersion_omitsSlash() {
assertEquals("FTP", Protocol.FTP.toString());
}
+
+ @Test
+ void valueOf_warScheme_isNoLongerKnown() {
+ Protocol protocol = Protocol.valueOf("war");
+ assertEquals("WAR", protocol.getName());
+ assertEquals(Protocol.UNKNOWN_PORT, protocol.getDefaultPort());
+ }
}
diff --git a/org.restlet/src/test/java/org/restlet/engine/adapter/JettyHandlerTestCase.java b/org.restlet/src/test/java/org/restlet/engine/adapter/JettyHandlerTestCase.java
deleted file mode 100644
index 3b01b7dd5c..0000000000
--- a/org.restlet/src/test/java/org/restlet/engine/adapter/JettyHandlerTestCase.java
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * Copyright 2005-2026 Qlik
- *
- * 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.engine.adapter;
-
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-
-import org.junit.jupiter.api.Test;
-import org.restlet.Server;
-import org.restlet.data.Protocol;
-
-/** Unit tests for {@link JettyHandler}. */
-class JettyHandlerTestCase {
-
- @Test
- void constructor_defaultsToHttp() {
- Server server = new Server(Protocol.HTTP, 0);
- JettyHandler handler = new JettyHandler(server);
- assertNotNull(handler);
- }
-
- @Test
- void constructor_secureFlagTrue_usesHttpsHelper() {
- Server server = new Server(Protocol.HTTPS, 0);
- JettyHandler handler = new JettyHandler(server, true);
- assertNotNull(handler);
- }
-
- @Test
- void constructor_secureFlagFalse_usesHttpHelper() {
- Server server = new Server(Protocol.HTTP, 0);
- JettyHandler handler = new JettyHandler(server, false);
- assertNotNull(handler);
- }
-}
diff --git a/org.restlet/src/test/java/org/restlet/engine/application/StatusFilterTestCase.java b/org.restlet/src/test/java/org/restlet/engine/application/StatusFilterTestCase.java
index ffc72137a5..8c8974b67d 100644
--- a/org.restlet/src/test/java/org/restlet/engine/application/StatusFilterTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/application/StatusFilterTestCase.java
@@ -14,17 +14,24 @@
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.restlet.Context;
import org.restlet.Request;
import org.restlet.Response;
import org.restlet.data.Method;
import org.restlet.data.Status;
+import org.restlet.engine.Engine;
import org.restlet.representation.StringRepresentation;
import org.restlet.service.StatusService;
class StatusFilterTestCase {
+ @BeforeEach
+ void setUp() {
+ Engine.register();
+ }
+
private Response newResponse() {
Request request = new Request(Method.GET, "http://localhost/test");
return new Response(request);
diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/BackPressureTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/BackPressureTestCase.java
new file mode 100644
index 0000000000..530cc89102
--- /dev/null
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/BackPressureTestCase.java
@@ -0,0 +1,279 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.connector;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+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.Restlet;
+import org.restlet.Server;
+import org.restlet.data.MediaType;
+import org.restlet.data.Method;
+import org.restlet.data.Protocol;
+import org.restlet.data.Status;
+import org.restlet.engine.Engine;
+import org.restlet.representation.OutputRepresentation;
+import org.restlet.representation.Representation;
+
+/**
+ * Tests the back-pressure of the connectors: a fast producer is slowed down by a slow consumer
+ * instead of buffering the whole content in memory, and the content arrives intact.
+ */
+class BackPressureTestCase {
+
+ /** Large enough to exceed the socket and Netty buffers by far. */
+ private static final long SIZE = 64L * 1024 * 1024;
+
+ private static final int CHUNK = 64 * 1024;
+
+ private Server server;
+
+ private Client client;
+
+ @BeforeEach
+ void setUp() {
+ Engine.clearThreadLocalVariables();
+ Engine.register(false);
+ Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null));
+ Engine.getInstance().getRegisteredClients().add(new HttpClientHelper(null));
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (client != null) {
+ client.stop();
+ }
+ if (server != null) {
+ server.stop();
+ }
+ Engine.clearThreadLocalVariables();
+ }
+
+ /** A large response body written as fast as possible, tracking the bytes produced. */
+ private static Representation largeBody(AtomicLong produced) {
+ return new OutputRepresentation(MediaType.APPLICATION_OCTET_STREAM, SIZE) {
+ @Override
+ public void write(OutputStream out) throws IOException {
+ byte[] chunk = pattern(CHUNK);
+ for (long sent = 0; sent < SIZE; sent += CHUNK) {
+ out.write(chunk);
+ produced.addAndGet(CHUNK);
+ }
+ }
+ };
+ }
+
+ private static byte[] pattern(int length) {
+ byte[] result = new byte[length];
+ for (int i = 0; i < length; i++) {
+ result[i] = (byte) i;
+ }
+ return result;
+ }
+
+ private static long drain(InputStream in, int bytesPerRead) throws IOException {
+ byte[] buffer = new byte[bytesPerRead];
+ long total = 0;
+ int n;
+ while ((n = in.read(buffer)) != -1) {
+ for (int i = 0; i < n; i++) {
+ if (buffer[i] != (byte) ((total + i) % CHUNK)) {
+ throw new IOException("Corrupted content at offset " + (total + i));
+ }
+ }
+ total += n;
+ }
+ return total;
+ }
+
+ @Test
+ void serverWriterIsSlowedDownBySlowReader() throws Exception {
+ final AtomicLong produced = new AtomicLong();
+ server =
+ new Server(
+ new Context(),
+ Protocol.HTTP,
+ 0,
+ new Restlet() {
+ @Override
+ public void handle(Request request, Response response) {
+ response.setEntity(largeBody(produced));
+ }
+ });
+ server.start();
+
+ // A raw socket client that pauses before reading anything.
+ try (Socket socket = new Socket("localhost", server.getActualPort())) {
+ socket.setReceiveBufferSize(64 * 1024);
+ OutputStream out = socket.getOutputStream();
+ out.write(
+ "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
+ .getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+
+ long producedWhileStalled = awaitStall(produced);
+ assertTrue(
+ producedWhileStalled < SIZE / 4,
+ "The server buffered " + producedWhileStalled + " bytes for a stalled reader");
+
+ InputStream in = socket.getInputStream();
+ String head = readHead(in);
+ assertTrue(head.startsWith("HTTP/1.1 200"), head);
+ assertEquals(SIZE, drain(in, CHUNK));
+ }
+ assertEquals(SIZE, produced.get());
+ }
+
+ @Test
+ void clientWriterIsSlowedDownBySlowServerReader() throws Exception {
+ final CountDownLatch started = new CountDownLatch(1);
+ final CountDownLatch readAllowed = new CountDownLatch(1);
+ final AtomicLong received = new AtomicLong();
+ server =
+ new Server(
+ new Context(),
+ Protocol.HTTP,
+ 0,
+ new Restlet() {
+ @Override
+ public void handle(Request request, Response response) {
+ started.countDown();
+ try (InputStream in = request.getEntity().getStream()) {
+ // Don't read until the test has checked the client stalled.
+ if (!readAllowed.await(30, TimeUnit.SECONDS)) {
+ throw new IOException("The test didn't allow reading");
+ }
+ received.set(drain(in, CHUNK));
+ response.setEntity(
+ Long.toString(received.get()), MediaType.TEXT_PLAIN);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ response.setStatus(Status.SERVER_ERROR_INTERNAL, e);
+ } catch (IOException e) {
+ response.setStatus(Status.SERVER_ERROR_INTERNAL, e);
+ }
+ }
+ });
+ server.start();
+
+ client = new Client(new Context(), Protocol.HTTP);
+ client.start();
+
+ final AtomicLong produced = new AtomicLong();
+ Request request =
+ new Request(
+ Method.PUT,
+ "http://localhost:" + server.getActualPort(),
+ largeBody(produced));
+
+ final Response[] result = new Response[1];
+ Thread sender = Thread.ofVirtual().start(() -> result[0] = client.handle(request));
+
+ assertTrue(started.await(10, TimeUnit.SECONDS));
+ long producedWhileStalled = awaitStall(produced);
+ readAllowed.countDown();
+ assertTrue(
+ producedWhileStalled < SIZE / 4,
+ "The client buffered " + producedWhileStalled + " bytes for a stalled server");
+
+ sender.join(60_000);
+ assertEquals(Status.SUCCESS_OK, result[0].getStatus());
+ assertEquals(Long.toString(SIZE), result[0].getEntityAsText());
+ assertEquals(SIZE, received.get());
+ }
+
+ @Test
+ void clientReadsLargeBodySlowlyWithoutLoss() throws Exception {
+ final AtomicLong produced = new AtomicLong();
+ server =
+ new Server(
+ new Context(),
+ Protocol.HTTP,
+ 0,
+ new Restlet() {
+ @Override
+ public void handle(Request request, Response response) {
+ response.setEntity(largeBody(produced));
+ }
+ });
+ server.start();
+
+ client = new Client(new Context(), Protocol.HTTP);
+ client.start();
+ Response response =
+ client.handle(
+ new Request(Method.GET, "http://localhost:" + server.getActualPort()));
+ assertEquals(Status.SUCCESS_OK, response.getStatus());
+
+ try (InputStream in = response.getEntity().getStream()) {
+ long producedWhileStalled = awaitStall(produced);
+ assertTrue(
+ producedWhileStalled < SIZE / 4,
+ "The server produced "
+ + producedWhileStalled
+ + " bytes while the client body was not read");
+ assertEquals(SIZE, drain(in, 8 * 1024));
+ }
+ }
+
+ private static String readHead(InputStream in) throws IOException {
+ StringBuilder sb = new StringBuilder();
+ while (!sb.toString().endsWith("\r\n\r\n")) {
+ int c = in.read();
+ if (c == -1) {
+ throw new IOException("Unexpected end of stream");
+ }
+ sb.append((char) c);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Waits, without consuming anything, until the producer stalls: its counter stops progressing
+ * for a while, because back-pressure blocks it. Fails if it never stalls.
+ *
+ * @param produced The bytes produced so far.
+ * @return The bytes produced once stalled.
+ */
+ private static long awaitStall(AtomicLong produced) throws InterruptedException {
+ final long quietNanos = TimeUnit.MILLISECONDS.toNanos(300);
+ final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+ final Object monitor = new Object();
+ long last = produced.get();
+ long lastChange = System.nanoTime();
+ synchronized (monitor) {
+ while (System.nanoTime() < deadline) {
+ monitor.wait(20);
+ long now = produced.get();
+ if (now != last) {
+ last = now;
+ lastChange = System.nanoTime();
+ } else if (System.nanoTime() - lastChange >= quietNanos) {
+ return last;
+ }
+ }
+ }
+ throw new AssertionError("The producer never stalled, " + last + " bytes produced");
+ }
+}
diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/BaseConnectorsTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/BaseConnectorsTestCase.java
index ac0aefa3ba..e51a446216 100644
--- a/org.restlet/src/test/java/org/restlet/engine/connector/BaseConnectorsTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/BaseConnectorsTestCase.java
@@ -11,7 +11,6 @@
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import java.util.List;
-import java.util.logging.Level;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
@@ -62,7 +61,7 @@ protected void configureServer(final Server server) {
protected abstract Application createApplication();
protected List listTestCases() {
- return List.of(new ConnectorsPair(HttpServer.JETTY_HTTP, HttpClient.JETTY));
+ return List.of(new ConnectorsPair(HttpServer.NETTY_HTTP, HttpClient.NETTY));
}
@TestFactory
@@ -76,10 +75,6 @@ Stream testsFactory() {
}
private void runTest(final HttpServer server, final HttpClient client) throws Exception {
- if (shouldDebug()) {
- System.setProperty("org.eclipse.jetty.LEVEL", "TRACE");
- }
-
try {
initEngine(server, client);
start();
@@ -109,9 +104,6 @@ private void stop() throws Exception {
}
private void initEngine(HttpServer server, HttpClient client) {
- if (shouldDebug()) {
- Engine.setLogLevel(Level.FINE);
- }
Engine.clearThreadLocalVariables();
Engine nre = Engine.register(false);
nre.getRegisteredServers().add(server.serverHelper);
@@ -127,8 +119,8 @@ private void resetEngine() {
}
public enum HttpServer {
- JETTY_HTTP(new org.restlet.engine.connector.HttpServerHelper(null)),
- JETTY_HTTPS(new org.restlet.engine.connector.HttpsServerHelper(null));
+ NETTY_HTTP(new org.restlet.engine.connector.HttpServerHelper(null)),
+ NETTY_HTTPS(new org.restlet.engine.connector.HttpsServerHelper(null));
final ServerHelper serverHelper;
@@ -138,7 +130,7 @@ public enum HttpServer {
}
public enum HttpClient {
- JETTY(new org.restlet.engine.connector.HttpClientHelper(null));
+ NETTY(new org.restlet.engine.connector.HttpClientHelper(null));
final ClientHelper clientHelper;
diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/Http2MultiplexingTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/Http2MultiplexingTestCase.java
new file mode 100644
index 0000000000..370a86bfb2
--- /dev/null
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/Http2MultiplexingTestCase.java
@@ -0,0 +1,190 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.connector;
+
+import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.restlet.Client;
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+import org.restlet.Server;
+import org.restlet.data.MediaType;
+import org.restlet.data.Method;
+import org.restlet.data.Protocol;
+import org.restlet.data.Status;
+import org.restlet.engine.Engine;
+import org.restlet.engine.header.HeaderConstants;
+
+/**
+ * Tests that concurrent client calls share one HTTP/2 connection, one stream per call, and that the
+ * calls are processed concurrently by the server.
+ */
+class Http2MultiplexingTestCase {
+
+ private static final int CALLS = 50;
+
+ /** Number of calls blocked together on the server, proving that streams are concurrent. */
+ private static final int BARRIER = 10;
+
+ @TempDir Path tempDir;
+
+ /** The distinct client ports seen by the server, one per TCP connection. */
+ private final Set clientPorts = ConcurrentHashMap.newKeySet();
+
+ private final Set versions = ConcurrentHashMap.newKeySet();
+
+ private final AtomicInteger inFlight = new AtomicInteger();
+
+ private final AtomicInteger maxInFlight = new AtomicInteger();
+
+ private CountDownLatch barrier;
+
+ private Server server;
+
+ private Client client;
+
+ @BeforeEach
+ void setUp() {
+ Engine.clearThreadLocalVariables();
+ Engine.register(false);
+ Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null));
+ Engine.getInstance().getRegisteredServers().add(new HttpsServerHelper(null));
+ Engine.getInstance().getRegisteredClients().add(new HttpClientHelper(null));
+ barrier = new CountDownLatch(BARRIER);
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (client != null) {
+ client.stop();
+ }
+ if (server != null) {
+ server.stop();
+ }
+ Engine.clearThreadLocalVariables();
+ }
+
+ private final Restlet echo =
+ new Restlet() {
+ @Override
+ public void handle(Request request, Response response) {
+ clientPorts.add(request.getClientInfo().getPort());
+ versions.add(
+ String.valueOf(
+ request.getAttributes()
+ .get(HeaderConstants.ATTRIBUTE_VERSION)));
+ int current = inFlight.incrementAndGet();
+ maxInFlight.accumulateAndGet(current, Math::max);
+ try {
+ // The first calls wait for each other: they can only all get here if the
+ // streams are processed concurrently.
+ barrier.countDown();
+ barrier.await(10, TimeUnit.SECONDS);
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ } finally {
+ inFlight.decrementAndGet();
+ }
+ response.setEntity(
+ "item " + request.getResourceRef().getQueryAsForm().getFirstValue("i"),
+ MediaType.TEXT_PLAIN);
+ }
+ };
+
+ private void runConcurrentCalls(String baseUri) throws Exception {
+ List> results = new ArrayList<>();
+ try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
+ for (int i = 0; i < CALLS; i++) {
+ final int item = i;
+ results.add(
+ executor.submit(
+ () -> {
+ Response response =
+ client.handle(
+ new Request(
+ Method.GET, baseUri + "/?i=" + item));
+ assertEquals(Status.SUCCESS_OK, response.getStatus());
+ return response.getEntityAsText();
+ }));
+ }
+ for (int i = 0; i < CALLS; i++) {
+ assertEquals("item " + i, results.get(i).get(30, TimeUnit.SECONDS));
+ }
+ }
+
+ assertEquals(1, clientPorts.size(), "Calls should share one connection: " + clientPorts);
+ assertEquals(Set.of("HTTP/2.0"), versions);
+ assertTrue(
+ maxInFlight.get() >= BARRIER,
+ "Streams should be processed concurrently, max in flight: " + maxInFlight.get());
+ }
+
+ @Test
+ void sharesOneTlsConnection() throws Exception {
+ Path keystore = tempDir.resolve("dummy.p12");
+ try (InputStream in = SslBaseConnectorsTestCase.class.getResourceAsStream("dummy.p12")) {
+ Files.copy(in, keystore, REPLACE_EXISTING);
+ }
+
+ Context serverContext = new Context();
+ serverContext.getParameters().add("http.transport.protocols", "HTTP2,HTTP1_1");
+ serverContext.getParameters().add("keystorePath", keystore.toString());
+ serverContext.getParameters().add("keystorePassword", "testtest");
+ serverContext.getParameters().add("keyStoreType", "PKCS12");
+ serverContext.getParameters().add("keyPassword", "testtest");
+ server = new Server(serverContext, Protocol.HTTPS, 0, echo);
+ server.start();
+
+ Context context = new Context();
+ context.getParameters().add("httpClientTransportMode", "DYNAMIC");
+ context.getParameters().add("truststorePath", keystore.toString());
+ context.getParameters().add("truststorePassword", "testtest");
+ context.getParameters().add("trustStoreType", "PKCS12");
+ client = new Client(context, Protocol.HTTPS);
+ client.start();
+
+ runConcurrentCalls("https://localhost:" + server.getActualPort());
+ }
+
+ @Test
+ void sharesOneCleartextConnection() throws Exception {
+ Context serverContext = new Context();
+ serverContext.getParameters().add("http.transport.protocol", "HTTP2");
+ server = new Server(serverContext, Protocol.HTTP, 0, echo);
+ server.start();
+
+ Context context = new Context();
+ context.getParameters().add("httpClientTransportMode", "HTTP2");
+ client = new Client(context, Protocol.HTTP);
+ client.start();
+
+ runConcurrentCalls("http://localhost:" + server.getActualPort());
+ }
+}
diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/Http3TestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/Http3TestCase.java
new file mode 100644
index 0000000000..1406d7c8ed
--- /dev/null
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/Http3TestCase.java
@@ -0,0 +1,353 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.connector;
+
+import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import io.netty.handler.codec.quic.Quic;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.restlet.Client;
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+import org.restlet.Server;
+import org.restlet.data.Encoding;
+import org.restlet.data.MediaType;
+import org.restlet.data.Method;
+import org.restlet.data.Preference;
+import org.restlet.data.Protocol;
+import org.restlet.data.Status;
+import org.restlet.engine.Engine;
+import org.restlet.engine.application.Encoder;
+import org.restlet.engine.header.HeaderConstants;
+import org.restlet.representation.OutputRepresentation;
+import org.restlet.representation.Representation;
+import org.restlet.representation.StringRepresentation;
+import org.restlet.service.EncoderService;
+
+/**
+ * Tests the experimental HTTP/3 support of the connectors. Skipped when the native QUIC library of
+ * Netty is not available on the build platform.
+ */
+class Http3TestCase {
+
+ private static final String PASSWORD = "testtest";
+
+ @TempDir Path tempDir;
+
+ private Path keystore;
+
+ private Server server;
+
+ private Client client;
+
+ /** The protocol versions seen by the server. */
+ private final Set versions = ConcurrentHashMap.newKeySet();
+
+ /** The client UDP ports seen by the server, one per QUIC connection. */
+ private final Set clientPorts = ConcurrentHashMap.newKeySet();
+
+ @BeforeAll
+ static void requireQuic() {
+ assumeTrue(Quic.isAvailable(), "The native QUIC library is not available");
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ Engine.clearThreadLocalVariables();
+ Engine.register(false);
+ Engine.getInstance().getRegisteredServers().add(new HttpsServerHelper(null));
+ Engine.getInstance().getRegisteredClients().add(new HttpClientHelper(null));
+
+ keystore = tempDir.resolve("dummy.p12");
+ try (InputStream in = SslBaseConnectorsTestCase.class.getResourceAsStream("dummy.p12")) {
+ Files.copy(in, keystore, REPLACE_EXISTING);
+ }
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (client != null) {
+ client.stop();
+ }
+ if (server != null) {
+ server.stop();
+ }
+ Engine.clearThreadLocalVariables();
+ }
+
+ private final Restlet echo =
+ new Restlet() {
+ @Override
+ public void handle(Request request, Response response) {
+ versions.add(
+ String.valueOf(
+ request.getAttributes()
+ .get(HeaderConstants.ATTRIBUTE_VERSION)));
+ clientPorts.add(request.getClientInfo().getPort());
+ String body =
+ request.isEntityAvailable() ? " " + request.getEntityAsText() : "";
+ response.setEntity(
+ request.getMethod() + " " + request.getResourceRef().getPath() + body,
+ MediaType.TEXT_PLAIN);
+ }
+ };
+
+ private void startServer(String protocols, Restlet restlet, String... parameters)
+ throws Exception {
+ Context context = new Context();
+ context.getParameters().add("http.transport.protocols", protocols);
+ context.getParameters().add("keystorePath", keystore.toString());
+ context.getParameters().add("keystorePassword", PASSWORD);
+ context.getParameters().add("keyStoreType", "PKCS12");
+ context.getParameters().add("keyPassword", PASSWORD);
+ for (int i = 0; i < parameters.length; i += 2) {
+ context.getParameters().add(parameters[i], parameters[i + 1]);
+ }
+ server = new Server(context, Protocol.HTTPS, 0, restlet);
+ server.start();
+ }
+
+ private void startClient(String mode, String... parameters) throws Exception {
+ Context context = new Context();
+ context.getParameters().add("httpClientTransportMode", mode);
+ context.getParameters().add("truststorePath", keystore.toString());
+ context.getParameters().add("truststorePassword", PASSWORD);
+ context.getParameters().add("trustStoreType", "PKCS12");
+ context.getParameters().add("connectTimeout", "5000");
+ context.getParameters().add("idleTimeout", "10000");
+ for (int i = 0; i < parameters.length; i += 2) {
+ context.getParameters().add(parameters[i], parameters[i + 1]);
+ }
+ client = new Client(context, Protocol.HTTPS);
+ client.start();
+ }
+
+ private String uri(String path) {
+ return "https://localhost:" + server.getActualPort() + path;
+ }
+
+ @Test
+ void getAndPutOverHttp3() throws Exception {
+ startServer("HTTP1_1,HTTP3", echo);
+ startClient("HTTP3");
+
+ Response get = client.handle(new Request(Method.GET, uri("/a")));
+ assertEquals(Status.SUCCESS_OK, get.getStatus());
+ assertEquals("GET /a", get.getEntityAsText());
+
+ Response put =
+ client.handle(new Request(Method.PUT, uri("/b"), new StringRepresentation("x")));
+ assertEquals(Status.SUCCESS_OK, put.getStatus());
+ assertEquals("PUT /b x", put.getEntityAsText());
+
+ assertEquals(Set.of("HTTP/3.0"), versions);
+ }
+
+ @Test
+ void tcpResponsesAdvertiseHttp3() throws Exception {
+ startServer("HTTP2,HTTP1_1,HTTP3", echo);
+ startClient("DYNAMIC");
+
+ Response response = client.handle(new Request(Method.GET, uri("/")));
+ assertEquals(Status.SUCCESS_OK, response.getStatus());
+ assertEquals(
+ "h3=\":" + server.getActualPort() + "\"; ma=86400",
+ response.getHeaders().getFirstValue("Alt-Svc", true));
+ assertEquals(Set.of("HTTP/2.0"), versions);
+ }
+
+ @Test
+ void concurrentCallsShareOneQuicConnection() throws Exception {
+ startServer("HTTP1_1,HTTP3", echo);
+ startClient("HTTP3");
+
+ List> results = new ArrayList<>();
+ try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
+ for (int i = 0; i < 30; i++) {
+ final String path = "/" + i;
+ results.add(
+ executor.submit(
+ () ->
+ client.handle(new Request(Method.GET, uri(path)))
+ .getEntityAsText()));
+ }
+ for (int i = 0; i < 30; i++) {
+ assertEquals("GET /" + i, results.get(i).get(30, TimeUnit.SECONDS));
+ }
+ }
+ assertEquals(1, clientPorts.size(), "Calls should share one connection: " + clientPorts);
+ }
+
+ @Test
+ void largeBodiesOverHttp3() throws Exception {
+ final long size = 8L * 1024 * 1024;
+ startServer(
+ "HTTP1_1,HTTP3",
+ new Restlet() {
+ @Override
+ public void handle(Request request, Response response) {
+ try (InputStream in = request.getEntity().getStream()) {
+ long received = in.transferTo(OutputStream.nullOutputStream());
+ response.setEntity(pattern(size));
+ response.getHeaders().add("X-Received", Long.toString(received));
+ } catch (IOException e) {
+ response.setStatus(Status.SERVER_ERROR_INTERNAL, e);
+ }
+ }
+ });
+ startClient("HTTP3");
+
+ Response response = client.handle(new Request(Method.POST, uri("/"), pattern(size)));
+ assertEquals(Status.SUCCESS_OK, response.getStatus());
+ assertEquals(Long.toString(size), response.getHeaders().getFirstValue("X-Received", true));
+ try (InputStream in = response.getEntity().getStream()) {
+ long total = 0;
+ byte[] buffer = new byte[16 * 1024];
+ int n;
+ while ((n = in.read(buffer)) != -1) {
+ for (int i = 0; i < n; i++) {
+ assertEquals((byte) ((total + i) % 251), buffer[i]);
+ }
+ total += n;
+ }
+ assertEquals(size, total);
+ }
+ }
+
+ @Test
+ void compressedResponseIsDecoded() throws Exception {
+ final String text = "compress me ".repeat(1000);
+ Restlet plain =
+ new Restlet() {
+ @Override
+ public void handle(Request request, Response response) {
+ response.setEntity(text, MediaType.TEXT_PLAIN);
+ }
+ };
+ // Encodes the responses, as an application with its encoder service enabled would.
+ Encoder encoder = new Encoder(new Context(), false, true, new EncoderService(true));
+ encoder.setNext(plain);
+ startServer("HTTP1_1,HTTP3", encoder);
+ startClient("HTTP3");
+
+ Request request = new Request(Method.GET, uri("/"));
+ request.getClientInfo().getAcceptedEncodings().add(new Preference<>(Encoding.GZIP));
+ Response response = client.handle(request);
+ assertEquals(Status.SUCCESS_OK, response.getStatus());
+ assertEquals(text, response.getEntityAsText());
+ }
+
+ @Test
+ void clientCertificateOverHttp3() throws Exception {
+ startServer(
+ "HTTP1_1,HTTP3",
+ new Restlet() {
+ @Override
+ public void handle(Request request, Response response) {
+ X509Certificate certificate =
+ (X509Certificate)
+ request.getClientInfo().getCertificates().getFirst();
+ response.setEntity(
+ certificate.getSubjectX500Principal().getName(),
+ MediaType.TEXT_PLAIN);
+ }
+ },
+ "truststorePath",
+ keystore.toString(),
+ "truststorePassword",
+ PASSWORD,
+ "trustStoreType",
+ "PKCS12",
+ "needClientAuthentication",
+ "true");
+ startClient(
+ "HTTP3",
+ "keystorePath",
+ keystore.toString(),
+ "keystorePassword",
+ PASSWORD,
+ "keyStoreType",
+ "PKCS12",
+ "keyPassword",
+ PASSWORD);
+
+ Response response = client.handle(new Request(Method.GET, uri("/")));
+ assertEquals(Status.SUCCESS_OK, response.getStatus());
+ assertTrue(response.getEntityAsText().contains("CN=localhost"));
+ }
+
+ @Test
+ void untrustedServerIsRejected() throws Exception {
+ startServer("HTTP1_1,HTTP3", echo);
+ // No trust store: the JDK default trust store doesn't trust the test certificate.
+ Context context = new Context();
+ context.getParameters().add("httpClientTransportMode", "HTTP3");
+ context.getParameters().add("connectTimeout", "5000");
+ client = new Client(context, Protocol.HTTPS);
+ client.start();
+
+ Response response = client.handle(new Request(Method.GET, uri("/")));
+ assertTrue(response.getStatus().isConnectorError(), response.getStatus().toString());
+ }
+
+ @Test
+ void http3AloneIsRejected() {
+ Context context = new Context();
+ context.getParameters().add("http.transport.protocols", "HTTP3");
+ context.getParameters().add("keystorePath", keystore.toString());
+ context.getParameters().add("keystorePassword", PASSWORD);
+ context.getParameters().add("keyStoreType", "PKCS12");
+ context.getParameters().add("keyPassword", PASSWORD);
+ Server alone = new Server(context, Protocol.HTTPS, 0, echo);
+ assertThrows(IllegalArgumentException.class, alone::start);
+ }
+
+ /** A repeatable representation of the given size with a known byte pattern. */
+ private static Representation pattern(final long size) {
+ return new OutputRepresentation(MediaType.APPLICATION_OCTET_STREAM, size) {
+ @Override
+ public void write(OutputStream out) throws IOException {
+ byte[] chunk = new byte[16 * 1024];
+ long written = 0;
+ while (written < size) {
+ int n = (int) Math.min(chunk.length, size - written);
+ for (int i = 0; i < n; i++) {
+ chunk[i] = (byte) ((written + i) % 251);
+ }
+ out.write(chunk, 0, n);
+ written += n;
+ }
+ }
+ };
+ }
+}
diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/HttpTransportProtocolsTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/HttpTransportProtocolsTestCase.java
index e8515e3cbb..f0044991b8 100644
--- a/org.restlet/src/test/java/org/restlet/engine/connector/HttpTransportProtocolsTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/HttpTransportProtocolsTestCase.java
@@ -45,7 +45,7 @@
import org.restlet.engine.ssl.DefaultSslContextFactory;
import org.restlet.util.Series;
-/** Test the support of HTTP2 and HTTP3 transport protocols. */
+/** Test the support of the HTTP/1.1 and HTTP/2 transport protocols. */
class HttpTransportProtocolsTestCase {
protected static final String KEYSTORE_FILE_NAME = "dummy.p12";
@@ -117,19 +117,21 @@ static Stream validTestCases() {
Arguments.of("HTTP1_1", newJdkHttpClient(HttpClient.Version.HTTP_2)),
Arguments.of("HTTP2", newJdkHttpClient(HttpClient.Version.HTTP_1_1)),
Arguments.of("HTTP1_1", newJdkHttpClient(HttpClient.Version.HTTP_2)),
- Arguments.of(null, newJettyHttpClient("HTTP1_1")), // server default to HTTP1_1
- Arguments.of(null, newJettyHttpClient("DYNAMIC")), // server default to HTTP1_1
- Arguments.of("HTTP1_1", newJettyHttpClient("HTTP1_1")),
- Arguments.of("HTTP1_1", newJettyHttpClient("DYNAMIC")),
- Arguments.of("HTTP2", newJettyHttpClient("HTTP1_1")),
- Arguments.of("HTTP2", newJettyHttpClient("HTTP2")),
- Arguments.of("HTTP2", newJettyHttpClient("DYNAMIC")));
+ Arguments.of(
+ null, newRestletHttpClient("HTTP1_1")), // server default to HTTP1_1
+ Arguments.of(
+ null, newRestletHttpClient("DYNAMIC")), // server default to HTTP1_1
+ Arguments.of("HTTP1_1", newRestletHttpClient("HTTP1_1")),
+ Arguments.of("HTTP1_1", newRestletHttpClient("DYNAMIC")),
+ Arguments.of("HTTP2", newRestletHttpClient("HTTP1_1")),
+ Arguments.of("HTTP2", newRestletHttpClient("HTTP2")),
+ Arguments.of("HTTP2", newRestletHttpClient("DYNAMIC")));
}
static Stream invalidTestCases() {
return Stream.of(
- Arguments.of(null, newJettyHttpClient("HTTP2")), // server default to HTTP1_1
- Arguments.of("HTTP1_1", newJettyHttpClient("HTTP2")));
+ Arguments.of(null, newRestletHttpClient("HTTP2")), // server default to HTTP1_1
+ Arguments.of("HTTP1_1", newRestletHttpClient("HTTP2")));
}
}
@@ -174,26 +176,28 @@ static Stream validTestCases() {
Arguments.of("HTTP1_1,HTTP2", newJdkHttpsClient(HttpClient.Version.HTTP_2)),
Arguments.of("HTTP2,HTTP1_1", newJdkHttpsClient(HttpClient.Version.HTTP_1_1)),
Arguments.of("HTTP2,HTTP1_1", newJdkHttpsClient(HttpClient.Version.HTTP_2)),
- Arguments.of(null, newJettyHttpsClient("HTTP1_1")), // server default to HTTP1_1
- Arguments.of(null, newJettyHttpsClient("DYNAMIC")), // server default to HTTP1_1
- Arguments.of("HTTP1_1", newJettyHttpsClient("HTTP1_1")),
- Arguments.of("HTTP1_1", newJettyHttpsClient("DYNAMIC")),
- Arguments.of("HTTP2", newJettyHttpsClient("HTTP2")),
- Arguments.of("HTTP2", newJettyHttpsClient("DYNAMIC")),
- Arguments.of("HTTP1_1,HTTP2", newJettyHttpsClient("HTTP1_1")),
- Arguments.of("HTTP1_1,HTTP2", newJettyHttpsClient("DYNAMIC")),
- Arguments.of("HTTP2,HTTP1_1", newJettyHttpsClient("HTTP1_1")),
- Arguments.of("HTTP2,HTTP1_1", newJettyHttpsClient("HTTP2")),
- Arguments.of("HTTP2,HTTP1_1", newJettyHttpsClient("DYNAMIC")));
+ Arguments.of(
+ null, newRestletHttpsClient("HTTP1_1")), // server default to HTTP1_1
+ Arguments.of(
+ null, newRestletHttpsClient("DYNAMIC")), // server default to HTTP1_1
+ Arguments.of("HTTP1_1", newRestletHttpsClient("HTTP1_1")),
+ Arguments.of("HTTP1_1", newRestletHttpsClient("DYNAMIC")),
+ Arguments.of("HTTP2", newRestletHttpsClient("HTTP2")),
+ Arguments.of("HTTP2", newRestletHttpsClient("DYNAMIC")),
+ Arguments.of("HTTP1_1,HTTP2", newRestletHttpsClient("HTTP1_1")),
+ Arguments.of("HTTP1_1,HTTP2", newRestletHttpsClient("HTTP2")),
+ Arguments.of("HTTP1_1,HTTP2", newRestletHttpsClient("DYNAMIC")),
+ Arguments.of("HTTP2,HTTP1_1", newRestletHttpsClient("HTTP1_1")),
+ Arguments.of("HTTP2,HTTP1_1", newRestletHttpsClient("HTTP2")),
+ Arguments.of("HTTP2,HTTP1_1", newRestletHttpsClient("DYNAMIC")));
}
static Stream invalidTestCases() {
return Stream.of(
Arguments.of("HTTP2", newJdkHttpsClient(HttpClient.Version.HTTP_1_1)),
- Arguments.of(null, newJettyHttpsClient("HTTP2")), // server default to http1
- Arguments.of("HTTP1_1", newJettyHttpsClient("HTTP2")),
- Arguments.of("HTTP2", newJettyHttpsClient("HTTP1_1")),
- Arguments.of("HTTP1_1,HTTP2", newJettyHttpsClient("HTTP2")));
+ Arguments.of(null, newRestletHttpsClient("HTTP2")), // server default to http1
+ Arguments.of("HTTP1_1", newRestletHttpsClient("HTTP2")),
+ Arguments.of("HTTP2", newRestletHttpsClient("HTTP1_1")));
}
@Override
@@ -285,12 +289,12 @@ private static TestHttpClient newJdkHttpsClient(final HttpClient.Version httpVer
return new JdkTestHttpClient(Protocol.HTTPS, httpVersion);
}
- private static TestHttpClient newJettyHttpClient(final String httpClientTransportMode) {
- return new JettyTestHttpClient(Protocol.HTTP, httpClientTransportMode);
+ private static TestHttpClient newRestletHttpClient(final String httpClientTransportMode) {
+ return new RestletTestHttpClient(Protocol.HTTP, httpClientTransportMode);
}
- private static TestHttpClient newJettyHttpsClient(final String httpClientTransportMode) {
- return new JettyTestHttpClient(Protocol.HTTPS, httpClientTransportMode);
+ private static TestHttpClient newRestletHttpsClient(final String httpClientTransportMode) {
+ return new RestletTestHttpClient(Protocol.HTTPS, httpClientTransportMode);
}
static class JdkTestHttpClient implements TestHttpClient {
@@ -343,12 +347,13 @@ public String toString() {
}
}
- static class JettyTestHttpClient implements TestHttpClient {
+ static class RestletTestHttpClient implements TestHttpClient {
final Protocol protocol;
final String httpClientTransportMode;
- private JettyTestHttpClient(final Protocol protocol, final String httpClientTransportMode) {
+ private RestletTestHttpClient(
+ final Protocol protocol, final String httpClientTransportMode) {
this.protocol = protocol;
this.httpClientTransportMode = httpClientTransportMode;
}
@@ -386,7 +391,7 @@ protected Context newClientContext() {
@Override
public String toString() {
- return "Jetty " + protocol.getSchemeName() + " " + httpClientTransportMode;
+ return "Restlet " + protocol.getSchemeName() + " " + httpClientTransportMode;
}
}
}
diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/MultiPartRepresentationTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/MultiPartRepresentationTestCase.java
index 1ff69ece4c..866d4a67c8 100644
--- a/org.restlet/src/test/java/org/restlet/engine/connector/MultiPartRepresentationTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/MultiPartRepresentationTestCase.java
@@ -9,19 +9,20 @@
package org.restlet.engine.connector;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
-import org.eclipse.jetty.client.StringRequestContent;
-import org.eclipse.jetty.http.HttpFields;
-import org.eclipse.jetty.http.MultiPart;
-import org.eclipse.jetty.http.MultiPart.Part;
-import org.eclipse.jetty.io.ByteBufferPool;
+import java.util.List;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import org.restlet.data.MediaType;
+import org.restlet.representation.FileRepresentation;
import org.restlet.representation.MultiPartRepresentation;
+import org.restlet.representation.MultiPartRepresentation.Part;
import org.restlet.representation.StringRepresentation;
/**
@@ -31,79 +32,67 @@
*/
class MultiPartRepresentationTestCase {
+ private static final String BOUNDARY = "-----------------------------1294919323195";
+
+ private static final String ENTITY =
+ """
+ --%s\r
+ Content-Disposition: form-data; name="field"\r
+ \r
+ foo\r
+ --%s\r
+ Content-Disposition: form-data; name="icon"; filename="text.txt"\r
+ \r
+ this is the content of the file\r
+ --%s--\r
+ """
+ .replace("%s", BOUNDARY);
+
+ @TempDir Path tempDir;
+
@Test
void testWriteFromParts() throws IOException {
- Path textFilePath = Files.createTempFile("multiPart", "");
+ Path textFilePath = Files.createTempFile(tempDir, "multiPart", "");
Files.writeString(textFilePath, "this is the content of the file");
- MultiPart.PathPart filePart =
- new MultiPart.PathPart(
- ByteBufferPool.SIZED_NON_POOLING,
+ Part filePart =
+ MultiPartRepresentation.createPart(
"icon",
"text.txt",
- HttpFields.EMPTY,
- textFilePath);
-
- MultiPart.ContentSourcePart contentSourcePart =
- new MultiPart.ContentSourcePart(
- "field", null, HttpFields.EMPTY, new StringRequestContent("foo"));
-
- final String boundary = "-----------------------------1294919323195";
-
- MultiPartRepresentation rep = new MultiPartRepresentation(contentSourcePart, filePart);
- rep.setBoundary(boundary);
-
- final String expected =
- """
- --%s\r
- Content-Disposition: form-data; name="field"\r
- \r
- foo\r
- --%s\r
- Content-Disposition: form-data; name="icon"; filename="text.txt"\r
- \r
- this is the content of the file\r
- --%s--\r
- """
- .replace("%s", boundary);
- assertEquals(expected, rep.getText());
+ new FileRepresentation(textFilePath.toFile(), MediaType.TEXT_PLAIN));
+ Part fieldPart = new Part("field", null, new StringRepresentation("foo"));
+
+ MultiPartRepresentation rep = new MultiPartRepresentation(fieldPart, filePart);
+ rep.setBoundary(BOUNDARY);
+
+ assertEquals(ENTITY, rep.getText());
}
/** Tests the multipart content-type. */
@Test
void testContentType() {
- MultiPart.ContentSourcePart contentSourcePart =
- new MultiPart.ContentSourcePart(
- "field", null, HttpFields.EMPTY, new StringRequestContent("foo"));
- MultiPartRepresentation rep =
- new MultiPartRepresentation("myInitialBoundary", contentSourcePart);
+ Part part = new Part("field", null, new StringRepresentation("foo"));
+ MultiPartRepresentation rep = new MultiPartRepresentation("myInitialBoundary", part);
rep.setBoundary("myActualBoundary");
assertEquals(
"multipart/form-data; boundary=myActualBoundary", rep.getMediaType().toString());
}
+ @Test
+ void testRandomBoundary() {
+ MultiPartRepresentation rep =
+ new MultiPartRepresentation(new Part("a", null, new StringRepresentation("b")));
+ assertEquals(rep.getBoundary(), MultiPartRepresentation.getBoundary(rep.getMediaType()));
+ }
+
@Test
void testParseIntoParts() throws IOException {
- final String boundary = "-----------------------------1294919323195";
- final String multipartEntityContent =
- """
- --%s\r
- Content-Disposition: form-data; name="field"\r
- \r
- foo\r
- --%s\r
- Content-Disposition: form-data; name="icon"; filename="text.txt"\r
- \r
- this is the content of the file\r
- --%s--\r
- """
- .replace("%s", boundary);
-
- StringRepresentation multipartEntity = new StringRepresentation(multipartEntityContent);
+ StringRepresentation multipartEntity = new StringRepresentation(ENTITY);
multipartEntity.setMediaType(
- MediaType.valueOf("multipart/form-data; boundary=" + boundary));
- Path tempDir = Files.createTempDirectory("multipartRepresentationTestCase");
+ MediaType.valueOf("multipart/form-data; boundary=" + BOUNDARY));
MultiPartRepresentation rep = new MultiPartRepresentation(multipartEntity, tempDir);
+ assertEquals(2, rep.getParts().size());
+
Part part1 = rep.getParts().getFirst();
assertEquals("field", part1.getName());
assertNull(part1.getFileName());
@@ -116,4 +105,52 @@ void testParseIntoParts() throws IOException {
assertEquals(31, part2.getLength());
assertEquals("this is the content of the file", part2.getContentAsString(null));
}
+
+ @Test
+ void testRoundTripWithHeadersAndLargePart() throws IOException {
+ String large = "x\r\n--almost-a-boundary\r".repeat(10_000);
+ StringRepresentation typed = new StringRepresentation("{\"a\":1}");
+ Part jsonPart =
+ new Part(
+ "data",
+ null,
+ new org.restlet.util.Series<>(org.restlet.data.Header.class),
+ typed);
+ jsonPart.getHeaders().add("Content-Type", "application/json");
+ Part largePart = new Part("big", "big.txt", new StringRepresentation(large));
+
+ MultiPartRepresentation generated =
+ new MultiPartRepresentation(List.of(jsonPart, largePart));
+ StringRepresentation entity = new StringRepresentation(generated.getText());
+ entity.setMediaType(generated.getMediaType());
+
+ MultiPartRepresentation parsed = new MultiPartRepresentation(entity, tempDir);
+ assertEquals(2, parsed.getParts().size());
+
+ Part p1 = parsed.getParts().getFirst();
+ assertEquals("data", p1.getName());
+ assertEquals("{\"a\":1}", p1.getContentAsString(null));
+ assertEquals(MediaType.APPLICATION_JSON, p1.getContent().getMediaType());
+
+ Part p2 = parsed.getParts().get(1);
+ assertEquals("big.txt", p2.getFileName());
+ assertEquals(large.length(), p2.getLength());
+ assertInstanceOf(FileRepresentation.class, p2.getContent());
+ assertEquals(large, p2.getContentAsString(null));
+ }
+
+ @Test
+ void testParseTruncatedEntity() {
+ String truncated = ENTITY.substring(0, ENTITY.indexOf("this is"));
+ StringRepresentation entity = new StringRepresentation(truncated);
+ entity.setMediaType(MediaType.valueOf("multipart/form-data; boundary=" + BOUNDARY));
+ assertThrows(IOException.class, () -> new MultiPartRepresentation(entity));
+ }
+
+ @Test
+ void testParseRequiresBoundary() {
+ StringRepresentation entity = new StringRepresentation(ENTITY);
+ entity.setMediaType(MediaType.MULTIPART_FORM_DATA);
+ assertThrows(IllegalArgumentException.class, () -> new MultiPartRepresentation(entity));
+ }
}
diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/ProxyTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/ProxyTestCase.java
new file mode 100644
index 0000000000..1ef0fc8f83
--- /dev/null
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/ProxyTestCase.java
@@ -0,0 +1,302 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.connector;
+
+import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.restlet.Client;
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+import org.restlet.Server;
+import org.restlet.data.MediaType;
+import org.restlet.data.Method;
+import org.restlet.data.Protocol;
+import org.restlet.data.Status;
+import org.restlet.engine.Engine;
+
+/**
+ * Tests the HTTP proxy support of the client connector: absolute-form requests for HTTP, and
+ * CONNECT tunnels for HTTPS.
+ */
+class ProxyTestCase {
+
+ @TempDir Path tempDir;
+
+ private TestProxy proxy;
+
+ private Server server;
+
+ private Client client;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ Engine.clearThreadLocalVariables();
+ Engine.register(false);
+ Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null));
+ Engine.getInstance().getRegisteredServers().add(new HttpsServerHelper(null));
+ Engine.getInstance().getRegisteredClients().add(new HttpClientHelper(null));
+ proxy = new TestProxy();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (client != null) {
+ client.stop();
+ }
+ if (server != null) {
+ server.stop();
+ }
+ proxy.close();
+ Engine.clearThreadLocalVariables();
+ }
+
+ private static final Restlet HELLO =
+ new Restlet() {
+ @Override
+ public void handle(Request request, Response response) {
+ response.setEntity(
+ "hello " + request.getResourceRef().getPath(), MediaType.TEXT_PLAIN);
+ }
+ };
+
+ @Test
+ void forwardsHttpRequestsWithAbsoluteUris() throws Exception {
+ server = new Server(new Context(), Protocol.HTTP, 0, HELLO);
+ server.start();
+
+ Context context = new Context();
+ context.getParameters().add("proxyHost", "localhost");
+ context.getParameters().add("proxyPort", Integer.toString(proxy.port()));
+ client = new Client(context, Protocol.HTTP);
+ client.start();
+
+ String uri = "http://localhost:" + server.getActualPort() + "/a?b=c";
+ for (int i = 0; i < 2; i++) {
+ Response response = client.handle(new Request(Method.GET, uri));
+ assertEquals(Status.SUCCESS_OK, response.getStatus());
+ assertEquals("hello /a", response.getEntityAsText());
+ }
+
+ // The connection to the proxy is reused: the proxy relays it to the origin after the first
+ // request head, which it receives in absolute form.
+ assertEquals("GET " + uri + " HTTP/1.1", proxy.requestLines.getFirst());
+ }
+
+ @Test
+ void tunnelsHttpsRequestsWithConnect() throws Exception {
+ Path keystore = tempDir.resolve("dummy.p12");
+ try (InputStream in = SslBaseConnectorsTestCase.class.getResourceAsStream("dummy.p12")) {
+ Files.copy(in, keystore, REPLACE_EXISTING);
+ }
+
+ Context serverContext = new Context();
+ serverContext.getParameters().add("keystorePath", keystore.toString());
+ serverContext.getParameters().add("keystorePassword", "testtest");
+ serverContext.getParameters().add("keyStoreType", "PKCS12");
+ serverContext.getParameters().add("keyPassword", "testtest");
+ server = new Server(serverContext, Protocol.HTTPS, 0, HELLO);
+ server.start();
+
+ Context context = new Context();
+ context.getParameters().add("proxyHost", "localhost");
+ context.getParameters().add("proxyPort", Integer.toString(proxy.port()));
+ context.getParameters().add("truststorePath", keystore.toString());
+ context.getParameters().add("truststorePassword", "testtest");
+ context.getParameters().add("trustStoreType", "PKCS12");
+ client = new Client(context, Protocol.HTTPS);
+ client.start();
+
+ Response response =
+ client.handle(
+ new Request(
+ Method.GET,
+ "https://localhost:" + server.getActualPort() + "/secure"));
+ assertEquals(Status.SUCCESS_OK, response.getStatus());
+ assertEquals("hello /secure", response.getEntityAsText());
+
+ assertEquals(1, proxy.requestLines.size());
+ assertEquals(
+ "CONNECT localhost:" + server.getActualPort() + " HTTP/1.1",
+ proxy.requestLines.getFirst());
+ }
+
+ @Test
+ void reportsUnreachableProxy() throws Exception {
+ server = new Server(new Context(), Protocol.HTTP, 0, HELLO);
+ server.start();
+ int closedPort;
+ try (ServerSocket socket = new ServerSocket(0)) {
+ closedPort = socket.getLocalPort();
+ }
+
+ Context context = new Context();
+ context.getParameters().add("proxyHost", "localhost");
+ context.getParameters().add("proxyPort", Integer.toString(closedPort));
+ context.getParameters().add("connectTimeout", "2000");
+ client = new Client(context, Protocol.HTTP);
+ client.start();
+
+ Response response =
+ client.handle(
+ new Request(Method.GET, "http://localhost:" + server.getActualPort()));
+ assertTrue(response.getStatus().isConnectorError(), response.getStatus().toString());
+ }
+
+ /**
+ * Minimal HTTP proxy: forwards absolute-form requests (one connection to the origin per client
+ * connection, bytes relayed both ways) and opens CONNECT tunnels.
+ */
+ private static final class TestProxy implements AutoCloseable {
+
+ final List requestLines = new CopyOnWriteArrayList<>();
+
+ private final ServerSocket serverSocket;
+
+ private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
+
+ TestProxy() throws IOException {
+ serverSocket = new ServerSocket(0, 50, InetAddress.getLoopbackAddress());
+ executor.execute(this::acceptLoop);
+ }
+
+ int port() {
+ return serverSocket.getLocalPort();
+ }
+
+ private void acceptLoop() {
+ try {
+ while (!serverSocket.isClosed()) {
+ Socket socket = serverSocket.accept();
+ executor.execute(() -> handle(socket));
+ }
+ } catch (IOException _) {
+ // closed
+ }
+ }
+
+ private void handle(Socket client) {
+ try (client) {
+ InputStream in = client.getInputStream();
+ String head = readHead(in);
+ String requestLine = head.substring(0, head.indexOf("\r\n"));
+ requestLines.add(requestLine);
+ String[] parts = requestLine.split(" ");
+
+ if ("CONNECT".equals(parts[0])) {
+ String[] hostPort = parts[1].split(":");
+ try (Socket origin = new Socket(hostPort[0], Integer.parseInt(hostPort[1]))) {
+ client.getOutputStream()
+ .write(
+ "HTTP/1.1 200 Connection established\r\n\r\n"
+ .getBytes(StandardCharsets.US_ASCII));
+ client.getOutputStream().flush();
+ relay(client, in, origin);
+ }
+ } else {
+ URI uri = URI.create(parts[1]);
+ try (Socket origin = new Socket(uri.getHost(), uri.getPort())) {
+ // Rewrite the request line in origin form, keep the rest as is.
+ String path =
+ uri.getRawPath()
+ + (uri.getRawQuery() == null
+ ? ""
+ : "?" + uri.getRawQuery());
+ String rewritten =
+ parts[0]
+ + " "
+ + path
+ + " "
+ + parts[2]
+ + head.substring(head.indexOf("\r\n"));
+ origin.getOutputStream()
+ .write(rewritten.getBytes(StandardCharsets.ISO_8859_1));
+ origin.getOutputStream().flush();
+ relay(client, in, origin);
+ }
+ }
+ } catch (IOException _) {
+ // connection closed
+ }
+ }
+
+ /** Relays bytes both ways until one side closes. */
+ private void relay(Socket client, InputStream clientIn, Socket origin) throws IOException {
+ Thread upstream =
+ Thread.ofVirtual()
+ .start(
+ () -> {
+ try {
+ clientIn.transferTo(origin.getOutputStream());
+ } catch (IOException _) {
+ // closed
+ } finally {
+ try {
+ origin.shutdownOutput();
+ } catch (IOException _) {
+ // ignore
+ }
+ }
+ });
+ try {
+ origin.getInputStream().transferTo(client.getOutputStream());
+ } finally {
+ client.shutdownOutput();
+ try {
+ upstream.join(5000);
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ /** Reads the request head, up to and including the empty line. */
+ private static String readHead(InputStream in) throws IOException {
+ ByteArrayOutputStream head = new ByteArrayOutputStream();
+ int matched = 0;
+ int c;
+ while (matched < 4 && (c = in.read()) != -1) {
+ head.write(c);
+ matched =
+ (c == '\r' && (matched == 0 || matched == 2))
+ || (c == '\n' && (matched == 1 || matched == 3))
+ ? matched + 1
+ : (c == '\r' ? 1 : 0);
+ }
+ return head.toString(StandardCharsets.ISO_8859_1);
+ }
+
+ @Override
+ public void close() throws IOException {
+ serverSocket.close();
+ executor.shutdownNow();
+ }
+ }
+}
diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/README.md b/org.restlet/src/test/java/org/restlet/engine/connector/README.md
index a430b40dc6..678070f1a7 100644
--- a/org.restlet/src/test/java/org/restlet/engine/connector/README.md
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/README.md
@@ -1,32 +1,27 @@
-# Debug Jetty
+# Debug the Netty connectors
-## Add full logs
+The HTTP connectors of the core are based on Netty. Netty logs through `java.util.logging` when no
+other logging framework (SLF4J, Log4j 2) is found, under the `io.netty` logger names.
-[Jetty's documentation](https://jetty.org/docs/jetty/12/programming-guide/troubleshooting/logging.html) (the `org.eclipse.jetty:jetty-slf4j-impl`is already added to the `pom.xml`).
+To trace the connectors in a test:
-Programmatically:
-```
-System.setProperty("org.eclipse.jetty.LEVEL", "TRACE");
+```java
+Engine.setLogLevel(Level.FINE);
+Logger.getLogger("io.netty").setLevel(Level.FINE);
```
-Or add a `jetty-logging.properties`:
-```
-org.eclipse.jetty.LEVEL=TRACE
-org.eclipse.jetty.client.LEVEL=TRACE
-```
+To dump the HTTP traffic, add a Netty `LoggingHandler` at the start of the connection pipeline, in
+`NettyServerHelper.start()` (server) or `NettyClientHelper.initConnection(...)` (client):
-## Debug using JMX
-You need to update the current implementation by hand.
+```java
+p.addFirst("wire", new io.netty.handler.logging.LoggingHandler(io.netty.handler.logging.LogLevel.INFO));
+```
-- [activate JMX](https://jetty.org/docs/jetty/12/programming-guide/arch/jmx.html)
-The Jetty server is created in class `JettyServerHelper`.
+Server calls run on virtual threads by default. A thread dump that includes them can be taken with:
```
- // Create an MBeanContainer with the platform MBeanServer.
- MBeanContainer mbeanContainer = new MBeanContainer(ManagementFactory.getPlatformMBeanServer());
- // Add MBeanContainer to the root component.
- jettyServer.addBean(mbeanContainer);
+jcmd Thread.dump_to_file -format=text threads.txt
```
-- to [state tracking](https://jetty.org/docs/jetty/12/programming-guide/troubleshooting/state-tracking.html)
- You can use [jconsole](https://docs.oracle.com/javase/8/docs/technotes/guides/management/jconsole.html) to check the state of MBean or just run operations on them.
+Set the "threadPool.virtual" server parameter to `false` to run calls on platform threads instead,
+which `jstack` also shows.
diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/RedirectTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/RedirectTestCase.java
new file mode 100644
index 0000000000..54a3d5eccd
--- /dev/null
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/RedirectTestCase.java
@@ -0,0 +1,217 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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
+ *
+ * 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.engine.connector;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+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.Restlet;
+import org.restlet.Server;
+import org.restlet.data.MediaType;
+import org.restlet.data.Method;
+import org.restlet.data.Protocol;
+import org.restlet.data.Status;
+import org.restlet.engine.Engine;
+
+/**
+ * Tests that the server answers 503 Service Unavailable once "server.maxConcurrentCalls" calls are
+ * in progress, and accepts calls again once they complete.
+ */
+class ServerMaxConcurrentCallsTestCase {
+
+ private static final int MAX_CALLS = 2;
+
+ private final CountDownLatch inProgress = new CountDownLatch(MAX_CALLS);
+
+ private final CountDownLatch release = new CountDownLatch(1);
+
+ private Server server;
+
+ private Client client;
+
+ 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));
+
+ Context context = new Context();
+ context.getParameters().add("server.maxConcurrentCalls", Integer.toString(MAX_CALLS));
+ server =
+ new Server(
+ context,
+ Protocol.HTTP,
+ 0,
+ new Restlet() {
+ @Override
+ public void handle(Request request, Response response) {
+ if ("/block".equals(request.getResourceRef().getPath())) {
+ inProgress.countDown();
+ try {
+ release.await(30, TimeUnit.SECONDS);
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ response.setEntity("done", MediaType.TEXT_PLAIN);
+ }
+ });
+ server.start();
+ uri = "http://localhost:" + server.getActualPort();
+
+ client = new Client(new Context(), Protocol.HTTP);
+ client.start();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ release.countDown();
+ client.stop();
+ server.stop();
+ Engine.clearThreadLocalVariables();
+ }
+
+ @Test
+ void rejectsCallsAboveTheLimit() throws Exception {
+ try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
+ List> blocked = new ArrayList<>();
+ for (int i = 0; i < MAX_CALLS; i++) {
+ blocked.add(
+ executor.submit(
+ () ->
+ client.handle(new Request(Method.GET, uri + "/block"))
+ .getStatus()));
+ }
+ assertTrue(inProgress.await(10, TimeUnit.SECONDS), "The calls didn't start");
+
+ // The limit is reached: extra calls are rejected right away.
+ for (int i = 0; i < 3; i++) {
+ Response response = client.handle(new Request(Method.GET, uri + "/extra"));
+ assertEquals(Status.SERVER_ERROR_SERVICE_UNAVAILABLE, response.getStatus());
+ }
+
+ // Once the calls complete, the server accepts calls again.
+ release.countDown();
+ for (Future status : blocked) {
+ assertEquals(Status.SUCCESS_OK, status.get(10, TimeUnit.SECONDS));
+ }
+ Response response = client.handle(new Request(Method.GET, uri + "/extra"));
+ assertEquals(Status.SUCCESS_OK, response.getStatus());
+ assertEquals("done", response.getEntityAsText());
+ }
+ }
+}
diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/ServerMaxConnectionsTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/ServerMaxConnectionsTestCase.java
index d71857fabf..8dc96029bb 100644
--- a/org.restlet/src/test/java/org/restlet/engine/connector/ServerMaxConnectionsTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/ServerMaxConnectionsTestCase.java
@@ -46,9 +46,6 @@ protected void configureServer(final Server server) {
final Series parameters = server.getContext().getParameters();
parameters.add("server.maxConnections", Integer.toString(CONNECTIONS_NUMBER));
- parameters.add(
- "connector.acceptors",
- Integer.toString(CONCURRENT_REQUESTS)); // server can accept all requests
}
@Override
diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/ShutdownHookTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/ShutdownHookTestCase.java
index c585cbba79..579739fd41 100644
--- a/org.restlet/src/test/java/org/restlet/engine/connector/ShutdownHookTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/ShutdownHookTestCase.java
@@ -141,8 +141,7 @@ void whenServerIsHandlingBlockingRequestThenItGracefullyWaitsFor1SecondBeforeSto
assertIntervalBetweenDatesEquals(
shutdownTimeout, serverAskedToStopInstant, hangingClient.stoppedAt);
- assertIntervalBetweenDatesEquals(
- toJettyEffectiveTimeout(shutdownTimeout), serverAskedToStopInstant, Instant.now());
+ assertIntervalBetweenDatesEquals(shutdownTimeout, serverAskedToStopInstant, Instant.now());
}
/**
@@ -190,8 +189,7 @@ void whenServerIsHandlingBlockingRequestThenItRefusesNewRequest() throws Excepti
assertTrue(
blockedTestClient.cr.getStatus().isConnectorError(),
"Any new client is blocked and fails with a connection error");
- assertIntervalBetweenDatesEquals(
- toJettyEffectiveTimeout(shutdownTimeout), serverAskedToStopInstant, Instant.now());
+ assertIntervalBetweenDatesEquals(shutdownTimeout, serverAskedToStopInstant, Instant.now());
}
/**
@@ -282,7 +280,7 @@ private Server startServer(
Engine.getInstance()
.getRegisteredServers()
- .addFirst(new HttpServerHelper(null)); // Creates a Jetty server helper manually
+ .addFirst(new HttpServerHelper(null)); // Creates a Netty server helper manually
// 0 port means it will be computed when the server starts
Server server =
new Server(
@@ -295,16 +293,9 @@ private Server startServer(
if (shouldDebug) {
server.getContext().getParameters().add("tracing", "true");
- System.setProperty("org.eclipse.jetty.LEVEL", "TRACE");
Engine.setLogLevel(Level.FINE);
}
- if (graceful) {
- // Don't let the lowResource monitor mess with the current test
- server.getContext()
- .getParameters()
- .add("lowResource.idleTimeout", Long.toString(timeout.toMillis() * 10));
- }
server.getContext().getParameters().add("shutdown.gracefully", Boolean.toString(graceful));
server.getContext()
.getParameters()
@@ -330,8 +321,7 @@ public void handle(final Request request, final Response response) {
try {
Thread.sleep(requestHangingTime.toMillis());
} catch (Exception e) {
- // silently stops, especially when Jetty server will abruptly quit after time
- // out
+ // silently stops, especially when the server abruptly quits after time out
LOGGER.log(Level.FINE, "Restlet error", e);
}
log("Restlet woke up, answering");
@@ -348,8 +338,7 @@ public void handle(final Request request, final Response response) {
try {
Thread.sleep(requestHangingTime.toMillis());
} catch (Exception e) {
- // silently stops, especially when Jetty server will abruptly quit after time
- // out
+ // silently stops, especially when the server abruptly quits after time out
LOGGER.log(Level.FINE, "Restlet error", e);
}
LOGGER.log(Level.FINE, "Restlet woke up, answering");
@@ -392,7 +381,7 @@ private synchronized Instant stopServer(final Server server) {
final HttpServerHelper serverHelper =
(HttpServerHelper)
server.getContext().getAttributes().get("org.restlet.engine.helper");
- serverHelper.getWrappedServer().stop();
+ serverHelper.stop();
log("Server stopped");
} catch (Exception e) {
// silently ignore errors
@@ -401,14 +390,6 @@ private synchronized Instant stopServer(final Server server) {
return serverAskedToStopInstant;
}
- /**
- * Returns the effective Jetty timeout since there is an extra half-timeout in the {@link
- * org.eclipse.jetty.util.thread.QueuedThreadPool}.
- */
- private Duration toJettyEffectiveTimeout(final Duration timeout) {
- return timeout.plusMillis(500); // Accounts for Jetty's extra half-timeout behavior.
- }
-
private static void log(final String message) {
LOGGER.info(Instant.now().toString() + " " + message);
}
diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/SslBaseConnectorsTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/SslBaseConnectorsTestCase.java
index ebcc7dfbf6..f0ce534c97 100644
--- a/org.restlet/src/test/java/org/restlet/engine/connector/SslBaseConnectorsTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/SslBaseConnectorsTestCase.java
@@ -63,7 +63,7 @@ public static void globalSetUp() throws IOException {
@Override
protected List listTestCases() {
- return List.of(new ConnectorsPair(HttpServer.JETTY_HTTPS, HttpClient.JETTY));
+ return List.of(new ConnectorsPair(HttpServer.NETTY_HTTPS, HttpClient.NETTY));
}
@Override
diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/SslClientAuthenticationTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/SslClientAuthenticationTestCase.java
new file mode 100644
index 0000000000..92d46c4412
--- /dev/null
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/SslClientAuthenticationTestCase.java
@@ -0,0 +1,158 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
import org.restlet.resource.ServerResource;
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedInterface03.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedInterface03.java
similarity index 91%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedInterface03.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedInterface03.java
index 3a5a84fd9d..41db5d18ff 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedInterface03.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedInterface03.java
@@ -6,7 +6,7 @@
*
* Restlet is a registered trademark of QlikTech International AB.
*/
-package org.restlet.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
/**
* Annotated interface that extends {@link AnnotatedInterface03_01} and {@link
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedInterface03_01.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedInterface03_01.java
similarity index 91%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedInterface03_01.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedInterface03_01.java
index e888496f8f..d4cd36c7a8 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedInterface03_01.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedInterface03_01.java
@@ -6,7 +6,7 @@
*
* Restlet is a registered trademark of QlikTech International AB.
*/
-package org.restlet.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
import org.restlet.resource.Get;
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedInterface03_02.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedInterface03_02.java
similarity index 91%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedInterface03_02.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedInterface03_02.java
index 4e6d7136a7..2bd54a9283 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedInterface03_02.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedInterface03_02.java
@@ -6,7 +6,7 @@
*
* Restlet is a registered trademark of QlikTech International AB.
*/
-package org.restlet.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
import org.restlet.resource.Post;
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedResource09TestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedResource09TestCase.java
similarity index 98%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedResource09TestCase.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedResource09TestCase.java
index 7066f5446b..f6ae07ffef 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedResource09TestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedResource09TestCase.java
@@ -6,7 +6,7 @@
*
* Restlet is a registered trademark of QlikTech International AB.
*/
-package org.restlet.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -34,7 +34,7 @@
*
* @author Thierry Boileau
*/
-class AnnotatedResource09TestCase extends JettyConnectorTestCase {
+class AnnotatedResource09TestCase extends ConnectorTestCase {
public static final Method SI =
new Method("SI", "What a method!", "http://restlet.org", true, true);
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedResource10TestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedResource10TestCase.java
similarity index 94%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedResource10TestCase.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedResource10TestCase.java
index 31c2be7686..874acf7a82 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedResource10TestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedResource10TestCase.java
@@ -6,7 +6,7 @@
*
* Restlet is a registered trademark of QlikTech International AB.
*/
-package org.restlet.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -27,7 +27,7 @@
*
* @author Thierry Boileau
*/
-class AnnotatedResource10TestCase extends JettyConnectorTestCase {
+class AnnotatedResource10TestCase extends ConnectorTestCase {
protected Application createApplication(final String path) {
return new Application() {
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedResource11TestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedResource11TestCase.java
similarity index 94%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedResource11TestCase.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedResource11TestCase.java
index 7accc54e4e..f85d63cef9 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AnnotatedResource11TestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AnnotatedResource11TestCase.java
@@ -6,7 +6,7 @@
*
* Restlet is a registered trademark of QlikTech International AB.
*/
-package org.restlet.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -27,7 +27,7 @@
*
* @author Thierry Boileau
*/
-class AnnotatedResource11TestCase extends JettyConnectorTestCase {
+class AnnotatedResource11TestCase extends ConnectorTestCase {
protected Application createApplication(final String path) {
return new Application() {
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/JettyConnectorTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/ConnectorTestCase.java
similarity index 95%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/JettyConnectorTestCase.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/ConnectorTestCase.java
index 2b165c0e60..02e035b249 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/JettyConnectorTestCase.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/ConnectorTestCase.java
@@ -6,7 +6,7 @@
*
* Restlet is a registered trademark of QlikTech International AB.
*/
-package org.restlet.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
import java.util.logging.Level;
import org.junit.jupiter.api.AfterEach;
@@ -28,7 +28,7 @@
*
* @author Jerome Louvel
*/
-public abstract class JettyConnectorTestCase {
+public abstract class ConnectorTestCase {
private Component c;
@@ -36,7 +36,7 @@ public abstract class JettyConnectorTestCase {
private String uri;
- protected JettyConnectorTestCase() {
+ protected ConnectorTestCase() {
super();
}
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/MyResource09.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/MyResource09.java
similarity index 98%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/MyResource09.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/MyResource09.java
index b4dae75fff..dc1faf2570 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/MyResource09.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/MyResource09.java
@@ -6,7 +6,7 @@
*
* Restlet is a registered trademark of QlikTech International AB.
*/
-package org.restlet.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
import org.restlet.data.Form;
import org.restlet.resource.ServerResource;
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/MyResource10.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/MyResource10.java
similarity index 90%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/MyResource10.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/MyResource10.java
index 78e6fd734c..bb8e944197 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/MyResource10.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/MyResource10.java
@@ -6,7 +6,7 @@
*
* Restlet is a registered trademark of QlikTech International AB.
*/
-package org.restlet.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
/**
* Resource that inherits annotated methods.
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/MyResource11.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/MyResource11.java
similarity index 92%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/MyResource11.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/MyResource11.java
index a225f9983b..8f0a07d7d1 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/MyResource11.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/MyResource11.java
@@ -6,7 +6,7 @@
*
* Restlet is a registered trademark of QlikTech International AB.
*/
-package org.restlet.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
import org.restlet.resource.Get;
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/SIMethod.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/SIMethod.java
similarity index 93%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/SIMethod.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/SIMethod.java
index 71fc739a5c..12477199db 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/SIMethod.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/SIMethod.java
@@ -6,7 +6,7 @@
*
* Restlet is a registered trademark of QlikTech International AB.
*/
-package org.restlet.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/SNIMethod.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/SNIMethod.java
similarity index 93%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/SNIMethod.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/SNIMethod.java
index 7ddabbd8ce..64b41ebe20 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/SNIMethod.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/SNIMethod.java
@@ -6,7 +6,7 @@
*
* Restlet is a registered trademark of QlikTech International AB.
*/
-package org.restlet.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/USIMethod.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/USIMethod.java
similarity index 93%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/USIMethod.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/USIMethod.java
index 4ae5939759..95b8dcec83 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/USIMethod.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/USIMethod.java
@@ -6,7 +6,7 @@
*
* Restlet is a registered trademark of QlikTech International AB.
*/
-package org.restlet.engine.jetty.resource;
+package org.restlet.engine.connector.resource;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/USNIMethod.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/USNIMethod.java
similarity index 93%
rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/USNIMethod.java
rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/USNIMethod.java
index 56c03c343b..3638a6bcb0 100644
--- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/USNIMethod.java
+++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/USNIMethod.java
@@ -6,7 +6,7 @@
*