From 54af59ee8f6c4e2fe30742cdd9910ffa9cd3239d Mon Sep 17 00:00:00 2001
From: bohdanslobodian
Date: Thu, 25 Jun 2026 08:06:34 +0300
Subject: [PATCH 1/6] gh-298 Allow configuring the HTTP protocol version on the
JDK client http request factory
---
.../rest/SpringAddonsRestProperties.java | 7 +++
.../SpringAddonsClientHttpRequestFactory.java | 1 +
...ientHttpRequestFactoryHttpVersionTest.java | 50 +++++++++++++++++++
3 files changed, 58 insertions(+)
create mode 100644 spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryHttpVersionTest.java
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
index a61a3a9d3..bcf313461 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
@@ -264,6 +264,13 @@ public static class ClientHttpRequestFactoryProperties {
*/
private boolean sslCertificatesValidationEnabled = true;
+ /**
+ * HTTP protocol version to use. Currently honored only by the JDK
+ * {@link ClientHttpRequestFactoryImpl#JDK} implementation (ignored by HTTP_COMPONENTS and
+ * JETTY). When empty, the underlying client default is used.
+ */
+ private Optional httpProtocolVersion = Optional.empty();
+
@Data
public static class ProxyProperties {
private boolean enabled = true;
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java
index 29ccaf581..6a73892ba 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java
@@ -83,6 +83,7 @@ private static HttpClient.Builder httpClientBuilder(
final var httpClient = HttpClient.newBuilder();
properties.getConnectTimeoutMillis().map(Duration::ofMillis)
.ifPresent(httpClient::connectTimeout);
+ properties.getHttpProtocolVersion().ifPresent(httpClient::version);
return httpClient;
}
diff --git a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryHttpVersionTest.java b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryHttpVersionTest.java
new file mode 100644
index 000000000..825e7aca2
--- /dev/null
+++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryHttpVersionTest.java
@@ -0,0 +1,50 @@
+package com.c4_soft.springaddons.rest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.client.ClientHttpRequest;
+import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties;
+import com.c4_soft.springaddons.rest.synchronised.SpringAddonsClientHttpRequestFactory;
+
+/**
+ * Verifies that the optional {@code http.http-protocol-version} property is applied to the JDK
+ * {@link HttpClient} built by {@link SpringAddonsClientHttpRequestFactory}.
+ */
+class SpringAddonsClientHttpRequestFactoryHttpVersionTest {
+
+ @Test
+ void givenHttpProtocolVersionIsSet_whenCreatingRequest_thenJdkHttpClientUsesIt()
+ throws Exception {
+ final var http = new ClientHttpRequestFactoryProperties();
+ http.setHttpProtocolVersion(Optional.of(HttpClient.Version.HTTP_1_1));
+
+ final var httpClient = jdkHttpClientFor(http);
+
+ assertEquals(HttpClient.Version.HTTP_1_1, httpClient.version());
+ }
+
+ @Test
+ void givenHttpProtocolVersionIsNotSet_whenCreatingRequest_thenJdkHttpClientUsesItsDefault()
+ throws Exception {
+ final var http = new ClientHttpRequestFactoryProperties();
+
+ final var httpClient = jdkHttpClientFor(http);
+
+ // The JDK HttpClient defaults to HTTP/2 when no version is configured.
+ assertEquals(HttpClient.Version.HTTP_2, httpClient.version());
+ }
+
+ private static HttpClient jdkHttpClientFor(ClientHttpRequestFactoryProperties http)
+ throws Exception {
+ final var factory = new SpringAddonsClientHttpRequestFactory(new SystemProxyProperties(), http);
+ final ClientHttpRequest request =
+ factory.createRequest(URI.create("https://localhost/test"), HttpMethod.GET);
+ final var httpClientField = request.getClass().getDeclaredField("httpClient");
+ httpClientField.setAccessible(true);
+ return (HttpClient) httpClientField.get(request);
+ }
+}
From 51556df4a219f779cc0ae73dbb3890975d7ced78 Mon Sep 17 00:00:00 2001
From: bohdanslobodian
Date: Thu, 25 Jun 2026 11:30:19 +0300
Subject: [PATCH 2/6] gh-298 Add a use-virtual-threads property for the JDK
client and document the JDK http properties
---
spring-addons-starter-rest/README.md | 14 ++++++
.../rest/SpringAddonsRestProperties.java | 6 +++
.../SpringAddonsClientHttpRequestFactory.java | 6 +++
...tHttpRequestFactoryVirtualThreadsTest.java | 47 +++++++++++++++++++
4 files changed, 73 insertions(+)
create mode 100644 spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryVirtualThreadsTest.java
diff --git a/spring-addons-starter-rest/README.md b/spring-addons-starter-rest/README.md
index c304b1ad5..9a50a0ef3 100644
--- a/spring-addons-starter-rest/README.md
+++ b/spring-addons-starter-rest/README.md
@@ -233,6 +233,20 @@ com:
client-http-request-factory-impl: jetty
```
+The JDK implementation accepts two extra properties: `http-protocol-version` (forces the HTTP protocol version) and `use-virtual-threads` (uses a virtual-thread-per-task executor):
+```yaml
+com:
+ c4-soft:
+ springaddons:
+ rest:
+ client:
+ machin-client:
+ http:
+ # both honored only by the "jdk" client-http-request-factory-impl
+ http-protocol-version: HTTP_1_1
+ use-virtual-threads: true
+```
+
### 2.6. Working with SSL bundles
`spring-addons-starter-rest` integrates with Spring Boot SSL bundles (introduced in Boot `3.1`). Lets consider the following Boot configurations for SSL with self signed certificates generated with `openssl req -x509 -newkey rsa:4096 -keyout tls.key -out tls.crt -sha256 -days 3650 -passout pass:change-me -subj "/C=PY/ST=Tahiti/L=Papeete/CN=localhost/emailAddress=ch4mp@c4-soft.com"`:
- on the consumed REST API:
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
index bcf313461..a6c039792 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
@@ -271,6 +271,12 @@ public static class ClientHttpRequestFactoryProperties {
*/
private Optional httpProtocolVersion = Optional.empty();
+ /**
+ * Use a virtual-thread-per-task executor for the underlying client. Honored only by the JDK
+ * {@link ClientHttpRequestFactoryImpl#JDK} implementation.
+ */
+ private boolean useVirtualThreads = false;
+
@Data
public static class ProxyProperties {
private boolean enabled = true;
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java
index 6a73892ba..93798afcc 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java
@@ -18,6 +18,7 @@
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.client.ClientHttpRequest;
@@ -84,6 +85,11 @@ private static HttpClient.Builder httpClientBuilder(
properties.getConnectTimeoutMillis().map(Duration::ofMillis)
.ifPresent(httpClient::connectTimeout);
properties.getHttpProtocolVersion().ifPresent(httpClient::version);
+ if (properties.isUseVirtualThreads()) {
+ final var executor = new SimpleAsyncTaskExecutor();
+ executor.setVirtualThreads(true);
+ httpClient.executor(executor);
+ }
return httpClient;
}
diff --git a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryVirtualThreadsTest.java b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryVirtualThreadsTest.java
new file mode 100644
index 000000000..1508b414e
--- /dev/null
+++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryVirtualThreadsTest.java
@@ -0,0 +1,47 @@
+package com.c4_soft.springaddons.rest;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.net.URI;
+import java.net.http.HttpClient;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledForJreRange;
+import org.junit.jupiter.api.condition.JRE;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.client.ClientHttpRequest;
+import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties;
+import com.c4_soft.springaddons.rest.synchronised.SpringAddonsClientHttpRequestFactory;
+
+/**
+ * Verifies that the {@code http.use-virtual-threads} property sets an executor on the JDK
+ * {@link HttpClient} built by {@link SpringAddonsClientHttpRequestFactory}.
+ */
+class SpringAddonsClientHttpRequestFactoryVirtualThreadsTest {
+
+ @Test
+ @EnabledForJreRange(min = JRE.JAVA_21) // virtual threads require Java 21+
+ void givenUseVirtualThreads_whenCreatingRequest_thenTheJdkHttpClientHasAnExecutor()
+ throws Exception {
+ final var http = new ClientHttpRequestFactoryProperties();
+ http.setUseVirtualThreads(true);
+
+ assertTrue(jdkHttpClientFor(http).executor().isPresent());
+ }
+
+ @Test
+ void givenUseVirtualThreadsDisabled_whenCreatingRequest_thenTheJdkHttpClientHasNoExplicitExecutor()
+ throws Exception {
+ final var http = new ClientHttpRequestFactoryProperties();
+
+ assertTrue(jdkHttpClientFor(http).executor().isEmpty());
+ }
+
+ private static HttpClient jdkHttpClientFor(ClientHttpRequestFactoryProperties http)
+ throws Exception {
+ final var factory = new SpringAddonsClientHttpRequestFactory(new SystemProxyProperties(), http);
+ final ClientHttpRequest request =
+ factory.createRequest(URI.create("https://localhost/test"), HttpMethod.GET);
+ final var httpClientField = request.getClass().getDeclaredField("httpClient");
+ httpClientField.setAccessible(true);
+ return (HttpClient) httpClientField.get(request);
+ }
+}
From 520ed2fe18a3dd9dcca876c19f8e0cf7337bcdb4 Mon Sep 17 00:00:00 2001
From: bohdanslobodian
Date: Thu, 25 Jun 2026 12:32:21 +0300
Subject: [PATCH 3/6] gh-298 Add http-protocol-version and use-virtual-threads
support for the Jetty client
---
spring-addons-starter-rest/README.md | 7 +++-
.../JettyClientHttpRequestFactoryHelper.java | 22 ++++++++++-
...entHttpRequestFactoryJettyVersionTest.java | 38 +++++++++++++++++++
3 files changed, 64 insertions(+), 3 deletions(-)
create mode 100644 spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryJettyVersionTest.java
diff --git a/spring-addons-starter-rest/README.md b/spring-addons-starter-rest/README.md
index 9a50a0ef3..8d1d5114c 100644
--- a/spring-addons-starter-rest/README.md
+++ b/spring-addons-starter-rest/README.md
@@ -233,7 +233,7 @@ com:
client-http-request-factory-impl: jetty
```
-The JDK implementation accepts two extra properties: `http-protocol-version` (forces the HTTP protocol version) and `use-virtual-threads` (uses a virtual-thread-per-task executor):
+Two extra properties tune the underlying client: `http-protocol-version` (forces the HTTP protocol version) and `use-virtual-threads` (runs the client on virtual threads, requires a Java 21+ runtime):
```yaml
com:
c4-soft:
@@ -242,10 +242,13 @@ com:
client:
machin-client:
http:
- # both honored only by the "jdk" client-http-request-factory-impl
http-protocol-version: HTTP_1_1
use-virtual-threads: true
```
+Support depends on the `client-http-request-factory-impl`:
+- `jdk`: both properties (`http-protocol-version` via the client version, `use-virtual-threads` via the client executor).
+- `jetty`: both properties (`http-protocol-version` via the client transport — `HTTP_2` requires `org.eclipse.jetty.http2:jetty-http2-client` on the class-path; `use-virtual-threads` via the client executor).
+- `http-components`: neither — the classic Apache client is HTTP/1.1 only and runs on the calling thread (so already on a virtual thread when the caller is). Both properties are ignored.
### 2.6. Working with SSL bundles
`spring-addons-starter-rest` integrates with Spring Boot SSL bundles (introduced in Boot `3.1`). Lets consider the following Boot configurations for SSL with self signed certificates generated with `openssl req -x509 -newkey rsa:4096 -keyout tls.key -out tls.crt -sha256 -days 3650 -passout pass:change-me -subj "/C=PY/ST=Tahiti/L=Papeete/CN=localhost/emailAddress=ch4mp@c4-soft.com"`:
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java
index 46ddef22a..6ee4ef0dc 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java
@@ -3,16 +3,27 @@
import java.time.Duration;
import org.eclipse.jetty.client.HttpProxy;
import org.eclipse.jetty.client.Origin;
+import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP;
import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.http.client.JettyClientHttpRequestFactory;
import org.springframework.util.StringUtils;
import com.c4_soft.springaddons.rest.ProxySupport;
+import com.c4_soft.springaddons.rest.RestMisconfigurationException;
import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties;
class JettyClientHttpRequestFactoryHelper {
public static JettyClientHttpRequestFactory get(ProxySupport proxySupport,
ClientHttpRequestFactoryProperties properties) {
- final var httpClient = new org.eclipse.jetty.client.HttpClient();
+ final var httpClient = properties.getHttpProtocolVersion()
+ .map(JettyClientHttpRequestFactoryHelper::httpClientForVersion)
+ .orElseGet(org.eclipse.jetty.client.HttpClient::new);
+
+ if (properties.isUseVirtualThreads()) {
+ final var executor = new SimpleAsyncTaskExecutor();
+ executor.setVirtualThreads(true);
+ httpClient.setExecutor(executor);
+ }
if (proxySupport != null && proxySupport.isEnabled()) {
final var httpProxy = new HttpProxy(
@@ -31,4 +42,13 @@ public static JettyClientHttpRequestFactory get(ProxySupport proxySupport,
return clientHttpRequestFactory;
}
+
+ private static org.eclipse.jetty.client.HttpClient httpClientForVersion(
+ java.net.http.HttpClient.Version version) {
+ return switch (version) {
+ case HTTP_1_1 -> new org.eclipse.jetty.client.HttpClient(new HttpClientTransportOverHTTP());
+ case HTTP_2 -> throw new RestMisconfigurationException(
+ "http-protocol-version HTTP_2 is not supported for the Jetty implementation without org.eclipse.jetty.http2:jetty-http2-client on the class-path");
+ };
+ }
}
diff --git a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryJettyVersionTest.java b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryJettyVersionTest.java
new file mode 100644
index 000000000..e27628788
--- /dev/null
+++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryJettyVersionTest.java
@@ -0,0 +1,38 @@
+package com.c4_soft.springaddons.rest;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import java.net.http.HttpClient;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import com.c4_soft.springaddons.rest.RestMisconfigurationException;
+import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties;
+import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties.ClientHttpRequestFactoryImpl;
+import com.c4_soft.springaddons.rest.synchronised.SpringAddonsClientHttpRequestFactory;
+
+/**
+ * Verifies the {@code http.http-protocol-version} mapping for the Jetty implementation: HTTP/1.1 is
+ * configured through the over-HTTP transport, HTTP/2 fails fast (it requires jetty-http2-client).
+ */
+class SpringAddonsClientHttpRequestFactoryJettyVersionTest {
+
+ @Test
+ void givenJettyAndHttp1_1_whenBuildingFactory_thenSucceeds() {
+ final var http = new ClientHttpRequestFactoryProperties();
+ http.setClientHttpRequestFactoryImpl(ClientHttpRequestFactoryImpl.JETTY);
+ http.setHttpProtocolVersion(Optional.of(HttpClient.Version.HTTP_1_1));
+
+ assertDoesNotThrow(
+ () -> new SpringAddonsClientHttpRequestFactory(new SystemProxyProperties(), http));
+ }
+
+ @Test
+ void givenJettyAndHttp2_whenBuildingFactory_thenThrows() {
+ final var http = new ClientHttpRequestFactoryProperties();
+ http.setClientHttpRequestFactoryImpl(ClientHttpRequestFactoryImpl.JETTY);
+ http.setHttpProtocolVersion(Optional.of(HttpClient.Version.HTTP_2));
+
+ assertThrows(RestMisconfigurationException.class,
+ () -> new SpringAddonsClientHttpRequestFactory(new SystemProxyProperties(), http));
+ }
+}
From 113722c5d2a75727a65c42b3edcbc6dec494b501 Mon Sep 17 00:00:00 2001
From: bohdanslobodian
Date: Thu, 25 Jun 2026 13:30:43 +0300
Subject: [PATCH 4/6] gh-298 Resolve the task executor from the application
context instead of creating one
---
spring-addons-starter-rest/README.md | 2 +-
.../rest/SpringAddonsRestProperties.java | 13 +++---
.../JettyClientHttpRequestFactoryHelper.java | 9 ++--
.../RestClientBuilderFactoryBean.java | 28 ++++++++++++-
.../SpringAddonsClientHttpRequestFactory.java | 41 +++++++++++--------
...tHttpRequestFactoryVirtualThreadsTest.java | 28 +++++++------
6 files changed, 79 insertions(+), 42 deletions(-)
diff --git a/spring-addons-starter-rest/README.md b/spring-addons-starter-rest/README.md
index 8d1d5114c..994c10cfe 100644
--- a/spring-addons-starter-rest/README.md
+++ b/spring-addons-starter-rest/README.md
@@ -233,7 +233,7 @@ com:
client-http-request-factory-impl: jetty
```
-Two extra properties tune the underlying client: `http-protocol-version` (forces the HTTP protocol version) and `use-virtual-threads` (runs the client on virtual threads, requires a Java 21+ runtime):
+Two extra properties tune the underlying client: `http-protocol-version` (forces the HTTP protocol version) and `use-virtual-threads` (sets the application task executor — the `applicationTaskExecutor` bean — on the client; combine with `spring.threads.virtual.enabled=true` for virtual threads):
```yaml
com:
c4-soft:
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
index a6c039792..e6cb11a20 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
@@ -265,15 +265,18 @@ public static class ClientHttpRequestFactoryProperties {
private boolean sslCertificatesValidationEnabled = true;
/**
- * HTTP protocol version to use. Currently honored only by the JDK
- * {@link ClientHttpRequestFactoryImpl#JDK} implementation (ignored by HTTP_COMPONENTS and
- * JETTY). When empty, the underlying client default is used.
+ * HTTP protocol version to use. Honored by the JDK and JETTY implementations (ignored by
+ * HTTP_COMPONENTS, whose classic client is HTTP/1.1 only). For JETTY, HTTP_2 requires
+ * org.eclipse.jetty.http2:jetty-http2-client on the class-path. When empty, the underlying
+ * client default is used.
*/
private Optional httpProtocolVersion = Optional.empty();
/**
- * Use a virtual-thread-per-task executor for the underlying client. Honored only by the JDK
- * {@link ClientHttpRequestFactoryImpl#JDK} implementation.
+ * If true, the application task executor (the {@code applicationTaskExecutor} bean, which runs
+ * on virtual threads when {@code spring.threads.virtual.enabled} is true) is set on the
+ * underlying client. Honored by the JDK and JETTY implementations (the classic Apache client
+ * runs on the calling thread, so HTTP_COMPONENTS ignores it).
*/
private boolean useVirtualThreads = false;
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java
index 6ee4ef0dc..ee57bf22d 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java
@@ -1,27 +1,26 @@
package com.c4_soft.springaddons.rest.synchronised;
import java.time.Duration;
+import java.util.concurrent.Executor;
import org.eclipse.jetty.client.HttpProxy;
import org.eclipse.jetty.client.Origin;
import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP;
import org.eclipse.jetty.util.ssl.SslContextFactory;
-import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.http.client.JettyClientHttpRequestFactory;
import org.springframework.util.StringUtils;
+import org.jspecify.annotations.Nullable;
import com.c4_soft.springaddons.rest.ProxySupport;
import com.c4_soft.springaddons.rest.RestMisconfigurationException;
import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties;
class JettyClientHttpRequestFactoryHelper {
public static JettyClientHttpRequestFactory get(ProxySupport proxySupport,
- ClientHttpRequestFactoryProperties properties) {
+ ClientHttpRequestFactoryProperties properties, @Nullable Executor executor) {
final var httpClient = properties.getHttpProtocolVersion()
.map(JettyClientHttpRequestFactoryHelper::httpClientForVersion)
.orElseGet(org.eclipse.jetty.client.HttpClient::new);
- if (properties.isUseVirtualThreads()) {
- final var executor = new SimpleAsyncTaskExecutor();
- executor.setVirtualThreads(true);
+ if (executor != null) {
httpClient.setExecutor(executor);
}
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/RestClientBuilderFactoryBean.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/RestClientBuilderFactoryBean.java
index 8c292733e..400310648 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/RestClientBuilderFactoryBean.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/RestClientBuilderFactoryBean.java
@@ -2,9 +2,13 @@
import java.net.URL;
import java.util.Optional;
+import java.util.concurrent.Executor;
import org.jspecify.annotations.Nullable;
+import org.springframework.beans.BeansException;
import org.springframework.beans.factory.FactoryBean;
import org.springframework.boot.restclient.autoconfigure.RestClientSsl;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationContextAware;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestFactory;
@@ -27,7 +31,8 @@
@Data
@FieldNameConstants
-public class RestClientBuilderFactoryBean implements FactoryBean {
+public class RestClientBuilderFactoryBean
+ implements FactoryBean, ApplicationContextAware {
private String clientId;
private SystemProxyProperties systemProxyProperties = new SystemProxyProperties();
private SpringAddonsRestProperties restProperties = new SpringAddonsRestProperties();
@@ -37,6 +42,25 @@ public class RestClientBuilderFactoryBean implements FactoryBean clientHttpRequestFactory;
private RestClient.Builder restClientBuilder = RestClient.builder();
private Optional ssl;
+ private @Nullable ApplicationContext applicationContext;
+
+ @Override
+ public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
+ this.applicationContext = applicationContext;
+ }
+
+ private @Nullable Executor virtualThreadsExecutor(
+ SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties http) {
+ if (!http.isUseVirtualThreads()) {
+ return null;
+ }
+ if (applicationContext == null) {
+ throw new RestMisconfigurationException(
+ "use-virtual-threads requires an ApplicationContext to resolve the 'applicationTaskExecutor' bean for REST client '%s'"
+ .formatted(clientId));
+ }
+ return applicationContext.getBean("applicationTaskExecutor", Executor.class);
+ }
@Override
@@ -49,7 +73,7 @@ public RestClient.Builder getObject() throws Exception {
// Handle HTTP or SOCK proxy and set timeouts
builder.requestFactory(clientHttpRequestFactory
.orElseGet(() -> new SpringAddonsClientHttpRequestFactory(systemProxyProperties,
- clientProps.getHttp())));
+ clientProps.getHttp(), virtualThreadsExecutor(clientProps.getHttp()))));
clientProps.getBaseUrl().map(URL::toString).ifPresent(builder::baseUrl);
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java
index 93798afcc..d3bc31421 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java
@@ -14,11 +14,11 @@
import java.time.Duration;
import java.util.Base64;
import java.util.Optional;
+import java.util.concurrent.Executor;
import java.util.regex.Pattern;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
-import org.springframework.core.task.SimpleAsyncTaskExecutor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.client.ClientHttpRequest;
@@ -41,7 +41,7 @@
* When going through a proxy, the Proxy-Authorization header is set if username and password are
* non-empty.
*
- *
+ *
* @author Jérôme Wacongne <ch4mp@c4-soft.com>
*/
public class SpringAddonsClientHttpRequestFactory implements ClientHttpRequestFactory {
@@ -51,16 +51,27 @@ public class SpringAddonsClientHttpRequestFactory implements ClientHttpRequestFa
public SpringAddonsClientHttpRequestFactory(SystemProxyProperties systemProperties,
ClientHttpRequestFactoryProperties addonsProperties) {
+ this(systemProperties, addonsProperties, null);
+ }
+
+ /**
+ * @param executor the {@link Executor} to set on the underlying client when use-virtual-threads is
+ * enabled (typically the application task executor resolved from the context). Honored by
+ * the JDK and Jetty implementations.
+ */
+ public SpringAddonsClientHttpRequestFactory(SystemProxyProperties systemProperties,
+ ClientHttpRequestFactoryProperties addonsProperties, @Nullable Executor executor) {
final var proxySupport = new ProxySupport(systemProperties, addonsProperties.getProxy());
this.nonProxyHostsPattern = proxySupport.isEnabled()
? Optional.ofNullable(proxySupport.getNoProxy()).map(Pattern::compile)
: Optional.empty();
- this.noProxyDelegate = clientHttpRequestFactory(null, addonsProperties);
+ this.noProxyDelegate = clientHttpRequestFactory(null, addonsProperties, executor);
if (proxySupport.isEnabled()) {
- this.proxyDelegate = new ProxyAwareClientHttpRequestFactory(proxySupport, addonsProperties);
+ this.proxyDelegate =
+ new ProxyAwareClientHttpRequestFactory(proxySupport, addonsProperties, executor);
} else {
this.proxyDelegate = this.noProxyDelegate;
}
@@ -79,22 +90,20 @@ public SpringAddonsClientHttpRequestFactory(SystemProxyProperties systemProperti
return delegate.createRequest(uri, httpMethod);
}
- private static HttpClient.Builder httpClientBuilder(
- ClientHttpRequestFactoryProperties properties) {
+ private static HttpClient.Builder httpClientBuilder(ClientHttpRequestFactoryProperties properties,
+ @Nullable Executor executor) {
final var httpClient = HttpClient.newBuilder();
properties.getConnectTimeoutMillis().map(Duration::ofMillis)
.ifPresent(httpClient::connectTimeout);
properties.getHttpProtocolVersion().ifPresent(httpClient::version);
- if (properties.isUseVirtualThreads()) {
- final var executor = new SimpleAsyncTaskExecutor();
- executor.setVirtualThreads(true);
+ if (executor != null) {
httpClient.executor(executor);
}
return httpClient;
}
private static ClientHttpRequestFactory clientHttpRequestFactory(ProxySupport proxySupport,
- ClientHttpRequestFactoryProperties properties) {
+ ClientHttpRequestFactoryProperties properties, @Nullable Executor executor) {
switch (properties.getClientHttpRequestFactoryImpl()) {
case HTTP_COMPONENTS:
try {
@@ -103,10 +112,10 @@ private static ClientHttpRequestFactory clientHttpRequestFactory(ProxySupport pr
throw new RestMisconfigurationException(e);
}
case JETTY:
- return JettyClientHttpRequestFactoryHelper.get(proxySupport, properties);
+ return JettyClientHttpRequestFactoryHelper.get(proxySupport, properties, executor);
default:
try {
- return jdkClientHttpRequestFactory(proxySupport, properties);
+ return jdkClientHttpRequestFactory(proxySupport, properties, executor);
} catch (KeyManagementException | NoSuchAlgorithmException e) {
throw new RestMisconfigurationException(e);
}
@@ -114,9 +123,9 @@ private static ClientHttpRequestFactory clientHttpRequestFactory(ProxySupport pr
}
private static JdkClientHttpRequestFactory jdkClientHttpRequestFactory(ProxySupport proxySupport,
- ClientHttpRequestFactoryProperties properties)
+ ClientHttpRequestFactoryProperties properties, @Nullable Executor executor)
throws NoSuchAlgorithmException, KeyManagementException {
- final var httpClientBuilder = httpClientBuilder(properties);
+ final var httpClientBuilder = httpClientBuilder(properties, executor);
if (proxySupport != null && proxySupport.isEnabled()) {
final var proxyAddress =
new InetSocketAddress(proxySupport.getHostname().get(), proxySupport.getPort());
@@ -156,7 +165,7 @@ public static class ProxyAwareClientHttpRequestFactory implements ClientHttpRequ
private final @Nullable String password;
public ProxyAwareClientHttpRequestFactory(ProxySupport proxySupport,
- ClientHttpRequestFactoryProperties properties) {
+ ClientHttpRequestFactoryProperties properties, @Nullable Executor executor) {
this.username = proxySupport.getUsername();
this.password = proxySupport.getPassword();
final var httpClient = HttpClient.newBuilder();
@@ -165,7 +174,7 @@ public ProxyAwareClientHttpRequestFactory(ProxySupport proxySupport,
httpClient.proxy(ProxySelector.of(proxyAddress));
properties.getConnectTimeoutMillis().map(Duration::ofMillis)
.ifPresent(httpClient::connectTimeout);
- this.delegate = clientHttpRequestFactory(proxySupport, properties);
+ this.delegate = clientHttpRequestFactory(proxySupport, properties, executor);
}
@Override
diff --git a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryVirtualThreadsTest.java b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryVirtualThreadsTest.java
index 1508b414e..361644088 100644
--- a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryVirtualThreadsTest.java
+++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryVirtualThreadsTest.java
@@ -3,41 +3,43 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.URI;
import java.net.http.HttpClient;
+import java.util.concurrent.Executor;
import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.condition.EnabledForJreRange;
-import org.junit.jupiter.api.condition.JRE;
import org.springframework.http.HttpMethod;
import org.springframework.http.client.ClientHttpRequest;
import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties;
import com.c4_soft.springaddons.rest.synchronised.SpringAddonsClientHttpRequestFactory;
/**
- * Verifies that the {@code http.use-virtual-threads} property sets an executor on the JDK
- * {@link HttpClient} built by {@link SpringAddonsClientHttpRequestFactory}.
+ * Verifies that the {@link Executor} passed to {@link SpringAddonsClientHttpRequestFactory} (resolved
+ * from the context when use-virtual-threads is enabled) is set on the JDK {@link HttpClient}.
*/
class SpringAddonsClientHttpRequestFactoryVirtualThreadsTest {
@Test
- @EnabledForJreRange(min = JRE.JAVA_21) // virtual threads require Java 21+
- void givenUseVirtualThreads_whenCreatingRequest_thenTheJdkHttpClientHasAnExecutor()
- throws Exception {
+ void givenAnExecutor_whenCreatingRequest_thenTheJdkHttpClientUsesIt() throws Exception {
final var http = new ClientHttpRequestFactoryProperties();
- http.setUseVirtualThreads(true);
+ final Executor executor = Runnable::run;
+
+ final var factory =
+ new SpringAddonsClientHttpRequestFactory(new SystemProxyProperties(), http, executor);
- assertTrue(jdkHttpClientFor(http).executor().isPresent());
+ assertTrue(jdkHttpClientOf(factory).executor().isPresent());
}
@Test
- void givenUseVirtualThreadsDisabled_whenCreatingRequest_thenTheJdkHttpClientHasNoExplicitExecutor()
+ void givenNoExecutor_whenCreatingRequest_thenTheJdkHttpClientHasNoExplicitExecutor()
throws Exception {
final var http = new ClientHttpRequestFactoryProperties();
- assertTrue(jdkHttpClientFor(http).executor().isEmpty());
+ final var factory =
+ new SpringAddonsClientHttpRequestFactory(new SystemProxyProperties(), http);
+
+ assertTrue(jdkHttpClientOf(factory).executor().isEmpty());
}
- private static HttpClient jdkHttpClientFor(ClientHttpRequestFactoryProperties http)
+ private static HttpClient jdkHttpClientOf(SpringAddonsClientHttpRequestFactory factory)
throws Exception {
- final var factory = new SpringAddonsClientHttpRequestFactory(new SystemProxyProperties(), http);
final ClientHttpRequest request =
factory.createRequest(URI.create("https://localhost/test"), HttpMethod.GET);
final var httpClientField = request.getClass().getDeclaredField("httpClient");
From a4877046112dbd982324815daf0635b2033cf184 Mon Sep 17 00:00:00 2001
From: bohdanslobodian
Date: Thu, 25 Jun 2026 14:21:17 +0300
Subject: [PATCH 5/6] gh-298 Support Jetty HTTP/2 and default
use-virtual-threads to spring.threads.virtual.enabled
---
spring-addons-starter-rest/README.md | 4 ++--
spring-addons-starter-rest/pom.xml | 12 ++++++++++++
.../rest/SpringAddonsRestProperties.java | 5 +++--
.../JettyClientHttpRequestFactoryHelper.java | 13 +++++++++++--
.../RestClientBuilderFactoryBean.java | 18 ++++++++++++++----
...ientHttpRequestFactoryJettyVersionTest.java | 11 +++++------
6 files changed, 47 insertions(+), 16 deletions(-)
diff --git a/spring-addons-starter-rest/README.md b/spring-addons-starter-rest/README.md
index 994c10cfe..cc5563f73 100644
--- a/spring-addons-starter-rest/README.md
+++ b/spring-addons-starter-rest/README.md
@@ -233,7 +233,7 @@ com:
client-http-request-factory-impl: jetty
```
-Two extra properties tune the underlying client: `http-protocol-version` (forces the HTTP protocol version) and `use-virtual-threads` (sets the application task executor — the `applicationTaskExecutor` bean — on the client; combine with `spring.threads.virtual.enabled=true` for virtual threads):
+Two extra properties tune the underlying client: `http-protocol-version` (forces the HTTP protocol version) and `use-virtual-threads` (sets the application task executor — the `applicationTaskExecutor` bean, virtual-thread when `spring.threads.virtual.enabled=true` — on the client). `use-virtual-threads` is a boolean that, when left unset, defaults to the value of `spring.threads.virtual.enabled`:
```yaml
com:
c4-soft:
@@ -247,7 +247,7 @@ com:
```
Support depends on the `client-http-request-factory-impl`:
- `jdk`: both properties (`http-protocol-version` via the client version, `use-virtual-threads` via the client executor).
-- `jetty`: both properties (`http-protocol-version` via the client transport — `HTTP_2` requires `org.eclipse.jetty.http2:jetty-http2-client` on the class-path; `use-virtual-threads` via the client executor).
+- `jetty`: both properties (`http-protocol-version` via the client transport — `HTTP_2` requires `org.eclipse.jetty.http2:jetty-http2-client` and `jetty-http2-client-transport` on the class-path; `use-virtual-threads` via the client executor).
- `http-components`: neither — the classic Apache client is HTTP/1.1 only and runs on the calling thread (so already on a virtual thread when the caller is). Both properties are ignored.
### 2.6. Working with SSL bundles
diff --git a/spring-addons-starter-rest/pom.xml b/spring-addons-starter-rest/pom.xml
index 7fc7c6011..c194effcd 100644
--- a/spring-addons-starter-rest/pom.xml
+++ b/spring-addons-starter-rest/pom.xml
@@ -89,6 +89,18 @@
jetty-client
true
+
+ org.eclipse.jetty.http2
+ jetty-http2-client
+ ${jetty.version}
+ true
+
+
+ org.eclipse.jetty.http2
+ jetty-http2-client-transport
+ ${jetty.version}
+ true
+
org.slf4j
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
index e6cb11a20..834f78bcd 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
@@ -276,9 +276,10 @@ public static class ClientHttpRequestFactoryProperties {
* If true, the application task executor (the {@code applicationTaskExecutor} bean, which runs
* on virtual threads when {@code spring.threads.virtual.enabled} is true) is set on the
* underlying client. Honored by the JDK and JETTY implementations (the classic Apache client
- * runs on the calling thread, so HTTP_COMPONENTS ignores it).
+ * runs on the calling thread, so HTTP_COMPONENTS ignores it). When empty, defaults to the
+ * value of {@code spring.threads.virtual.enabled}.
*/
- private boolean useVirtualThreads = false;
+ private Optional useVirtualThreads = Optional.empty();
@Data
public static class ProxyProperties {
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java
index ee57bf22d..68daabd27 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java
@@ -5,6 +5,8 @@
import org.eclipse.jetty.client.HttpProxy;
import org.eclipse.jetty.client.Origin;
import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP;
+import org.eclipse.jetty.http2.client.HTTP2Client;
+import org.eclipse.jetty.http2.client.transport.HttpClientTransportOverHTTP2;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.springframework.http.client.JettyClientHttpRequestFactory;
import org.springframework.util.StringUtils;
@@ -46,8 +48,15 @@ private static org.eclipse.jetty.client.HttpClient httpClientForVersion(
java.net.http.HttpClient.Version version) {
return switch (version) {
case HTTP_1_1 -> new org.eclipse.jetty.client.HttpClient(new HttpClientTransportOverHTTP());
- case HTTP_2 -> throw new RestMisconfigurationException(
- "http-protocol-version HTTP_2 is not supported for the Jetty implementation without org.eclipse.jetty.http2:jetty-http2-client on the class-path");
+ case HTTP_2 -> {
+ try {
+ yield new org.eclipse.jetty.client.HttpClient(
+ new HttpClientTransportOverHTTP2(new HTTP2Client()));
+ } catch (NoClassDefFoundError e) {
+ throw new RestMisconfigurationException(
+ "http-protocol-version HTTP_2 with the Jetty implementation requires org.eclipse.jetty.http2:jetty-http2-client and jetty-http2-client-transport on the class-path");
+ }
+ }
};
}
}
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/RestClientBuilderFactoryBean.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/RestClientBuilderFactoryBean.java
index 400310648..ff6a81970 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/RestClientBuilderFactoryBean.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/RestClientBuilderFactoryBean.java
@@ -6,6 +6,7 @@
import org.jspecify.annotations.Nullable;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.FactoryBean;
+import org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration;
import org.springframework.boot.restclient.autoconfigure.RestClientSsl;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
@@ -51,15 +52,24 @@ public void setApplicationContext(ApplicationContext applicationContext) throws
private @Nullable Executor virtualThreadsExecutor(
SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties http) {
- if (!http.isUseVirtualThreads()) {
+ final boolean useVirtualThreads =
+ http.getUseVirtualThreads().orElseGet(this::isSpringVirtualThreadsEnabled);
+ if (!useVirtualThreads) {
return null;
}
if (applicationContext == null) {
throw new RestMisconfigurationException(
- "use-virtual-threads requires an ApplicationContext to resolve the 'applicationTaskExecutor' bean for REST client '%s'"
- .formatted(clientId));
+ "use-virtual-threads requires an ApplicationContext to resolve the '%s' bean for REST client '%s'"
+ .formatted(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME,
+ clientId));
}
- return applicationContext.getBean("applicationTaskExecutor", Executor.class);
+ return applicationContext.getBean(
+ TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME, Executor.class);
+ }
+
+ private boolean isSpringVirtualThreadsEnabled() {
+ return applicationContext != null && Boolean.TRUE.equals(applicationContext.getEnvironment()
+ .getProperty("spring.threads.virtual.enabled", Boolean.class, Boolean.FALSE));
}
diff --git a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryJettyVersionTest.java b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryJettyVersionTest.java
index e27628788..7b315220e 100644
--- a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryJettyVersionTest.java
+++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryJettyVersionTest.java
@@ -1,18 +1,17 @@
package com.c4_soft.springaddons.rest;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
-import static org.junit.jupiter.api.Assertions.assertThrows;
import java.net.http.HttpClient;
import java.util.Optional;
import org.junit.jupiter.api.Test;
-import com.c4_soft.springaddons.rest.RestMisconfigurationException;
import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties;
import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties.ClientHttpRequestFactoryImpl;
import com.c4_soft.springaddons.rest.synchronised.SpringAddonsClientHttpRequestFactory;
/**
- * Verifies the {@code http.http-protocol-version} mapping for the Jetty implementation: HTTP/1.1 is
- * configured through the over-HTTP transport, HTTP/2 fails fast (it requires jetty-http2-client).
+ * Verifies the {@code http.http-protocol-version} mapping for the Jetty implementation: HTTP/1.1
+ * goes through the over-HTTP transport and HTTP/2 through the over-HTTP/2 transport (the
+ * jetty-http2-client dependencies are on the test class-path).
*/
class SpringAddonsClientHttpRequestFactoryJettyVersionTest {
@@ -27,12 +26,12 @@ void givenJettyAndHttp1_1_whenBuildingFactory_thenSucceeds() {
}
@Test
- void givenJettyAndHttp2_whenBuildingFactory_thenThrows() {
+ void givenJettyAndHttp2_whenBuildingFactory_thenSucceeds() {
final var http = new ClientHttpRequestFactoryProperties();
http.setClientHttpRequestFactoryImpl(ClientHttpRequestFactoryImpl.JETTY);
http.setHttpProtocolVersion(Optional.of(HttpClient.Version.HTTP_2));
- assertThrows(RestMisconfigurationException.class,
+ assertDoesNotThrow(
() -> new SpringAddonsClientHttpRequestFactory(new SystemProxyProperties(), http));
}
}
From 3d4548593b44f8473a405defd5275408709237c6 Mon Sep 17 00:00:00 2001
From: bohdanslobodian
Date: Thu, 25 Jun 2026 14:32:00 +0300
Subject: [PATCH 6/6] gh-298 Add a per-client request factory customizer
---
spring-addons-starter-rest/README.md | 18 +++++++
.../rest/SpringAddonsRestProperties.java | 7 +++
.../synchronised/HttpClientCustomizer.java | 21 ++++++++
...ponentsClientHttpRequestFactoryHelper.java | 5 +-
.../JettyClientHttpRequestFactoryHelper.java | 5 +-
.../RestClientBuilderFactoryBean.java | 17 ++++++-
.../SpringAddonsClientHttpRequestFactory.java | 42 +++++++++++-----
...lientHttpRequestFactoryCustomizerTest.java | 49 +++++++++++++++++++
8 files changed, 149 insertions(+), 15 deletions(-)
create mode 100644 spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/HttpClientCustomizer.java
create mode 100644 spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryCustomizerTest.java
diff --git a/spring-addons-starter-rest/README.md b/spring-addons-starter-rest/README.md
index cc5563f73..e62c4f59f 100644
--- a/spring-addons-starter-rest/README.md
+++ b/spring-addons-starter-rest/README.md
@@ -250,6 +250,24 @@ Support depends on the `client-http-request-factory-impl`:
- `jetty`: both properties (`http-protocol-version` via the client transport — `HTTP_2` requires `org.eclipse.jetty.http2:jetty-http2-client` and `jetty-http2-client-transport` on the class-path; `use-virtual-threads` via the client executor).
- `http-components`: neither — the classic Apache client is HTTP/1.1 only and runs on the calling thread (so already on a virtual thread when the caller is). Both properties are ignored.
+For anything not exposed as a property, reference an `HttpClientCustomizer` bean with `request-factory-customizer-bean`. It is applied to the implementation-specific client builder just before the request factory is built (`java.net.http.HttpClient.Builder` for `jdk`, `org.apache.hc.client5.http.impl.classic.HttpClientBuilder` for `http-components`, `org.eclipse.jetty.client.HttpClient` for `jetty`):
+```yaml
+com:
+ c4-soft:
+ springaddons:
+ rest:
+ client:
+ machin-client:
+ http:
+ request-factory-customizer-bean: machinHttpClientCustomizer
+```
+```java
+@Bean
+HttpClientCustomizer machinHttpClientCustomizer() {
+ return builder -> builder.version(HttpClient.Version.HTTP_1_1);
+}
+```
+
### 2.6. Working with SSL bundles
`spring-addons-starter-rest` integrates with Spring Boot SSL bundles (introduced in Boot `3.1`). Lets consider the following Boot configurations for SSL with self signed certificates generated with `openssl req -x509 -newkey rsa:4096 -keyout tls.key -out tls.crt -sha256 -days 3650 -passout pass:change-me -subj "/C=PY/ST=Tahiti/L=Papeete/CN=localhost/emailAddress=ch4mp@c4-soft.com"`:
- on the consumed REST API:
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
index 834f78bcd..1f0c608ca 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/SpringAddonsRestProperties.java
@@ -281,6 +281,13 @@ public static class ClientHttpRequestFactoryProperties {
*/
private Optional useVirtualThreads = Optional.empty();
+ /**
+ * Name of an {@code HttpClientCustomizer} bean applied to the underlying client builder of the
+ * configured {@link #clientHttpRequestFactoryImpl} just before the request factory is built.
+ * Enables configuration that is not exposed as properties.
+ */
+ private Optional requestFactoryCustomizerBean = Optional.empty();
+
@Data
public static class ProxyProperties {
private boolean enabled = true;
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/HttpClientCustomizer.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/HttpClientCustomizer.java
new file mode 100644
index 000000000..5933e8b4a
--- /dev/null
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/HttpClientCustomizer.java
@@ -0,0 +1,21 @@
+package com.c4_soft.springaddons.rest.synchronised;
+
+/**
+ * Customizes the implementation-specific HTTP client builder just before the request factory is
+ * built, for whatever is not exposed as a property. {@code B} is the builder of the configured
+ * {@code client-http-request-factory-impl}: {@code java.net.http.HttpClient.Builder} (JDK),
+ * {@code org.apache.hc.client5.http.impl.classic.HttpClientBuilder} (HttpComponents) or
+ * {@code org.eclipse.jetty.client.HttpClient} (Jetty). Reference it per client with the
+ * {@code http.request-factory-customizer-bean} property.
+ */
+@FunctionalInterface
+public interface HttpClientCustomizer {
+ void customize(B builder);
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ static void apply(Object customizer, Object builder) {
+ if (customizer instanceof HttpClientCustomizer c) {
+ c.customize(builder);
+ }
+ }
+}
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/HttpComponentsClientHttpRequestFactoryHelper.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/HttpComponentsClientHttpRequestFactoryHelper.java
index 2e9b43d9f..708a6bca8 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/HttpComponentsClientHttpRequestFactoryHelper.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/HttpComponentsClientHttpRequestFactoryHelper.java
@@ -14,12 +14,13 @@
import org.apache.hc.core5.http.HttpHost;
import org.apache.hc.core5.ssl.SSLContextBuilder;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
+import org.jspecify.annotations.Nullable;
import com.c4_soft.springaddons.rest.ProxySupport;
import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties;
class HttpComponentsClientHttpRequestFactoryHelper {
public static HttpComponentsClientHttpRequestFactory get(ProxySupport proxySupport,
- ClientHttpRequestFactoryProperties properties)
+ ClientHttpRequestFactoryProperties properties, @Nullable Object requestFactoryCustomizer)
throws KeyManagementException, NoSuchAlgorithmException, KeyStoreException {
final var httpClientBuilder = HttpClients.custom();
@@ -36,6 +37,8 @@ public static HttpComponentsClientHttpRequestFactory get(ProxySupport proxySuppo
.build());
}
+ HttpClientCustomizer.apply(requestFactoryCustomizer, httpClientBuilder);
+
final var clientHttpRequestFactory =
new HttpComponentsClientHttpRequestFactory(httpClientBuilder.build());
properties.getReadTimeoutMillis().map(Duration::ofMillis)
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java
index 68daabd27..6b8703f88 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/JettyClientHttpRequestFactoryHelper.java
@@ -17,7 +17,8 @@
class JettyClientHttpRequestFactoryHelper {
public static JettyClientHttpRequestFactory get(ProxySupport proxySupport,
- ClientHttpRequestFactoryProperties properties, @Nullable Executor executor) {
+ ClientHttpRequestFactoryProperties properties, @Nullable Executor executor,
+ @Nullable Object requestFactoryCustomizer) {
final var httpClient = properties.getHttpProtocolVersion()
.map(JettyClientHttpRequestFactoryHelper::httpClientForVersion)
.orElseGet(org.eclipse.jetty.client.HttpClient::new);
@@ -37,6 +38,8 @@ public static JettyClientHttpRequestFactory get(ProxySupport proxySupport,
httpClient.setSslContextFactory(new SslContextFactory.Client(true));
}
+ HttpClientCustomizer.apply(requestFactoryCustomizer, httpClient);
+
final var clientHttpRequestFactory = new JettyClientHttpRequestFactory(httpClient);
properties.getReadTimeoutMillis().map(Duration::ofMillis)
.ifPresent(clientHttpRequestFactory::setReadTimeout);
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/RestClientBuilderFactoryBean.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/RestClientBuilderFactoryBean.java
index ff6a81970..508943129 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/RestClientBuilderFactoryBean.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/RestClientBuilderFactoryBean.java
@@ -72,6 +72,20 @@ private boolean isSpringVirtualThreadsEnabled() {
.getProperty("spring.threads.virtual.enabled", Boolean.class, Boolean.FALSE));
}
+ private @Nullable Object requestFactoryCustomizer(
+ SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties http) {
+ if (http.getRequestFactoryCustomizerBean().isEmpty()) {
+ return null;
+ }
+ final var beanName = http.getRequestFactoryCustomizerBean().get();
+ if (applicationContext == null) {
+ throw new RestMisconfigurationException(
+ "Cannot resolve request-factory-customizer-bean '%s' for REST client '%s': no ApplicationContext available"
+ .formatted(beanName, clientId));
+ }
+ return applicationContext.getBean(beanName);
+ }
+
@Override
public RestClient.Builder getObject() throws Exception {
@@ -83,7 +97,8 @@ public RestClient.Builder getObject() throws Exception {
// Handle HTTP or SOCK proxy and set timeouts
builder.requestFactory(clientHttpRequestFactory
.orElseGet(() -> new SpringAddonsClientHttpRequestFactory(systemProxyProperties,
- clientProps.getHttp(), virtualThreadsExecutor(clientProps.getHttp()))));
+ clientProps.getHttp(), virtualThreadsExecutor(clientProps.getHttp()),
+ requestFactoryCustomizer(clientProps.getHttp()))));
clientProps.getBaseUrl().map(URL::toString).ifPresent(builder::baseUrl);
diff --git a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java
index d3bc31421..effc79c08 100644
--- a/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java
+++ b/spring-addons-starter-rest/src/main/java/com/c4_soft/springaddons/rest/synchronised/SpringAddonsClientHttpRequestFactory.java
@@ -51,27 +51,36 @@ public class SpringAddonsClientHttpRequestFactory implements ClientHttpRequestFa
public SpringAddonsClientHttpRequestFactory(SystemProxyProperties systemProperties,
ClientHttpRequestFactoryProperties addonsProperties) {
- this(systemProperties, addonsProperties, null);
+ this(systemProperties, addonsProperties, null, null);
+ }
+
+ public SpringAddonsClientHttpRequestFactory(SystemProxyProperties systemProperties,
+ ClientHttpRequestFactoryProperties addonsProperties, @Nullable Executor executor) {
+ this(systemProperties, addonsProperties, executor, null);
}
/**
* @param executor the {@link Executor} to set on the underlying client when use-virtual-threads is
* enabled (typically the application task executor resolved from the context). Honored by
* the JDK and Jetty implementations.
+ * @param requestFactoryCustomizer optional {@link HttpClientCustomizer} bean applied to the
+ * implementation-specific client builder just before the request factory is built.
*/
public SpringAddonsClientHttpRequestFactory(SystemProxyProperties systemProperties,
- ClientHttpRequestFactoryProperties addonsProperties, @Nullable Executor executor) {
+ ClientHttpRequestFactoryProperties addonsProperties, @Nullable Executor executor,
+ @Nullable Object requestFactoryCustomizer) {
final var proxySupport = new ProxySupport(systemProperties, addonsProperties.getProxy());
this.nonProxyHostsPattern = proxySupport.isEnabled()
? Optional.ofNullable(proxySupport.getNoProxy()).map(Pattern::compile)
: Optional.empty();
- this.noProxyDelegate = clientHttpRequestFactory(null, addonsProperties, executor);
+ this.noProxyDelegate =
+ clientHttpRequestFactory(null, addonsProperties, executor, requestFactoryCustomizer);
if (proxySupport.isEnabled()) {
- this.proxyDelegate =
- new ProxyAwareClientHttpRequestFactory(proxySupport, addonsProperties, executor);
+ this.proxyDelegate = new ProxyAwareClientHttpRequestFactory(proxySupport, addonsProperties,
+ executor, requestFactoryCustomizer);
} else {
this.proxyDelegate = this.noProxyDelegate;
}
@@ -103,19 +112,23 @@ private static HttpClient.Builder httpClientBuilder(ClientHttpRequestFactoryProp
}
private static ClientHttpRequestFactory clientHttpRequestFactory(ProxySupport proxySupport,
- ClientHttpRequestFactoryProperties properties, @Nullable Executor executor) {
+ ClientHttpRequestFactoryProperties properties, @Nullable Executor executor,
+ @Nullable Object requestFactoryCustomizer) {
switch (properties.getClientHttpRequestFactoryImpl()) {
case HTTP_COMPONENTS:
try {
- return HttpComponentsClientHttpRequestFactoryHelper.get(proxySupport, properties);
+ return HttpComponentsClientHttpRequestFactoryHelper.get(proxySupport, properties,
+ requestFactoryCustomizer);
} catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) {
throw new RestMisconfigurationException(e);
}
case JETTY:
- return JettyClientHttpRequestFactoryHelper.get(proxySupport, properties, executor);
+ return JettyClientHttpRequestFactoryHelper.get(proxySupport, properties, executor,
+ requestFactoryCustomizer);
default:
try {
- return jdkClientHttpRequestFactory(proxySupport, properties, executor);
+ return jdkClientHttpRequestFactory(proxySupport, properties, executor,
+ requestFactoryCustomizer);
} catch (KeyManagementException | NoSuchAlgorithmException e) {
throw new RestMisconfigurationException(e);
}
@@ -123,7 +136,8 @@ private static ClientHttpRequestFactory clientHttpRequestFactory(ProxySupport pr
}
private static JdkClientHttpRequestFactory jdkClientHttpRequestFactory(ProxySupport proxySupport,
- ClientHttpRequestFactoryProperties properties, @Nullable Executor executor)
+ ClientHttpRequestFactoryProperties properties, @Nullable Executor executor,
+ @Nullable Object requestFactoryCustomizer)
throws NoSuchAlgorithmException, KeyManagementException {
final var httpClientBuilder = httpClientBuilder(properties, executor);
if (proxySupport != null && proxySupport.isEnabled()) {
@@ -152,6 +166,8 @@ public void checkServerTrusted(X509Certificate[] arg0, String arg1)
httpClientBuilder.sslContext(sslContext);
}
+ HttpClientCustomizer.apply(requestFactoryCustomizer, httpClientBuilder);
+
final var clientHttpRequestFactory = new JdkClientHttpRequestFactory(httpClientBuilder.build());
properties.getReadTimeoutMillis().map(Duration::ofMillis)
.ifPresent(clientHttpRequestFactory::setReadTimeout);
@@ -165,7 +181,8 @@ public static class ProxyAwareClientHttpRequestFactory implements ClientHttpRequ
private final @Nullable String password;
public ProxyAwareClientHttpRequestFactory(ProxySupport proxySupport,
- ClientHttpRequestFactoryProperties properties, @Nullable Executor executor) {
+ ClientHttpRequestFactoryProperties properties, @Nullable Executor executor,
+ @Nullable Object requestFactoryCustomizer) {
this.username = proxySupport.getUsername();
this.password = proxySupport.getPassword();
final var httpClient = HttpClient.newBuilder();
@@ -174,7 +191,8 @@ public ProxyAwareClientHttpRequestFactory(ProxySupport proxySupport,
httpClient.proxy(ProxySelector.of(proxyAddress));
properties.getConnectTimeoutMillis().map(Duration::ofMillis)
.ifPresent(httpClient::connectTimeout);
- this.delegate = clientHttpRequestFactory(proxySupport, properties, executor);
+ this.delegate =
+ clientHttpRequestFactory(proxySupport, properties, executor, requestFactoryCustomizer);
}
@Override
diff --git a/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryCustomizerTest.java b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryCustomizerTest.java
new file mode 100644
index 000000000..3d94b483e
--- /dev/null
+++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryCustomizerTest.java
@@ -0,0 +1,49 @@
+package com.c4_soft.springaddons.rest;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import java.net.URI;
+import java.net.http.HttpClient;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.client.ClientHttpRequest;
+import com.c4_soft.springaddons.rest.SpringAddonsRestProperties.RestClientProperties.ClientHttpRequestFactoryProperties;
+import com.c4_soft.springaddons.rest.synchronised.HttpClientCustomizer;
+import com.c4_soft.springaddons.rest.synchronised.SpringAddonsClientHttpRequestFactory;
+
+/**
+ * Verifies that an {@link HttpClientCustomizer} is applied to the underlying client builder by
+ * {@link SpringAddonsClientHttpRequestFactory} (JDK implementation).
+ */
+class SpringAddonsClientHttpRequestFactoryCustomizerTest {
+
+ @Test
+ void givenJdkCustomizer_whenCreatingRequest_thenItIsAppliedToTheHttpClient() throws Exception {
+ final var http = new ClientHttpRequestFactoryProperties();
+ final HttpClientCustomizer customizer =
+ builder -> builder.version(HttpClient.Version.HTTP_1_1);
+
+ final var factory = new SpringAddonsClientHttpRequestFactory(new SystemProxyProperties(), http,
+ null, customizer);
+
+ assertEquals(HttpClient.Version.HTTP_1_1, jdkHttpClientOf(factory).version());
+ }
+
+ @Test
+ void givenNoCustomizer_whenCreatingRequest_thenTheJdkDefaultIsKept() throws Exception {
+ final var http = new ClientHttpRequestFactoryProperties();
+
+ final var factory =
+ new SpringAddonsClientHttpRequestFactory(new SystemProxyProperties(), http, null, null);
+
+ assertEquals(HttpClient.Version.HTTP_2, jdkHttpClientOf(factory).version());
+ }
+
+ private static HttpClient jdkHttpClientOf(SpringAddonsClientHttpRequestFactory factory)
+ throws Exception {
+ final ClientHttpRequest request =
+ factory.createRequest(URI.create("https://localhost/test"), HttpMethod.GET);
+ final var httpClientField = request.getClass().getDeclaredField("httpClient");
+ httpClientField.setAccessible(true);
+ return (HttpClient) httpClientField.get(request);
+ }
+}