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); + } +}