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