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 @@ * *
  * SpringContext springContext = new SpringContext(getContext());
- * springContext.getPropertyConfigRefs().add("war://config/database.properties");
- * springContext.getXmlConfigRefs().add("war://config/applicationContext.xml");
+ * springContext.getPropertyConfigRefs().add("clap://thread/config/database.properties");
  * springContext.getXmlConfigRefs().add(
  *         "file:///C/myApp/config/applicationContext.xml");
  * springContext.getXmlConfigRefs().add(
diff --git a/org.restlet/pom.xml b/org.restlet/pom.xml
index c1e1bc4cfa..a41c5dcfb2 100644
--- a/org.restlet/pom.xml
+++ b/org.restlet/pom.xml
@@ -16,88 +16,46 @@
     
         
         9
+        
+        linux-x86_64
     
 
     
         
-            org.eclipse.jetty
-            jetty-server
-            ${lib-jetty-version}
+            io.netty
+            netty-codec-http
         
         
-            org.eclipse.jetty.http2
-            jetty-http2-server
-            ${lib-jetty-version}
+            io.netty
+            netty-codec-http2
         
         
-            org.eclipse.jetty
-            jetty-alpn-server
-            ${lib-jetty-version}
+            io.netty
+            netty-handler
         
         
-            org.eclipse.jetty
-            jetty-alpn-java-server
-            ${lib-jetty-version}
+            io.netty
+            netty-handler-proxy
         
+        
         
-            org.eclipse.jetty.http3
-            jetty-http3-server
-            ${lib-jetty-version}
+            io.netty
+            netty-codec-http3
+            true
+            
+                
+                    io.netty
+                    netty-codec-native-quic
+                
+            
         
         
-            org.eclipse.jetty.quic
-            jetty-quic-server
-            ${lib-jetty-version}
-        
-        
-            org.eclipse.jetty
-            jetty-client
-            ${lib-jetty-version}
-        
-        
-            org.eclipse.jetty.http2
-            jetty-http2-client
-            ${lib-jetty-version}
-        
-        
-            org.eclipse.jetty.http2
-            jetty-http2-client-transport
-            ${lib-jetty-version}
-        
-        
-            org.eclipse.jetty.http3
-            jetty-http3-client
-            ${lib-jetty-version}
-        
-        
-            org.eclipse.jetty.http3
-            jetty-http3-client-transport
-            ${lib-jetty-version}
-        
-        
-            org.eclipse.jetty
-            jetty-slf4j-impl
-            ${lib-jetty-version}
-        
-        
-            org.eclipse.jetty
-            jetty-util
-            ${lib-jetty-version}
-        
-        
-            org.eclipse.jetty
-            jetty-io
-            ${lib-jetty-version}
-        
-        
-            org.eclipse.jetty
-            jetty-http
-            ${lib-jetty-version}
-        
-        
-            org.eclipse.jetty.quic
-            jetty-quic-client
-            ${lib-jetty-version}
+            io.netty
+            netty-codec-native-quic
+            ${netty.quic.classifier}
+            test
         
 
         
@@ -175,4 +133,43 @@
             
         
     
+
+    
+    
+        
+            quic-linux-x86_64
+            
+                unixLinuxamd64
+            
+            linux-x86_64
+        
+        
+            quic-linux-aarch_64
+            
+                unixLinuxaarch64
+            
+            linux-aarch_64
+        
+        
+            quic-osx-x86_64
+            
+                macx86_64
+            
+            osx-x86_64
+        
+        
+            quic-osx-aarch_64
+            
+                macaarch64
+            
+            osx-aarch_64
+        
+        
+            quic-windows-x86_64
+            
+                windowsamd64
+            
+            windows-x86_64
+        
+    
 
diff --git a/org.restlet/src/main/java/module-info.java b/org.restlet/src/main/java/module-info.java
index 078e6b65b5..7d442b7859 100644
--- a/org.restlet/src/main/java/module-info.java
+++ b/org.restlet/src/main/java/module-info.java
@@ -7,25 +7,28 @@
  * 

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 nameValue typeDefault valueDescription
addressResolutionTimeoutlong15000The timeout in milliseconds for the DNS resolution of host addresses
bindAddressStringnullThe address to bind socket channels to. You must set both this and bindPort
bindPortintnullThe address to bind socket channels to. You must set both this and bindAddress
cookieSupportedbooleanfalseWhether to support HTTP cookie, storing and automatically sending them back
connectBlockingbooleanfalseIndicates whether the connect operation is blocking. See {@link HttpClient#isConnectBlocking()}.
connectTimeoutlong15000The max time in milliseconds a connection can take to connect to destinations
destinationIdleTimeoutlong0the time in milliseconds after which idle destinations are removed
followRedirectsbooleantrueWhether to follow HTTP redirects
httpComplianceModeStringRFC7230Indicate the HTTP compliance mode among the following options: "RFC7230", "RFC2616", "LEGACY", "RFC7230_LEGACY". - * See {@link HttpCompliance}.
httpClientTransportModeStringHTTP1_1Indicate the HTTP client transport mode among the following options: "HTTP1_1", "HTTP2", "HTTP3", "DYNAMIC". See - * {@link HttpClientTransport}.
idleTimeoutlong30000The max time in milliseconds a connection can be idle (that is, without traffic of bytes in either - * direction)
maxConnectionsPerDestinationint64Sets the max number of connections to open to each destination
maxRedirectsint8The max number of HTTP redirects that are followed
maxRequestsQueuedPerDestinationint1024Sets the max number of requests that may be queued to a destination
maxResponseHeaderSizeint-1The max size in bytes of the response headers. -1 is unlimited.
proxyHostStringSystem property "http.proxyHost"The host name of the HTTP proxy.
proxyPortintSystem property "http.proxyPort" or "3128"The port of the HTTP proxy.
requestBufferSizeint4096The size in bytes of the buffer used to write requests
responseBufferSizeint16384The size in bytes of the buffer used to read responses
strictEventOrderingbooleanfalseWhether request events must be strictly ordered
userAgentFieldStringnullThe "User-Agent" HTTP header string; when null, uses the Jetty default
sslContextFactoryStringorg.restlet.ext.ssl.DefaultSslContextFactoryLet 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
http3PemWorkDirstringNo default valueDirectory 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.modestringParameter nameValue typeDefault valueDescription
http.transport.protocolStringHTTP1_1Supported 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. * * * @@ -48,32 +32,27 @@ * * * - * + * * * * - * + * * - * - * - * - * - * - * - * + * * *
list of supported parameters
sslContextFactoryStringorg.restlet.engine.ssl.DefaultSslContextFactoryLet 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 settingLet 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.protocolsstringStringHTTP1_1Comma separated and sorted list of supported protocols. Available values: HTTP1_1, HTTP2, HTTP3.
http3.pem.workdirstringNo default valueDirectory 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 nameValue typeDefault valueDescription
threadPool.minThreadsint8Thread pool minimum threads
threadPool.maxThreadsint200Thread pool max threads
threadPool.threadsPriorityint{@link Thread#NORM_PRIORITY}Thread pool threads priority
threadPool.idleTimeoutint60000Thread pool idle timeout in milliseconds; threads that are idle for - * longer than this period may be stopped
threadPool.stopTimeoutlong5000Thread pool stop timeout in milliseconds; the maximum time allowed for - * the service to shutdown
connector.acceptorsint-1Connector acceptor thread count; when -1, Jetty will default to 1
connector.selectorsint-1Connector 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.acceptQueueSizeint0Connector accept queue size; also known as accept backlog
connector.idleTimeoutint30000Connector 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.stopTimeoutlong30000Connector stop timeout in milliseconds; the maximum time allowed for the - * service to shutdown
http.headerCacheSizeint512HTTP header cache size in bytes
http.requestHeaderSizeint8*1024HTTP 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.responseHeaderSizeint8*1024HTTP 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.outputBufferSizeint32*1024HTTP 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.periodint1000Low-resource monitor period in milliseconds; when 0, low-resource - * monitoring is disabled
lowResource.threadsbooleantrueLow-resource monitor, whether to check if we're low on threads
lowResource.maxMemoryint0Low-resource monitor max memory in bytes; when 0, the check disabled; - * memory used is calculated as (totalMemory-freeMemory)
lowResource.idleTimeoutint1000Low-resource monitor idle timeout in milliseconds; applied to EndPoints - * when in the low-resources state
server.maxConnectionsint0Server max connections; when 0, there is no limit
server.maxConnections.idleTimeoutlong0The endpoint idle timeout in milliseconds to apply when the connection - * limit is reached; when 0, there is no idle timeout.
shutdown.gracefullybooleantrueWhen 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.timeoutlong30000Server 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 nameValue typeDefault valueDescription
bindAddressStringnullThe address to bind socket channels to. You must set both this and bindPort
bindPortintnullThe port to bind socket channels to. You must set both this and bindAddress
connectTimeoutlong15000The max time in milliseconds a connection can take to connect to destinations, including + * the TLS handshake
eventLoop.workerThreadsint0Number of event loop threads doing network I/O. When 0, Netty uses twice the number of + * available processors.
followRedirectsbooleantrueWhether to follow HTTP redirects (301, 302, 303, 307 and 308) of synchronous calls
hostnameVerificationbooleantrueWhether to check that the server certificate matches the host name (HTTPS endpoint + * identification)
httpClientTransportModeStringHTTP1_1Indicate 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}).
idleTimeoutlong30000The 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.
maxConnectionsPerDestinationint64The max number of HTTP/1.1 connections to open to each destination
maxRedirectsint8The max number of HTTP redirects that are followed
maxResponseHeaderSizeint8192The max size in bytes of the response status line and headers.
proxyHostStringSystem property "http.proxyHost"The host name of the HTTP proxy.
proxyPortintSystem property "http.proxyPort" or "3128"The port of the HTTP proxy.
requestBufferSizeint8192The size in bytes of the buffer used to write request entities
userAgentFieldStringnullThe "User-Agent" HTTP header string; when null, uses the Restlet default
sslContextFactoryStringorg.restlet.engine.ssl.DefaultSslContextFactoryLet 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 nameValue typeDefault valueDescription
threadPool.virtualbooleantrueWhen 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.minThreadsint8Platform thread pool minimum threads. Ignored with virtual threads.
threadPool.maxThreadsint200Platform thread pool maximum threads. Ignored with virtual threads.
threadPool.threadsPriorityint{@link Thread#NORM_PRIORITY}Platform thread pool threads priority. Ignored with virtual threads.
threadPool.idleTimeoutint60000Platform thread pool idle timeout in milliseconds. Ignored with virtual threads.
server.maxConcurrentCallsint0Maximum number of calls processed concurrently; extra calls receive a 503 response. When + * 0, there is no limit.
eventLoop.bossThreadsint1Number of event loop threads accepting connections.
eventLoop.workerThreadsint0Number of event loop threads doing network I/O. When 0, Netty uses twice the number of + * available processors.
connector.acceptQueueSizeint0Accept queue size (backlog). When 0, the operating system default is used.
connector.idleTimeoutint30000Connection idle timeout in milliseconds; idle connections are closed. When 0, there is no + * timeout.
http.requestHeaderSizeint8192Maximum size in bytes of the request line and headers.
http.outputBufferSizeint32768Size in bytes of the response output buffer.
server.maxConnectionsint0Maximum number of open connections; extra connections are closed on accept. When 0, there + * is no limit.
shutdown.gracefullybooleantrueWhen 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.timeoutlong0Graceful 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 - *

- * Restlet is a registered trademark of QlikTech International AB. - */ -package org.restlet.engine.security; - -import java.io.IOException; -import java.net.InetAddress; -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 RestletSslContextFactoryClient extends SslContextFactory.Client { - - /** - * Constructor. - * - * @param restletSslContextFactory The Restlet SSL context factory to leverage. - * @throws Exception - */ - public RestletSslContextFactoryClient( - 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 { - SSLServerSocketFactory factory = getSslContext().getServerSocketFactory(); - return (SSLServerSocket) - ((host == null) - ? factory.createServerSocket(port, backlog) - : factory.createServerSocket(port, backlog, InetAddress.getByName(host))); - } - - @Override - public SSLSocket newSslSocket() throws IOException { - return (SSLSocket) getSslContext().getSocketFactory().createSocket(); - } -} diff --git a/org.restlet/src/main/java/org/restlet/engine/security/RestletSslContextFactoryServer.java b/org.restlet/src/main/java/org/restlet/engine/security/RestletSslContextFactoryServer.java deleted file mode 100644 index 1b835fbe32..0000000000 --- a/org.restlet/src/main/java/org/restlet/engine/security/RestletSslContextFactoryServer.java +++ /dev/null @@ -1,63 +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.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 + *

+ * Restlet is a registered trademark of QlikTech International AB. + */ +package org.restlet.engine.connector; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +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.ChallengeResponse; +import org.restlet.data.ChallengeScheme; +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.StringRepresentation; + +/** Tests the HTTP redirects followed by the client connector. */ +class RedirectTestCase { + + /** The requests received by the origin server, as "METHOD path [entity]". */ + private final List received = new CopyOnWriteArrayList<>(); + + /** The Authorization headers received by the second server. */ + private final List otherOriginAuthorizations = new CopyOnWriteArrayList<>(); + + private Server server; + + private Server otherOrigin; + + private Client client; + + private String base; + + @BeforeEach + void setUp() throws Exception { + Engine.clearThreadLocalVariables(); + Engine.register(false); + Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null)); + Engine.getInstance().getRegisteredClients().add(new HttpClientHelper(null)); + + otherOrigin = + new Server( + new Context(), + Protocol.HTTP, + 0, + new Restlet() { + @Override + public void handle(Request request, Response response) { + otherOriginAuthorizations.add( + String.valueOf( + request.getHeaders() + .getFirstValue("Authorization", true))); + response.setEntity("other origin", MediaType.TEXT_PLAIN); + } + }); + otherOrigin.start(); + + server = + new Server( + new Context(), + Protocol.HTTP, + 0, + new Restlet() { + @Override + public void handle(Request request, Response response) { + String path = request.getResourceRef().getPath(); + String entity = + request.isEntityAvailable() + ? request.getEntityAsText() + : ""; + received.add(request.getMethod() + " " + path + " " + entity); + redirect(path, response); + } + }); + server.start(); + base = "http://localhost:" + server.getActualPort(); + } + + private void redirect(String path, Response response) { + switch (path) { + case "/301" -> redirect(response, Status.REDIRECTION_PERMANENT, "/target"); + case "/302" -> redirect(response, Status.REDIRECTION_FOUND, "/target"); + case "/303" -> redirect(response, Status.REDIRECTION_SEE_OTHER, "/target"); + case "/307" -> redirect(response, Status.REDIRECTION_TEMPORARY, "/target"); + case "/308" -> redirect(response, new Status(308), "/target"); + case "/chain1" -> redirect(response, Status.REDIRECTION_FOUND, "/chain2"); + case "/chain2" -> redirect(response, Status.REDIRECTION_FOUND, "target"); + case "/loop" -> redirect(response, Status.REDIRECTION_FOUND, "/loop"); + case "/other" -> + redirect( + response, + Status.REDIRECTION_FOUND, + "http://localhost:" + otherOrigin.getActualPort() + "/x"); + default -> response.setEntity("target reached", MediaType.TEXT_PLAIN); + } + } + + private static void redirect(Response response, Status status, String location) { + response.setStatus(status); + response.setLocationRef(location); + } + + @AfterEach + void tearDown() throws Exception { + if (client != null) { + client.stop(); + } + server.stop(); + otherOrigin.stop(); + Engine.clearThreadLocalVariables(); + } + + private Response send(Request request, String... parameters) throws Exception { + Context context = new Context(); + for (int i = 0; i < parameters.length; i += 2) { + context.getParameters().add(parameters[i], parameters[i + 1]); + } + client = new Client(context, Protocol.HTTP); + client.start(); + return client.handle(request); + } + + @Test + void followsRedirectsWithGet() throws Exception { + for (String code : List.of("301", "302", "303", "307", "308")) { + received.clear(); + Response response = send(new Request(Method.GET, base + "/" + code)); + assertEquals(Status.SUCCESS_OK, response.getStatus(), code); + assertEquals("target reached", response.getEntityAsText(), code); + assertEquals(List.of("GET /" + code + " ", "GET /target "), received, code); + client.stop(); + } + } + + @Test + void postBecomesGetOn302And303() throws Exception { + for (String code : List.of("301", "302", "303")) { + received.clear(); + Response response = + send( + new Request( + Method.POST, base + "/" + code, new StringRepresentation("x"))); + assertEquals(Status.SUCCESS_OK, response.getStatus(), code); + assertEquals(List.of("POST /" + code + " x", "GET /target "), received, code); + client.stop(); + } + } + + @Test + void methodAndEntityKeptOn307And308() throws Exception { + for (String code : List.of("307", "308")) { + received.clear(); + Response response = + send( + new Request( + Method.PUT, + base + "/" + code, + new StringRepresentation("payload"))); + assertEquals(Status.SUCCESS_OK, response.getStatus(), code); + assertEquals( + List.of("PUT /" + code + " payload", "PUT /target payload"), received, code); + client.stop(); + } + } + + @Test + void followsRelativeChains() throws Exception { + Response response = send(new Request(Method.GET, base + "/chain1")); + assertEquals(Status.SUCCESS_OK, response.getStatus()); + assertEquals(List.of("GET /chain1 ", "GET /chain2 ", "GET /target "), received); + // The response is attached to the original request. + assertEquals(base + "/chain1", response.getRequest().getResourceRef().toString()); + } + + @Test + void stopsAfterMaxRedirects() throws Exception { + Response response = send(new Request(Method.GET, base + "/loop"), "maxRedirects", "3"); + assertEquals(Status.REDIRECTION_FOUND, response.getStatus()); + assertEquals(4, received.size()); + } + + @Test + void doesNotFollowWhenDisabled() throws Exception { + Response response = + send(new Request(Method.GET, base + "/302"), "followRedirects", "false"); + assertEquals(Status.REDIRECTION_FOUND, response.getStatus()); + assertEquals(base + "/target", response.getLocationRef().toString()); + assertEquals(1, received.size()); + } + + @Test + void dropsCredentialsForOtherOrigins() throws Exception { + Request request = new Request(Method.GET, base + "/other"); + request.setChallengeResponse( + new ChallengeResponse(ChallengeScheme.HTTP_BASIC, "user", "secret")); + Response response = send(request); + assertEquals(Status.SUCCESS_OK, response.getStatus()); + assertEquals("other origin", response.getEntityAsText()); + assertEquals(List.of("null"), otherOriginAuthorizations); + } +} diff --git a/org.restlet/src/test/java/org/restlet/engine/connector/ServerMaxConcurrentCallsTestCase.java b/org.restlet/src/test/java/org/restlet/engine/connector/ServerMaxConcurrentCallsTestCase.java new file mode 100644 index 0000000000..66ed64ab9a --- /dev/null +++ b/org.restlet/src/test/java/org/restlet/engine/connector/ServerMaxConcurrentCallsTestCase.java @@ -0,0 +1,126 @@ +/** + * 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.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.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.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.List; +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 TLS client authentication: the server requires or requests a client certificate, and the + * certificate presented by the client is exposed in the request's client info. + */ +class SslClientAuthenticationTestCase { + + private static final String PASSWORD = "testtest"; + + @TempDir Path tempDir; + + private Path keystore; + + private Server server; + + private Client client; + + @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(); + } + + /** Answers with the subject of the client certificate, or "none". */ + private static final Restlet WHO_AM_I = + new Restlet() { + @Override + public void handle(Request request, Response response) { + List certificates = request.getClientInfo().getCertificates(); + String subject = + (certificates == null || certificates.isEmpty()) + ? "none" + : ((X509Certificate) certificates.getFirst()) + .getSubjectX500Principal() + .getName(); + response.setEntity(subject, MediaType.TEXT_PLAIN); + } + }; + + private void startServer(String clientAuthParameter) throws Exception { + Context context = new Context(); + context.getParameters().add("keystorePath", keystore.toString()); + context.getParameters().add("keystorePassword", PASSWORD); + context.getParameters().add("keyStoreType", "PKCS12"); + context.getParameters().add("keyPassword", PASSWORD); + context.getParameters().add("truststorePath", keystore.toString()); + context.getParameters().add("truststorePassword", PASSWORD); + context.getParameters().add("trustStoreType", "PKCS12"); + context.getParameters().add(clientAuthParameter, "true"); + server = new Server(context, Protocol.HTTPS, 0, WHO_AM_I); + server.start(); + } + + private Response call(boolean withCertificate) throws Exception { + Context context = new Context(); + context.getParameters().add("truststorePath", keystore.toString()); + context.getParameters().add("truststorePassword", PASSWORD); + context.getParameters().add("trustStoreType", "PKCS12"); + if (withCertificate) { + context.getParameters().add("keystorePath", keystore.toString()); + context.getParameters().add("keystorePassword", PASSWORD); + context.getParameters().add("keyStoreType", "PKCS12"); + context.getParameters().add("keyPassword", PASSWORD); + } + context.getParameters().add("connectTimeout", "5000"); + context.getParameters().add("idleTimeout", "5000"); + client = new Client(context, Protocol.HTTPS); + client.start(); + return client.handle( + new Request(Method.GET, "https://localhost:" + server.getActualPort())); + } + + @Test + void requiredCertificateIsExposedToTheApplication() throws Exception { + startServer("needClientAuthentication"); + Response response = call(true); + assertEquals(Status.SUCCESS_OK, response.getStatus()); + assertTrue( + response.getEntityAsText().contains("CN=localhost"), + "Unexpected subject: " + response.getEntityAsText()); + } + + @Test + void requiredCertificateMissingFailsTheHandshake() throws Exception { + startServer("needClientAuthentication"); + Response response = call(false); + assertTrue(response.getStatus().isConnectorError(), response.getStatus().toString()); + } + + @Test + void optionalCertificateIsExposedWhenPresented() throws Exception { + startServer("wantClientAuthentication"); + Response response = call(true); + assertEquals(Status.SUCCESS_OK, response.getStatus()); + assertTrue(response.getEntityAsText().contains("CN=localhost")); + } + + @Test + void optionalCertificateMayBeOmitted() throws Exception { + startServer("wantClientAuthentication"); + Response response = call(false); + assertEquals(Status.SUCCESS_OK, response.getStatus()); + assertEquals("none", response.getEntityAsText()); + } +} diff --git a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AbstractAnnotatedServerResource03.java b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AbstractAnnotatedServerResource03.java similarity index 93% rename from org.restlet/src/test/java/org/restlet/engine/jetty/resource/AbstractAnnotatedServerResource03.java rename to org.restlet/src/test/java/org/restlet/engine/connector/resource/AbstractAnnotatedServerResource03.java index 6a00aa6ead..c31c56b5ec 100644 --- a/org.restlet/src/test/java/org/restlet/engine/jetty/resource/AbstractAnnotatedServerResource03.java +++ b/org.restlet/src/test/java/org/restlet/engine/connector/resource/AbstractAnnotatedServerResource03.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.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 @@ *

* 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/converter/ConverterUtilsTestCase.java b/org.restlet/src/test/java/org/restlet/engine/converter/ConverterUtilsTestCase.java index 36540e6040..9b5f75caeb 100644 --- a/org.restlet/src/test/java/org/restlet/engine/converter/ConverterUtilsTestCase.java +++ b/org.restlet/src/test/java/org/restlet/engine/converter/ConverterUtilsTestCase.java @@ -14,9 +14,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.restlet.data.MediaType; import org.restlet.data.Status; +import org.restlet.engine.Engine; import org.restlet.engine.application.StatusInfo; import org.restlet.engine.resource.VariantInfo; import org.restlet.representation.StringRepresentation; @@ -24,6 +26,11 @@ class ConverterUtilsTestCase { + @BeforeEach + void setUp() { + Engine.register(); + } + @Test void getBestHelper_objectSource_findsMatchingConverter() { StatusInfo source = new StatusInfo(Status.SUCCESS_OK); diff --git a/pom.xml b/pom.xml index 0536113ee4..69f042a2db 100644 --- a/pom.xml +++ b/pom.xml @@ -42,9 +42,8 @@ false - 21 - ${java.version} - ${java.version} + 25 + ${java.version} UTF-8 UTF-8 0 @@ -58,7 +57,7 @@ 3.2.3 2.22 2.14.2 - 12.0.39 + 4.2.19.Final 20260522 3.0 2.0.18 @@ -253,6 +252,13 @@ + + io.netty + netty-bom + ${lib-netty-version} + pom + import + org.slf4j slf4j-api @@ -463,13 +469,12 @@ 3.16.0 UTF-8 - ${java.version} - ${java.version} + ${java.version} true false - -Xlint:all,-this-escape,-missing-explicit-ctor + -Xlint:all,-this-escape,-missing-explicit-ctor,-dangling-doc-comments -Werror