diff --git a/spring-addons-starter-rest/README.md b/spring-addons-starter-rest/README.md
index c304b1ad5..e62c4f59f 100644
--- a/spring-addons-starter-rest/README.md
+++ b/spring-addons-starter-rest/README.md
@@ -233,6 +233,41 @@ 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, 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:
+ springaddons:
+ rest:
+ client:
+ machin-client:
+ http:
+ 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` 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/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 a61a3a9d3..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
@@ -264,6 +264,30 @@ public static class ClientHttpRequestFactoryProperties {
*/
private boolean sslCertificatesValidationEnabled = true;
+ /**
+ * 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();
+
+ /**
+ * 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). When empty, defaults to the
+ * value of {@code spring.threads.virtual.enabled}.
+ */
+ 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 46ddef22a..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
@@ -1,18 +1,31 @@
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.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;
+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) {
- final var httpClient = new org.eclipse.jetty.client.HttpClient();
+ ClientHttpRequestFactoryProperties properties, @Nullable Executor executor,
+ @Nullable Object requestFactoryCustomizer) {
+ final var httpClient = properties.getHttpProtocolVersion()
+ .map(JettyClientHttpRequestFactoryHelper::httpClientForVersion)
+ .orElseGet(org.eclipse.jetty.client.HttpClient::new);
+
+ if (executor != null) {
+ httpClient.setExecutor(executor);
+ }
if (proxySupport != null && proxySupport.isEnabled()) {
final var httpProxy = new HttpProxy(
@@ -25,10 +38,28 @@ 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);
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 -> {
+ 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 8c292733e..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
@@ -2,9 +2,14 @@
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.autoconfigure.task.TaskExecutionAutoConfiguration;
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 +32,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 +43,48 @@ 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) {
+ 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 '%s' bean for REST client '%s'"
+ .formatted(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME,
+ clientId));
+ }
+ 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));
+ }
+
+ 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
@@ -49,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())));
+ 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 29ccaf581..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
@@ -14,6 +14,7 @@
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;
@@ -40,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 {
@@ -50,16 +51,36 @@ public class SpringAddonsClientHttpRequestFactory implements ClientHttpRequestFa
public SpringAddonsClientHttpRequestFactory(SystemProxyProperties systemProperties,
ClientHttpRequestFactoryProperties addonsProperties) {
+ 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,
+ @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);
+ this.noProxyDelegate =
+ clientHttpRequestFactory(null, addonsProperties, executor, requestFactoryCustomizer);
if (proxySupport.isEnabled()) {
- this.proxyDelegate = new ProxyAwareClientHttpRequestFactory(proxySupport, addonsProperties);
+ this.proxyDelegate = new ProxyAwareClientHttpRequestFactory(proxySupport, addonsProperties,
+ executor, requestFactoryCustomizer);
} else {
this.proxyDelegate = this.noProxyDelegate;
}
@@ -78,28 +99,36 @@ 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 (executor != null) {
+ httpClient.executor(executor);
+ }
return httpClient;
}
private static ClientHttpRequestFactory clientHttpRequestFactory(ProxySupport proxySupport,
- ClientHttpRequestFactoryProperties properties) {
+ 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);
+ return JettyClientHttpRequestFactoryHelper.get(proxySupport, properties, executor,
+ requestFactoryCustomizer);
default:
try {
- return jdkClientHttpRequestFactory(proxySupport, properties);
+ return jdkClientHttpRequestFactory(proxySupport, properties, executor,
+ requestFactoryCustomizer);
} catch (KeyManagementException | NoSuchAlgorithmException e) {
throw new RestMisconfigurationException(e);
}
@@ -107,9 +136,10 @@ private static ClientHttpRequestFactory clientHttpRequestFactory(ProxySupport pr
}
private static JdkClientHttpRequestFactory jdkClientHttpRequestFactory(ProxySupport proxySupport,
- ClientHttpRequestFactoryProperties properties)
+ ClientHttpRequestFactoryProperties properties, @Nullable Executor executor,
+ @Nullable Object requestFactoryCustomizer)
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());
@@ -136,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);
@@ -149,7 +181,8 @@ public static class ProxyAwareClientHttpRequestFactory implements ClientHttpRequ
private final @Nullable String password;
public ProxyAwareClientHttpRequestFactory(ProxySupport proxySupport,
- ClientHttpRequestFactoryProperties properties) {
+ ClientHttpRequestFactoryProperties properties, @Nullable Executor executor,
+ @Nullable Object requestFactoryCustomizer) {
this.username = proxySupport.getUsername();
this.password = proxySupport.getPassword();
final var httpClient = HttpClient.newBuilder();
@@ -158,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);
+ 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);
+ }
+}
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);
+ }
+}
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..7b315220e
--- /dev/null
+++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryJettyVersionTest.java
@@ -0,0 +1,37 @@
+package com.c4_soft.springaddons.rest;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import java.net.http.HttpClient;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+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
+ * 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 {
+
+ @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_thenSucceeds() {
+ final var http = new ClientHttpRequestFactoryProperties();
+ http.setClientHttpRequestFactoryImpl(ClientHttpRequestFactoryImpl.JETTY);
+ http.setHttpProtocolVersion(Optional.of(HttpClient.Version.HTTP_2));
+
+ assertDoesNotThrow(
+ () -> new SpringAddonsClientHttpRequestFactory(new SystemProxyProperties(), http));
+ }
+}
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..361644088
--- /dev/null
+++ b/spring-addons-starter-rest/src/test/java/com/c4_soft/springaddons/rest/SpringAddonsClientHttpRequestFactoryVirtualThreadsTest.java
@@ -0,0 +1,49 @@
+package com.c4_soft.springaddons.rest;
+
+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.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 {@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
+ void givenAnExecutor_whenCreatingRequest_thenTheJdkHttpClientUsesIt() throws Exception {
+ final var http = new ClientHttpRequestFactoryProperties();
+ final Executor executor = Runnable::run;
+
+ final var factory =
+ new SpringAddonsClientHttpRequestFactory(new SystemProxyProperties(), http, executor);
+
+ assertTrue(jdkHttpClientOf(factory).executor().isPresent());
+ }
+
+ @Test
+ void givenNoExecutor_whenCreatingRequest_thenTheJdkHttpClientHasNoExplicitExecutor()
+ throws Exception {
+ final var http = new ClientHttpRequestFactoryProperties();
+
+ final var factory =
+ new SpringAddonsClientHttpRequestFactory(new SystemProxyProperties(), http);
+
+ assertTrue(jdkHttpClientOf(factory).executor().isEmpty());
+ }
+
+ 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);
+ }
+}