From 983e7b637b3afc8241a18dd57a49e17b6a2c7690 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Thu, 27 Mar 2025 14:24:39 -0700 Subject: [PATCH] Introduce `ClientHttpConnectorBuilder` support Add a new `ClientHttpConnectorBuilder` interface to support the creation of `ClientHttpConnector` instances. The new code is similar to the `ClientHttpRequestFactoryBuilder` interface that was added to Spring Boot 3.4. The `ClientHttpConnectorBuilder` is a functional interface with additional static factory methods for the various supported `ClientHttpConnector` types. Each type has it's own builder which to support client specific customization. The previous auto-configuration has been relocated to the `org.springframework.boot.autoconfigure.http.client.reactive` package and updated to make use of the builder. Closes gh-43079 --- .../client/AbstractHttpClientProperties.java | 120 +++++++++ .../AbstractHttpRequestFactoryProperties.java | 100 +++++++ .../client/HttpClientAutoConfiguration.java | 26 +- .../http/client/HttpClientProperties.java | 134 +-------- ...AbstractClientHttpConnectorProperties.java | 100 +++++++ .../ClientHttpConnectorAutoConfiguration.java | 106 ++++++++ .../ClientHttpConnectorBuilderCustomizer.java | 38 +++ .../HttpReactiveClientSettingsProperties.java | 33 +++ .../http/client/reactive/package-info.java | 20 ++ .../client/AutoConfiguredWebClientSsl.java | 17 +- .../ClientHttpConnectorAutoConfiguration.java | 69 +++-- ...ientHttpConnectorFactoryConfiguration.java | 81 ------ ...pComponentsClientHttpConnectorFactory.java | 64 ----- .../client/JdkClientHttpConnectorFactory.java | 49 ---- .../ReactorClientHttpConnectorFactory.java | 100 ------- .../client/ReactorNettyHttpClientMapper.java | 6 + .../client/WebClientAutoConfiguration.java | 22 +- ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../client/HttpClientPropertiesTests.java | 4 +- ...ntHttpConnectorAutoConfigurationTests.java | 204 ++++++++++++++ ...ReactiveClientSettingsPropertiesTests.java | 63 +++++ ...stractClientHttpConnectorFactoryTests.java | 106 -------- ...ntHttpConnectorAutoConfigurationTests.java | 135 ++-------- ...ttpConnectorFactoryConfigurationTests.java | 83 ------ ...eactorClientHttpConnectorFactoryTests.java | 49 ---- .../ReactorNettyHttpClientMapperTests.java | 1 + .../WebClientAutoConfigurationTests.java | 5 +- .../web/reactive/function/client/test.jks | Bin 1276 -> 0 bytes .../reference/pages/io/rest-client.adoc | 39 +++ .../MyConnectorHttpConfiguration.java | 33 +++ .../MyConnectorHttpConfiguration.kt | 17 ++ spring-boot-project/spring-boot/build.gradle | 2 + ...stractClientHttpRequestFactoryBuilder.java | 15 +- .../ClientHttpRequestFactorySettings.java | 30 ++- .../boot/http/client/Empty.java} | 22 +- .../boot/http/client/HttpClientSettings.java | 43 +++ ...onentsClientHttpRequestFactoryBuilder.java | 144 ++-------- .../HttpComponentsHttpAsyncClientBuilder.java | 193 +++++++++++++ .../HttpComponentsHttpClientBuilder.java | 192 +++++++++++++ .../HttpComponentsRedirectStrategy.java | 70 +++++ .../HttpComponentsSslBundleTlsStrategy.java | 46 ++++ .../boot/http/client/HttpRedirects.java} | 27 +- .../JdkClientHttpRequestFactoryBuilder.java | 52 +--- .../http/client/JdkHttpClientBuilder.java | 94 +++++++ .../JettyClientHttpRequestFactoryBuilder.java | 95 +------ .../http/client/JettyHttpClientBuilder.java | 186 +++++++++++++ ...eactorClientHttpRequestFactoryBuilder.java | 61 +---- .../http/client/ReactorHttpClientBuilder.java | 114 ++++++++ .../AbstractClientHttpConnectorBuilder.java | 82 ++++++ .../reactive/ClientHttpConnectorBuilder.java | 201 ++++++++++++++ .../reactive/ClientHttpConnectorSettings.java | 116 ++++++++ ...pComponentsClientHttpConnectorBuilder.java | 146 ++++++++++ .../JdkClientHttpConnectorBuilder.java | 91 +++++++ .../JettyClientHttpConnectorBuilder.java | 120 +++++++++ .../ReactorClientHttpConnectorBuilder.java | 91 +++++++ .../http/client/reactive/package-info.java | 20 ++ ...sClientHttpRequestFactoryBuilderTests.java | 3 +- ...kClientHttpRequestFactoryBuilderTests.java | 4 +- ...yClientHttpRequestFactoryBuilderTests.java | 5 +- ...rClientHttpRequestFactoryBuilderTests.java | 5 +- ...stractClientHttpConnectorBuilderTests.java | 255 ++++++++++++++++++ .../ClientHttpConnectorBuilderTests.java | 158 +++++++++++ ...onentsClientHttpConnectorBuilderTests.java | 115 ++++++++ .../JdkClientHttpConnectorBuilderTests.java | 64 +++++ .../JettyClientHttpConnectorBuilderTests.java | 71 +++++ ...eactorClientHttpConnectorBuilderTests.java | 79 ++++++ .../http/client/reactive/TestCustomizer.java} | 27 +- .../boot/http/client/reactive/test.jks | Bin 0 -> 6536 bytes 68 files changed, 3682 insertions(+), 1182 deletions(-) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/AbstractHttpClientProperties.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/AbstractHttpRequestFactoryProperties.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/AbstractClientHttpConnectorProperties.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorBuilderCustomizer.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/HttpReactiveClientSettingsProperties.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/package-info.java delete mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java delete mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactory.java delete mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JdkClientHttpConnectorFactory.java delete mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorAutoConfigurationTests.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/HttpReactiveClientSettingsPropertiesTests.java delete mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AbstractClientHttpConnectorFactoryTests.java delete mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java delete mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java delete mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/reactive/function/client/test.jks create mode 100644 spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/io/restclient/webclient/configuration/MyConnectorHttpConfiguration.java create mode 100644 spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/webclient/configuration/MyConnectorHttpConfiguration.kt rename spring-boot-project/{spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JdkClientHttpConnectorFactoryTests.java => spring-boot/src/main/java/org/springframework/boot/http/client/Empty.java} (60%) create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpClientSettings.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpAsyncClientBuilder.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpClientBuilder.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsRedirectStrategy.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsSslBundleTlsStrategy.java rename spring-boot-project/{spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactoryTests.java => spring-boot/src/main/java/org/springframework/boot/http/client/HttpRedirects.java} (55%) create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JdkHttpClientBuilder.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JettyHttpClientBuilder.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/ReactorHttpClientBuilder.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/AbstractClientHttpConnectorBuilder.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/ClientHttpConnectorBuilder.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/ClientHttpConnectorSettings.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilder.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilder.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilder.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilder.java create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/package-info.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/AbstractClientHttpConnectorBuilderTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/ClientHttpConnectorBuilderTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilderTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilderTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilderTests.java create mode 100644 spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilderTests.java rename spring-boot-project/{spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactory.java => spring-boot/src/test/java/org/springframework/boot/http/client/reactive/TestCustomizer.java} (50%) create mode 100644 spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/http/client/reactive/test.jks diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/AbstractHttpClientProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/AbstractHttpClientProperties.java new file mode 100644 index 0000000000..ea0c41791b --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/AbstractHttpClientProperties.java @@ -0,0 +1,120 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.http.client; + +import java.time.Duration; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.http.client.HttpRedirects; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.util.StringUtils; + +/** + * Abstract base class for properties that directly or indirectly make use of a blocking + * or reactive HTTP client. + * + * @author Phillip Webb + * @since 3.5.0 + * @see HttpClientSettings + */ +public abstract class AbstractHttpClientProperties { + + /** + * Handling for HTTP redirects. + */ + private HttpRedirects redirects = HttpRedirects.FOLLOW_WHEN_POSSIBLE; + + /** + * Default connect timeout for a client HTTP request. + */ + private Duration connectTimeout; + + /** + * Default read timeout for a client HTTP request. + */ + private Duration readTimeout; + + /** + * Default SSL configuration for a client HTTP request. + */ + private final Ssl ssl = new Ssl(); + + public HttpRedirects getRedirects() { + return this.redirects; + } + + public void setRedirects(HttpRedirects redirects) { + this.redirects = redirects; + } + + public Duration getConnectTimeout() { + return this.connectTimeout; + } + + public void setConnectTimeout(Duration connectTimeout) { + this.connectTimeout = connectTimeout; + } + + public Duration getReadTimeout() { + return this.readTimeout; + } + + public void setReadTimeout(Duration readTimeout) { + this.readTimeout = readTimeout; + } + + public Ssl getSsl() { + return this.ssl; + } + + /** + * Return {@link HttpClientSettings} based on these properties. + * @param sslBundles a {@link SslBundles} provider + * @return the {@link HttpClientSettings} + */ + protected HttpClientSettings httpClientSettings(ObjectProvider sslBundles) { + return new HttpClientSettings(this.redirects, this.connectTimeout, this.readTimeout, sslBundle(sslBundles)); + } + + private SslBundle sslBundle(ObjectProvider sslBundles) { + String name = getSsl().getBundle(); + return (StringUtils.hasLength(name)) ? sslBundles.getObject().getBundle(name) : null; + } + + /** + * SSL configuration. + */ + public static class Ssl { + + /** + * SSL bundle to use. + */ + private String bundle; + + public String getBundle() { + return this.bundle; + } + + public void setBundle(String bundle) { + this.bundle = bundle; + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/AbstractHttpRequestFactoryProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/AbstractHttpRequestFactoryProperties.java new file mode 100644 index 0000000000..5c8ae4b43a --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/AbstractHttpRequestFactoryProperties.java @@ -0,0 +1,100 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.http.client; + +import java.util.function.Supplier; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; +import org.springframework.http.client.ClientHttpRequestFactory; + +/** + * Base {@link ConfigurationProperties @ConfigurationProperties} for configuring a + * {@link ClientHttpRequestFactory}. + * + * @author Phillip Webb + * @since 3.5.0 + * @see ClientHttpRequestFactorySettings + */ +public abstract class AbstractHttpRequestFactoryProperties extends AbstractHttpClientProperties { + + /** + * Default factory used for a client HTTP request. + */ + private Factory factory; + + public Factory getFactory() { + return this.factory; + } + + public void setFactory(Factory factory) { + this.factory = factory; + } + + /** + * Return a {@link ClientHttpRequestFactoryBuilder} based on the properties. + * @return a {@link ClientHttpRequestFactoryBuilder} + */ + protected final ClientHttpRequestFactoryBuilder factoryBuilder() { + Factory factory = getFactory(); + return (factory != null) ? factory.builder() : ClientHttpRequestFactoryBuilder.detect(); + } + + /** + * Supported factory types. + */ + public enum Factory { + + /** + * Apache HttpComponents HttpClient. + */ + HTTP_COMPONENTS(ClientHttpRequestFactoryBuilder::httpComponents), + + /** + * Jetty's HttpClient. + */ + JETTY(ClientHttpRequestFactoryBuilder::jetty), + + /** + * Reactor-Netty. + */ + REACTOR(ClientHttpRequestFactoryBuilder::reactor), + + /** + * Java's HttpClient. + */ + JDK(ClientHttpRequestFactoryBuilder::jdk), + + /** + * Standard JDK facilities. + */ + SIMPLE(ClientHttpRequestFactoryBuilder::simple); + + private final Supplier> builderSupplier; + + Factory(Supplier> builderSupplier) { + this.builderSupplier = builderSupplier; + } + + ClientHttpRequestFactoryBuilder builder() { + return this.builderSupplier.get(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/HttpClientAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/HttpClientAutoConfiguration.java index faa6273442..9d8d193fd1 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/HttpClientAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/HttpClientAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,17 +21,17 @@ import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.http.client.HttpClientProperties.Factory; import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; -import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings.Redirects; +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.http.client.HttpRedirects; import org.springframework.boot.ssl.SslBundles; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Conditional; import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.util.StringUtils; /** * {@link EnableAutoConfiguration Auto-configuration} for @@ -49,22 +49,24 @@ public class HttpClientAutoConfiguration { @Bean @ConditionalOnMissingBean ClientHttpRequestFactoryBuilder clientHttpRequestFactoryBuilder(HttpClientProperties httpClientProperties) { - Factory factory = httpClientProperties.getFactory(); - return (factory != null) ? factory.builder() : ClientHttpRequestFactoryBuilder.detect(); + return httpClientProperties.factoryBuilder(); } @Bean @ConditionalOnMissingBean ClientHttpRequestFactorySettings clientHttpRequestFactorySettings(HttpClientProperties httpClientProperties, ObjectProvider sslBundles) { - SslBundle sslBundle = getSslBundle(httpClientProperties.getSsl(), sslBundles); - return new ClientHttpRequestFactorySettings(httpClientProperties.getRedirects(), - httpClientProperties.getConnectTimeout(), httpClientProperties.getReadTimeout(), sslBundle); + HttpClientSettings settings = httpClientProperties.httpClientSettings(sslBundles); + return new ClientHttpRequestFactorySettings(asRequestFactoryRedirects(settings.redirects()), + settings.connectTimeout(), settings.readTimeout(), settings.sslBundle()); } - private SslBundle getSslBundle(HttpClientProperties.Ssl properties, ObjectProvider sslBundles) { - String name = properties.getBundle(); - return (StringUtils.hasLength(name)) ? sslBundles.getObject().getBundle(name) : null; + private Redirects asRequestFactoryRedirects(HttpRedirects redirects) { + return switch (redirects) { + case FOLLOW_WHEN_POSSIBLE -> Redirects.FOLLOW_WHEN_POSSIBLE; + case FOLLOW -> Redirects.FOLLOW; + case DONT_FOLLOW -> Redirects.DONT_FOLLOW; + }; } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/HttpClientProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/HttpClientProperties.java index abdc2e0c4c..7542c9ba93 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/HttpClientProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/HttpClientProperties.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,12 +16,8 @@ package org.springframework.boot.autoconfigure.http.client; -import java.time.Duration; -import java.util.function.Supplier; - import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; -import org.springframework.boot.http.client.ClientHttpRequestFactorySettings.Redirects; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; /** * {@link ConfigurationProperties @ConfigurationProperties} for a Spring's blocking HTTP @@ -29,131 +25,9 @@ import org.springframework.boot.http.client.ClientHttpRequestFactorySettings.Red * * @author Phillip Webb * @since 3.4.0 + * @see ClientHttpRequestFactorySettings */ @ConfigurationProperties("spring.http.client") -public class HttpClientProperties { - - /** - * Default factory used for a client HTTP request. - */ - private Factory factory; - - /** - * Handling for HTTP redirects. - */ - private Redirects redirects = Redirects.FOLLOW_WHEN_POSSIBLE; - - /** - * Default connect timeout for a client HTTP request. - */ - private Duration connectTimeout; - - /** - * Default read timeout for a client HTTP request. - */ - private Duration readTimeout; - - /** - * Default SSL configuration for a client HTTP request. - */ - private final Ssl ssl = new Ssl(); - - public Factory getFactory() { - return this.factory; - } - - public void setFactory(Factory factory) { - this.factory = factory; - } - - public Redirects getRedirects() { - return this.redirects; - } - - public void setRedirects(Redirects redirects) { - this.redirects = redirects; - } - - public Duration getConnectTimeout() { - return this.connectTimeout; - } - - public void setConnectTimeout(Duration connectTimeout) { - this.connectTimeout = connectTimeout; - } - - public Duration getReadTimeout() { - return this.readTimeout; - } - - public void setReadTimeout(Duration readTimeout) { - this.readTimeout = readTimeout; - } - - public Ssl getSsl() { - return this.ssl; - } - - /** - * Supported factory types. - */ - public enum Factory { - - /** - * Apache HttpComponents HttpClient. - */ - HTTP_COMPONENTS(ClientHttpRequestFactoryBuilder::httpComponents), - - /** - * Jetty's HttpClient. - */ - JETTY(ClientHttpRequestFactoryBuilder::jetty), - - /** - * Reactor-Netty. - */ - REACTOR(ClientHttpRequestFactoryBuilder::reactor), - - /** - * Java's HttpClient. - */ - JDK(ClientHttpRequestFactoryBuilder::jdk), - - /** - * Standard JDK facilities. - */ - SIMPLE(ClientHttpRequestFactoryBuilder::simple); - - private final Supplier> builderSupplier; - - Factory(Supplier> builderSupplier) { - this.builderSupplier = builderSupplier; - } - - ClientHttpRequestFactoryBuilder builder() { - return this.builderSupplier.get(); - } - - } - - /** - * SSL configuration. - */ - public static class Ssl { - - /** - * SSL bundle to use. - */ - private String bundle; - - public String getBundle() { - return this.bundle; - } - - public void setBundle(String bundle) { - this.bundle = bundle; - } - - } +public class HttpClientProperties extends AbstractHttpRequestFactoryProperties { } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/AbstractClientHttpConnectorProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/AbstractClientHttpConnectorProperties.java new file mode 100644 index 0000000000..d452f9b28e --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/AbstractClientHttpConnectorProperties.java @@ -0,0 +1,100 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.http.client.reactive; + +import java.util.function.Supplier; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.http.client.AbstractHttpClientProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.http.client.reactive.ClientHttpConnector; + +/** + * Base {@link ConfigurationProperties @ConfigurationProperties} for configuring a + * {@link ClientHttpConnector}. + * + * @author Phillip Webb + * @since 3.5.0 + * @see ClientHttpConnectorSettings + */ +public abstract class AbstractClientHttpConnectorProperties extends AbstractHttpClientProperties { + + /** + * Default connector used for a client HTTP request. + */ + private Connector connector; + + public Connector getConnector() { + return this.connector; + } + + public void setConnector(Connector connector) { + this.connector = connector; + } + + @Override + protected HttpClientSettings httpClientSettings(ObjectProvider sslBundles) { + return super.httpClientSettings(sslBundles); + } + + protected final ClientHttpConnectorBuilder connectorBuilder(ClassLoader classLoader) { + Connector connector = getConnector(); + return (connector != null) ? connector.builder() : ClientHttpConnectorBuilder.detect(classLoader); + } + + /** + * Supported factory types. + */ + public enum Connector { + + /** + * Reactor-Netty. + */ + REACTOR(ClientHttpConnectorBuilder::reactor), + + /** + * Jetty's HttpClient. + */ + JETTY(ClientHttpConnectorBuilder::jetty), + + /** + * Apache HttpComponents HttpClient. + */ + HTTP_COMPONENTS(ClientHttpConnectorBuilder::httpComponents), + + /** + * Java's HttpClient. + */ + JDK(ClientHttpConnectorBuilder::jdk); + + private final Supplier> builderSupplier; + + Connector(Supplier> builderSupplier) { + this.builderSupplier = builderSupplier; + } + + ClientHttpConnectorBuilder builder() { + return this.builderSupplier.get(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorAutoConfiguration.java new file mode 100644 index 0000000000..ce797e03ec --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorAutoConfiguration.java @@ -0,0 +1,106 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.http.client.reactive; + +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.reactor.netty.ReactorNettyConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.util.LambdaSafe; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Lazy; +import org.springframework.http.client.reactive.ClientHttpConnector; + +/** + * {@link EnableAutoConfiguration Auto-configuration} for + * {@link ClientHttpConnectorBuilder} and {@link ClientHttpConnectorSettings}. + * + * @author Phillip Webb + * @since 3.5.0 + */ +@AutoConfiguration(after = SslAutoConfiguration.class) +@ConditionalOnClass({ ClientHttpConnector.class, Mono.class }) +@EnableConfigurationProperties(HttpReactiveClientSettingsProperties.class) +public class ClientHttpConnectorAutoConfiguration implements BeanClassLoaderAware { + + private ClassLoader beanClassLoader; + + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.beanClassLoader = classLoader; + } + + @Bean + @ConditionalOnMissingBean + ClientHttpConnectorBuilder clientHttpConnectorBuilder( + HttpReactiveClientSettingsProperties httpReactiveClientSettingsProperties, + ObjectProvider> clientHttpConnectorBuilderCustomizers) { + ClientHttpConnectorBuilder builder = httpReactiveClientSettingsProperties + .connectorBuilder(this.beanClassLoader); + return customize(builder, clientHttpConnectorBuilderCustomizers.orderedStream().toList()); + } + + @SuppressWarnings("unchecked") + private ClientHttpConnectorBuilder customize(ClientHttpConnectorBuilder builder, + List> customizers) { + ClientHttpConnectorBuilder[] builderReference = { builder }; + LambdaSafe.callbacks(ClientHttpConnectorBuilderCustomizer.class, customizers, builderReference[0]) + .invoke((customizer) -> builderReference[0] = customizer.customize(builderReference[0])); + return builderReference[0]; + } + + @Bean + @ConditionalOnMissingBean + ClientHttpConnectorSettings clientHttpConnectorSettings( + HttpReactiveClientSettingsProperties httpReactiveClientSettingsProperties, + ObjectProvider sslBundles) { + HttpClientSettings settings = httpReactiveClientSettingsProperties.httpClientSettings(sslBundles); + return new ClientHttpConnectorSettings(settings.redirects(), settings.connectTimeout(), settings.readTimeout(), + settings.sslBundle()); + } + + @Bean + @Lazy + @ConditionalOnMissingBean + ClientHttpConnector clientHttpConnector(ClientHttpConnectorBuilder clientHttpConnectorBuilder, + ClientHttpConnectorSettings clientHttpRequestFactorySettings) { + return clientHttpConnectorBuilder.build(clientHttpRequestFactorySettings); + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(reactor.netty.http.client.HttpClient.class) + @Import(ReactorNettyConfigurations.ReactorResourceFactoryConfiguration.class) + static class ReactorNetty { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorBuilderCustomizer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorBuilderCustomizer.java new file mode 100644 index 0000000000..67fd64e84d --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorBuilderCustomizer.java @@ -0,0 +1,38 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.http.client.reactive; + +import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; + +/** + * Customizer that can be used to modify the auto-configured + * {@link ClientHttpConnectorBuilder} when its type matches. + * + * @param the builder type + * @author Phillip Webb + * @since 3.5.0 + */ +public interface ClientHttpConnectorBuilderCustomizer> { + + /** + * Customize the given builder. + * @param builder the builder to customize + * @return the customized builder + */ + B customize(B builder); + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/HttpReactiveClientSettingsProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/HttpReactiveClientSettingsProperties.java new file mode 100644 index 0000000000..c9e3074179 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/HttpReactiveClientSettingsProperties.java @@ -0,0 +1,33 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.http.client.reactive; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings; + +/** + * {@link ConfigurationProperties @ConfigurationProperties} to configure settings that + * apply to Spring's reactive client HTTP connectors. + * + * @author Phillip Webb + * @since 3.5.0 + * @see ClientHttpConnectorSettings + */ +@ConfigurationProperties("spring.http.reactiveclient.settings") +public class HttpReactiveClientSettingsProperties extends AbstractClientHttpConnectorProperties { + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/package-info.java new file mode 100644 index 0000000000..e60481c7c7 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-configuration for client-side reactive HTTP. + */ +package org.springframework.boot.autoconfigure.http.client.reactive; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AutoConfiguredWebClientSsl.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AutoConfiguredWebClientSsl.java index 503f42a35d..7460da016b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AutoConfiguredWebClientSsl.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AutoConfiguredWebClientSsl.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,8 @@ package org.springframework.boot.autoconfigure.web.reactive.function.client; import java.util.function.Consumer; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundles; import org.springframework.http.client.reactive.ClientHttpConnector; @@ -30,12 +32,16 @@ import org.springframework.web.reactive.function.client.WebClient; */ class AutoConfiguredWebClientSsl implements WebClientSsl { - private final ClientHttpConnectorFactory clientHttpConnectorFactory; + private final ClientHttpConnectorBuilder connectorBuilder; + + private final ClientHttpConnectorSettings settings; private final SslBundles sslBundles; - AutoConfiguredWebClientSsl(ClientHttpConnectorFactory clientHttpConnectorFactory, SslBundles sslBundles) { - this.clientHttpConnectorFactory = clientHttpConnectorFactory; + AutoConfiguredWebClientSsl(ClientHttpConnectorBuilder connectorBuilder, ClientHttpConnectorSettings settings, + SslBundles sslBundles) { + this.connectorBuilder = connectorBuilder; + this.settings = settings; this.sslBundles = sslBundles; } @@ -47,7 +53,8 @@ class AutoConfiguredWebClientSsl implements WebClientSsl { @Override public Consumer fromBundle(SslBundle bundle) { return (builder) -> { - ClientHttpConnector connector = this.clientHttpConnectorFactory.createClientHttpConnector(bundle); + ClientHttpConnectorSettings settings = this.settings.withSslBundle(bundle); + ClientHttpConnector connector = this.connectorBuilder.build(settings); builder.clientConnector(connector); }; } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java index 9b5aa365d1..94c481e6da 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,53 +16,64 @@ package org.springframework.boot.autoconfigure.web.reactive.function.client; +import java.util.List; + +import reactor.netty.http.client.HttpClient; + +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; -import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; +import org.springframework.boot.autoconfigure.http.client.reactive.ClientHttpConnectorBuilderCustomizer; +import org.springframework.boot.autoconfigure.reactor.netty.ReactorNettyConfigurations.ReactorResourceFactoryConfiguration; +import org.springframework.boot.http.client.reactive.ReactorClientHttpConnectorBuilder; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; -import org.springframework.context.annotation.Lazy; import org.springframework.core.annotation.Order; -import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.ReactorResourceFactory; import org.springframework.web.reactive.function.client.WebClient; /** - * {@link EnableAutoConfiguration Auto-configuration} for {@link ClientHttpConnector}. - *

- * It can produce a {@link org.springframework.http.client.reactive.ClientHttpConnector} - * bean and possibly a companion {@code ResourceFactory} bean, depending on the chosen - * HTTP client library. + * Deprecated {@link EnableAutoConfiguration Auto-configuration} for + * {@link ReactorNettyHttpClientMapper}. * * @author Brian Clozel * @author Phillip Webb * @since 2.1.0 + * @deprecated since 3.5.0 for removal in 3.7.0 in favor of + * {@link org.springframework.boot.autoconfigure.http.client.reactive.ClientHttpConnectorAutoConfiguration} + * and to align with the deprecation of {@link ReactorNettyHttpClientMapper} */ @AutoConfiguration @ConditionalOnClass(WebClient.class) -@AutoConfigureAfter(SslAutoConfiguration.class) -@Import({ ClientHttpConnectorFactoryConfiguration.ReactorNetty.class, - ClientHttpConnectorFactoryConfiguration.HttpClient5.class, - ClientHttpConnectorFactoryConfiguration.JdkClient.class }) +@Deprecated(since = "3.5.0", forRemoval = true) public class ClientHttpConnectorAutoConfiguration { - @Bean - @Lazy - @ConditionalOnMissingBean - ClientHttpConnector webClientHttpConnector(ClientHttpConnectorFactory clientHttpConnectorFactory) { - return clientHttpConnectorFactory.createClientHttpConnector(); - } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass(HttpClient.class) + @Import(ReactorResourceFactoryConfiguration.class) + @SuppressWarnings("removal") + static class ReactorNetty { + + @Bean + @Order(0) + ClientHttpConnectorBuilderCustomizer reactorNettyHttpClientMapperClientHttpConnectorBuilderCustomizer( + ReactorResourceFactory reactorResourceFactory, + ObjectProvider mapperProvider) { + return applyMappers(mapperProvider.orderedStream().toList()); + } + + private ClientHttpConnectorBuilderCustomizer applyMappers( + List mappers) { + return (builder) -> { + for (ReactorNettyHttpClientMapper mapper : mappers) { + builder = builder.withHttpClientCustomizer(mapper::configure); + } + return builder; + }; + } - @Bean - @Lazy - @Order(0) - @ConditionalOnBean(ClientHttpConnector.class) - public WebClientCustomizer webClientHttpConnectorCustomizer(ClientHttpConnector clientHttpConnector) { - return (builder) -> builder.clientConnector(clientHttpConnector); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java deleted file mode 100644 index d3f9aa1b0d..0000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfiguration.java +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright 2012-2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.boot.autoconfigure.web.reactive.function.client; - -import org.apache.hc.client5.http.impl.async.HttpAsyncClients; -import org.apache.hc.core5.http.nio.AsyncRequestProducer; -import org.apache.hc.core5.reactive.ReactiveResponseConsumer; - -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.reactor.netty.ReactorNettyConfigurations; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Import; -import org.springframework.http.client.ReactorResourceFactory; - -/** - * Configuration classes for WebClient client connectors. - *

- * Those should be {@code @Import} in a regular auto-configuration class to guarantee - * their order of execution. - * - * @author Brian Clozel - */ -class ClientHttpConnectorFactoryConfiguration { - - @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(reactor.netty.http.client.HttpClient.class) - @ConditionalOnMissingBean(ClientHttpConnectorFactory.class) - @Import(ReactorNettyConfigurations.ReactorResourceFactoryConfiguration.class) - static class ReactorNetty { - - @Bean - ReactorClientHttpConnectorFactory reactorClientHttpConnectorFactory( - ReactorResourceFactory reactorResourceFactory, - ObjectProvider mapperProvider) { - return new ReactorClientHttpConnectorFactory(reactorResourceFactory, mapperProvider::orderedStream); - } - - } - - @Configuration(proxyBeanMethods = false) - @ConditionalOnClass({ HttpAsyncClients.class, AsyncRequestProducer.class, ReactiveResponseConsumer.class }) - @ConditionalOnMissingBean(ClientHttpConnectorFactory.class) - static class HttpClient5 { - - @Bean - HttpComponentsClientHttpConnectorFactory httpComponentsClientHttpConnectorFactory() { - return new HttpComponentsClientHttpConnectorFactory(); - } - - } - - @Configuration(proxyBeanMethods = false) - @ConditionalOnClass(java.net.http.HttpClient.class) - @ConditionalOnMissingBean(ClientHttpConnectorFactory.class) - static class JdkClient { - - @Bean - JdkClientHttpConnectorFactory jdkClientHttpConnectorFactory() { - return new JdkClientHttpConnectorFactory(); - } - - } - -} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactory.java deleted file mode 100644 index cfae0d1659..0000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactory.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2012-2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.boot.autoconfigure.web.reactive.function.client; - -import javax.net.ssl.SSLContext; - -import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; -import org.apache.hc.client5.http.impl.async.HttpAsyncClients; -import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; -import org.apache.hc.client5.http.nio.AsyncClientConnectionManager; -import org.apache.hc.core5.http.nio.ssl.BasicClientTlsStrategy; -import org.apache.hc.core5.reactor.ssl.SSLSessionVerifier; - -import org.springframework.boot.ssl.SslBundle; -import org.springframework.boot.ssl.SslOptions; -import org.springframework.http.client.reactive.HttpComponentsClientHttpConnector; - -/** - * {@link ClientHttpConnectorFactory} for {@link HttpComponentsClientHttpConnector}. - * - * @author Phillip Webb - */ -class HttpComponentsClientHttpConnectorFactory - implements ClientHttpConnectorFactory { - - @Override - public HttpComponentsClientHttpConnector createClientHttpConnector(SslBundle sslBundle) { - HttpAsyncClientBuilder builder = HttpAsyncClients.custom().useSystemProperties(); - if (sslBundle != null) { - SslOptions options = sslBundle.getOptions(); - SSLContext sslContext = sslBundle.createSslContext(); - SSLSessionVerifier sessionVerifier = (endpoint, sslEngine) -> { - if (options.getCiphers() != null) { - sslEngine.setEnabledCipherSuites(options.getCiphers()); - } - if (options.getEnabledProtocols() != null) { - sslEngine.setEnabledProtocols(options.getEnabledProtocols()); - } - return null; - }; - BasicClientTlsStrategy tlsStrategy = new BasicClientTlsStrategy(sslContext, sessionVerifier); - AsyncClientConnectionManager connectionManager = PoolingAsyncClientConnectionManagerBuilder.create() - .setTlsStrategy(tlsStrategy) - .build(); - builder.setConnectionManager(connectionManager); - } - return new HttpComponentsClientHttpConnector(builder.build()); - } - -} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JdkClientHttpConnectorFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JdkClientHttpConnectorFactory.java deleted file mode 100644 index 695278f414..0000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/JdkClientHttpConnectorFactory.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2012-2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.boot.autoconfigure.web.reactive.function.client; - -import java.net.http.HttpClient; -import java.net.http.HttpClient.Builder; - -import javax.net.ssl.SSLParameters; - -import org.springframework.boot.ssl.SslBundle; -import org.springframework.boot.ssl.SslOptions; -import org.springframework.http.client.reactive.JdkClientHttpConnector; - -/** - * {@link ClientHttpConnectorFactory} for {@link JdkClientHttpConnector}. - * - * @author Phillip Webb - */ -class JdkClientHttpConnectorFactory implements ClientHttpConnectorFactory { - - @Override - public JdkClientHttpConnector createClientHttpConnector(SslBundle sslBundle) { - Builder builder = HttpClient.newBuilder(); - if (sslBundle != null) { - SslOptions options = sslBundle.getOptions(); - builder.sslContext(sslBundle.createSslContext()); - SSLParameters parameters = new SSLParameters(); - parameters.setCipherSuites(options.getCiphers()); - parameters.setProtocols(options.getEnabledProtocols()); - builder.sslParameters(parameters); - } - return new JdkClientHttpConnector(builder.build()); - } - -} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java deleted file mode 100644 index 3f596126ac..0000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactory.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2012-2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.boot.autoconfigure.web.reactive.function.client; - -import java.util.ArrayList; -import java.util.List; -import java.util.function.Supplier; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import javax.net.ssl.SSLException; - -import io.netty.handler.ssl.SslContextBuilder; -import reactor.netty.http.client.HttpClient; -import reactor.netty.tcp.SslProvider.SslContextSpec; - -import org.springframework.boot.ssl.SslBundle; -import org.springframework.boot.ssl.SslManagerBundle; -import org.springframework.boot.ssl.SslOptions; -import org.springframework.http.client.ReactorResourceFactory; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.util.function.ThrowingConsumer; - -/** - * {@link ClientHttpConnectorFactory} for {@link ReactorClientHttpConnector}. - * - * @author Phillip Webb - * @author Fernando Cappi - */ -class ReactorClientHttpConnectorFactory implements ClientHttpConnectorFactory { - - private final ReactorResourceFactory reactorResourceFactory; - - private final Supplier> mappers; - - ReactorClientHttpConnectorFactory(ReactorResourceFactory reactorResourceFactory) { - this(reactorResourceFactory, Stream::empty); - } - - ReactorClientHttpConnectorFactory(ReactorResourceFactory reactorResourceFactory, - Supplier> mappers) { - this.reactorResourceFactory = reactorResourceFactory; - this.mappers = mappers; - } - - @Override - public ReactorClientHttpConnector createClientHttpConnector(SslBundle sslBundle) { - List mappers = this.mappers.get() - .collect(Collectors.toCollection(ArrayList::new)); - if (sslBundle != null) { - mappers.add(new SslConfigurer(sslBundle)); - } - return new ReactorClientHttpConnector(this.reactorResourceFactory, - ReactorNettyHttpClientMapper.of(mappers)::configure); - } - - /** - * Configures the Netty {@link HttpClient} with SSL. - */ - private static class SslConfigurer implements ReactorNettyHttpClientMapper { - - private final SslBundle sslBundle; - - SslConfigurer(SslBundle sslBundle) { - this.sslBundle = sslBundle; - } - - @Override - public HttpClient configure(HttpClient httpClient) { - return httpClient.secure(ThrowingConsumer.of(this::customizeSsl).throwing(IllegalStateException::new)); - } - - private void customizeSsl(SslContextSpec spec) throws SSLException { - SslOptions options = this.sslBundle.getOptions(); - SslManagerBundle managers = this.sslBundle.getManagers(); - SslContextBuilder builder = SslContextBuilder.forClient() - .keyManager(managers.getKeyManagerFactory()) - .trustManager(managers.getTrustManagerFactory()) - .ciphers(SslOptions.asSet(options.getCiphers())) - .protocols(options.getEnabledProtocols()); - spec.sslContext(builder.build()); - } - - } - -} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorNettyHttpClientMapper.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorNettyHttpClientMapper.java index 0134ed8976..adb1f7f5d7 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorNettyHttpClientMapper.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorNettyHttpClientMapper.java @@ -20,6 +20,8 @@ import java.util.Collection; import reactor.netty.http.client.HttpClient; +import org.springframework.boot.autoconfigure.http.client.reactive.ClientHttpConnectorBuilderCustomizer; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.util.Assert; @@ -30,8 +32,12 @@ import org.springframework.util.Assert; * @author Brian Clozel * @author Phillip Webb * @since 2.3.0 + * @deprecated since 3.5.0 for removal in 3.7.0 in favor of + * {@link ClientHttpConnectorBuilderCustomizer} or declaring a pre-configured + * {@link ClientHttpConnectorBuilder} bean */ @FunctionalInterface +@Deprecated(since = "3.5.0", forRemoval = true) public interface ReactorNettyHttpClientMapper { /** diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfiguration.java index b0d86b1455..6593c50f78 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,14 +23,19 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.http.client.reactive.ClientHttpConnectorAutoConfiguration; import org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings; import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.web.codec.CodecCustomizer; import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Scope; import org.springframework.core.annotation.Order; +import org.springframework.http.client.reactive.ClientHttpConnector; import org.springframework.web.reactive.function.client.WebClient; /** @@ -42,6 +47,7 @@ import org.springframework.web.reactive.function.client.WebClient; * will receive a newly cloned instance of the builder. * * @author Brian Clozel + * @author Phillip Webb * @since 2.0.0 */ @AutoConfiguration(after = { CodecsAutoConfiguration.class, ClientHttpConnectorAutoConfiguration.class }) @@ -57,12 +63,20 @@ public class WebClientAutoConfiguration { return builder; } + @Bean + @Lazy + @Order(0) + @ConditionalOnBean(ClientHttpConnector.class) + public WebClientCustomizer webClientHttpConnectorCustomizer(ClientHttpConnector clientHttpConnector) { + return (builder) -> builder.clientConnector(clientHttpConnector); + } + @Bean @ConditionalOnMissingBean(WebClientSsl.class) @ConditionalOnBean(SslBundles.class) - AutoConfiguredWebClientSsl webClientSsl(ClientHttpConnectorFactory clientHttpConnectorFactory, - SslBundles sslBundles) { - return new AutoConfiguredWebClientSsl(clientHttpConnectorFactory, sslBundles); + AutoConfiguredWebClientSsl webClientSsl(ClientHttpConnectorBuilder clientHttpConnectorBuilder, + ClientHttpConnectorSettings clientHttpConnectorSettings, SslBundles sslBundles) { + return new AutoConfiguredWebClientSsl(clientHttpConnectorBuilder, clientHttpConnectorSettings, sslBundles); } @Configuration(proxyBeanMethods = false) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 05740b676a..8325b51ba0 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -63,6 +63,7 @@ org.springframework.boot.autoconfigure.hazelcast.HazelcastAutoConfiguration org.springframework.boot.autoconfigure.hazelcast.HazelcastJpaDependencyAutoConfiguration org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration org.springframework.boot.autoconfigure.http.client.HttpClientAutoConfiguration +org.springframework.boot.autoconfigure.http.client.reactive.ClientHttpConnectorAutoConfiguration org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration org.springframework.boot.autoconfigure.integration.IntegrationAutoConfiguration diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/HttpClientPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/HttpClientPropertiesTests.java index 16d66577ab..43b4b7c9ec 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/HttpClientPropertiesTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/HttpClientPropertiesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,7 +19,7 @@ package org.springframework.boot.autoconfigure.http.client; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.springframework.boot.autoconfigure.http.client.HttpClientProperties.Factory; +import org.springframework.boot.autoconfigure.http.client.AbstractHttpRequestFactoryProperties.Factory; import org.springframework.boot.http.client.HttpComponentsClientHttpRequestFactoryBuilder; import org.springframework.boot.http.client.JdkClientHttpRequestFactoryBuilder; import org.springframework.boot.http.client.JettyClientHttpRequestFactoryBuilder; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorAutoConfigurationTests.java new file mode 100644 index 0000000000..efe0024068 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorAutoConfigurationTests.java @@ -0,0 +1,204 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.http.client.reactive; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.apache.hc.client5.http.impl.async.HttpAsyncClients; +import org.junit.jupiter.api.Test; +import reactor.netty.http.client.HttpClient; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration; +import org.springframework.boot.http.client.HttpRedirects; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; +import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings; +import org.springframework.boot.http.client.reactive.JdkClientHttpConnectorBuilder; +import org.springframework.boot.http.client.reactive.JettyClientHttpConnectorBuilder; +import org.springframework.boot.http.client.reactive.ReactorClientHttpConnectorBuilder; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ReactorResourceFactory; +import org.springframework.http.client.reactive.ClientHttpConnector; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ClientHttpConnectorAutoConfiguration} + * + * @author Brian Clozel + * @author Phillip Webb + */ +class ClientHttpConnectorAutoConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration( + AutoConfigurations.of(ClientHttpConnectorAutoConfiguration.class, SslAutoConfiguration.class)); + + @Test + void whenReactorIsAvailableThenReactorBeansAreDefined() { + this.contextRunner.run((context) -> { + BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("clientHttpConnector"); + assertThat(connectorDefinition.isLazyInit()).isTrue(); + assertThat(context).hasSingleBean(ReactorResourceFactory.class); + assertThat(context.getBean(ClientHttpConnectorBuilder.class)) + .isExactlyInstanceOf(ReactorClientHttpConnectorBuilder.class); + }); + } + + @Test + void whenReactorIsUnavailableThenJettyClientBeansAreDefined() { + this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class)).run((context) -> { + BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("clientHttpConnector"); + assertThat(connectorDefinition.isLazyInit()).isTrue(); + assertThat(context.getBean(ClientHttpConnectorBuilder.class)) + .isExactlyInstanceOf(JettyClientHttpConnectorBuilder.class); + }); + } + + @Test + void whenReactorAndHttpClientAreUnavailableThenJettyClientBeansAreDefined() { + this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class, HttpAsyncClients.class)) + .run((context) -> { + BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("clientHttpConnector"); + assertThat(connectorDefinition.isLazyInit()).isTrue(); + assertThat(context.getBean(ClientHttpConnectorBuilder.class)) + .isExactlyInstanceOf(JettyClientHttpConnectorBuilder.class); + }); + } + + @Test + void whenReactorAndHttpClientAndJettyAreUnavailableThenJdkClientBeansAreDefined() { + this.contextRunner + .withClassLoader(new FilteredClassLoader(HttpClient.class, HttpAsyncClients.class, + org.eclipse.jetty.client.HttpClient.class)) + .run((context) -> { + BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("clientHttpConnector"); + assertThat(connectorDefinition.isLazyInit()).isTrue(); + assertThat(context.getBean(ClientHttpConnectorBuilder.class)) + .isExactlyInstanceOf(JdkClientHttpConnectorBuilder.class); + }); + } + + @Test + void shouldNotOverrideCustomClientConnector() { + this.contextRunner.withUserConfiguration(CustomClientHttpConnectorConfig.class).run((context) -> { + assertThat(context).hasSingleBean(ClientHttpConnector.class); + assertThat(context).hasBean("customConnector"); + }); + } + + @Test + void shouldUseCustomReactorResourceFactory() { + this.contextRunner.withUserConfiguration(CustomReactorResourceConfig.class).run((context) -> { + assertThat(context).hasSingleBean(ClientHttpConnector.class); + assertThat(context).hasSingleBean(ReactorResourceFactory.class); + assertThat(context).hasBean("customReactorResourceFactory"); + }); + } + + @Test + void configuresDetectedClientHttpConnectorBuilderBuilder() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ClientHttpConnectorBuilder.class)); + } + + @Test + void configuresDefinedClientHttpConnectorBuilder() { + this.contextRunner.withPropertyValues("spring.http.reactiveclient.settings.connector=jetty") + .run((context) -> assertThat(context.getBean(ClientHttpConnectorBuilder.class)) + .isInstanceOf(JettyClientHttpConnectorBuilder.class)); + } + + @Test + void configuresClientHttpConnectorSettings() { + this.contextRunner.withPropertyValues(sslPropertyValues().toArray(String[]::new)) + .withPropertyValues("spring.http.reactiveclient.settings.redirects=dont-follow", + "spring.http.reactiveclient.settings.connect-timeout=10s", + "spring.http.reactiveclient.settings.read-timeout=20s", + "spring.http.reactiveclient.settings.ssl.bundle=test") + .run((context) -> { + ClientHttpConnectorSettings settings = context.getBean(ClientHttpConnectorSettings.class); + assertThat(settings.redirects()).isEqualTo(HttpRedirects.DONT_FOLLOW); + assertThat(settings.connectTimeout()).isEqualTo(Duration.ofSeconds(10)); + assertThat(settings.readTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(settings.sslBundle().getKey().getAlias()).isEqualTo("alias1"); + }); + } + + private List sslPropertyValues() { + List propertyValues = new ArrayList<>(); + String location = "classpath:org/springframework/boot/autoconfigure/ssl/"; + propertyValues.add("spring.ssl.bundle.pem.test.key.alias=alias1"); + propertyValues.add("spring.ssl.bundle.pem.test.truststore.type=PKCS12"); + propertyValues.add("spring.ssl.bundle.pem.test.truststore.certificate=" + location + "rsa-cert.pem"); + propertyValues.add("spring.ssl.bundle.pem.test.truststore.private-key=" + location + "rsa-key.pem"); + return propertyValues; + } + + @Test + void clientHttpConnectorBuilderCustomizersAreApplied() { + this.contextRunner.withPropertyValues("spring.http.reactiveclient.settings.connector=jdk") + .withUserConfiguration(ClientHttpConnectorBuilderCustomizersConfiguration.class) + .run((context) -> { + ClientHttpConnector connector = context.getBean(ClientHttpConnectorBuilder.class).build(); + assertThat(connector).extracting("readTimeout").isEqualTo(Duration.ofSeconds(5)); + }); + } + + @Configuration(proxyBeanMethods = false) + static class CustomClientHttpConnectorConfig { + + @Bean + ClientHttpConnector customConnector() { + return mock(ClientHttpConnector.class); + } + + } + + @Configuration(proxyBeanMethods = false) + static class CustomReactorResourceConfig { + + @Bean + ReactorResourceFactory customReactorResourceFactory() { + return new ReactorResourceFactory(); + } + + } + + @Configuration(proxyBeanMethods = false) + static class ClientHttpConnectorBuilderCustomizersConfiguration { + + @Bean + ClientHttpConnectorBuilderCustomizer jdkCustomizer() { + return (builder) -> builder.withCustomizer((connector) -> connector.setReadTimeout(Duration.ofSeconds(5))); + } + + @Bean + ClientHttpConnectorBuilderCustomizer jettyCustomizer() { + return (builder) -> { + throw new IllegalStateException(); + }; + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/HttpReactiveClientSettingsPropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/HttpReactiveClientSettingsPropertiesTests.java new file mode 100644 index 0000000000..538965262c --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/HttpReactiveClientSettingsPropertiesTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.http.client.reactive; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.http.client.reactive.AbstractClientHttpConnectorProperties.Connector; +import org.springframework.boot.http.client.reactive.HttpComponentsClientHttpConnectorBuilder; +import org.springframework.boot.http.client.reactive.JdkClientHttpConnectorBuilder; +import org.springframework.boot.http.client.reactive.JettyClientHttpConnectorBuilder; +import org.springframework.boot.http.client.reactive.ReactorClientHttpConnectorBuilder; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link HttpReactiveClientSettingsProperties}. + * + * @author Phillip Webb + */ +class HttpReactiveClientSettingsPropertiesTests { + + @Nested + class ConnectorTests { + + @Test + void reactorBuilder() { + assertThat(Connector.REACTOR.builder()).isInstanceOf(ReactorClientHttpConnectorBuilder.class); + } + + @Test + void jettyBuilder() { + assertThat(Connector.JETTY.builder()).isInstanceOf(JettyClientHttpConnectorBuilder.class); + } + + @Test + void httpComponentsBuilder() { + assertThat(Connector.HTTP_COMPONENTS.builder()) + .isInstanceOf(HttpComponentsClientHttpConnectorBuilder.class); + } + + @Test + void jdkBuilder() { + assertThat(Connector.JDK.builder()).isInstanceOf(JdkClientHttpConnectorBuilder.class); + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AbstractClientHttpConnectorFactoryTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AbstractClientHttpConnectorFactoryTests.java deleted file mode 100644 index 44f573b737..0000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/AbstractClientHttpConnectorFactoryTests.java +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2012-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.boot.autoconfigure.web.reactive.function.client; - -import org.junit.jupiter.api.Test; - -import org.springframework.boot.ssl.SslBundle; -import org.springframework.boot.ssl.SslBundleKey; -import org.springframework.boot.ssl.jks.JksSslStoreBundle; -import org.springframework.boot.ssl.jks.JksSslStoreDetails; -import org.springframework.boot.testsupport.classpath.resources.WithPackageResources; -import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; -import org.springframework.boot.web.server.Ssl; -import org.springframework.boot.web.server.Ssl.ClientAuth; -import org.springframework.boot.web.server.WebServer; -import org.springframework.web.reactive.function.client.WebClient; -import org.springframework.web.reactive.function.client.WebClientRequestException; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; - -/** - * Abstract base class for {@link ClientHttpConnectorFactory} tests. - * - * @author Phillip Webb - */ -abstract class AbstractClientHttpConnectorFactoryTests { - - @Test - void insecureConnection() { - TomcatServletWebServerFactory webServerFactory = new TomcatServletWebServerFactory(0); - WebServer webServer = webServerFactory.getWebServer(); - try { - webServer.start(); - int port = webServer.getPort(); - String url = "http://localhost:%s".formatted(port); - WebClient insecureWebClient = WebClient.builder() - .clientConnector(getFactory().createClientHttpConnector()) - .build(); - String insecureBody = insecureWebClient.get() - .uri(url) - .exchangeToMono((response) -> response.bodyToMono(String.class)) - .block(); - assertThat(insecureBody).contains("HTTP Status 404 – Not Found"); - } - finally { - webServer.stop(); - } - } - - @Test - @WithPackageResources("test.jks") - void secureConnection() throws Exception { - TomcatServletWebServerFactory webServerFactory = new TomcatServletWebServerFactory(0); - Ssl ssl = new Ssl(); - ssl.setClientAuth(ClientAuth.NEED); - ssl.setKeyPassword("password"); - ssl.setKeyStore("classpath:test.jks"); - ssl.setTrustStore("classpath:test.jks"); - webServerFactory.setSsl(ssl); - WebServer webServer = webServerFactory.getWebServer(); - try { - webServer.start(); - int port = webServer.getPort(); - String url = "https://localhost:%s".formatted(port); - WebClient insecureWebClient = WebClient.builder() - .clientConnector(getFactory().createClientHttpConnector()) - .build(); - assertThatExceptionOfType(WebClientRequestException.class).isThrownBy(() -> insecureWebClient.get() - .uri(url) - .exchangeToMono((response) -> response.bodyToMono(String.class)) - .block()); - JksSslStoreDetails storeDetails = JksSslStoreDetails.forLocation("classpath:test.jks"); - JksSslStoreBundle stores = new JksSslStoreBundle(storeDetails, storeDetails); - SslBundle sslBundle = SslBundle.of(stores, SslBundleKey.of("password")); - WebClient secureWebClient = WebClient.builder() - .clientConnector(getFactory().createClientHttpConnector(sslBundle)) - .build(); - String secureBody = secureWebClient.get() - .uri(url) - .exchangeToMono((response) -> response.bodyToMono(String.class)) - .block(); - assertThat(secureBody).contains("HTTP Status 404 – Not Found"); - } - finally { - webServer.stop(); - } - } - - protected abstract ClientHttpConnectorFactory getFactory(); - -} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java index 9c0d2ee1f2..c98f1ba325 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,146 +16,45 @@ package org.springframework.boot.autoconfigure.web.reactive.function.client; -import org.apache.hc.client5.http.impl.async.HttpAsyncClients; import org.junit.jupiter.api.Test; -import reactor.netty.http.client.HttpClient; -import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.test.context.FilteredClassLoader; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.ReactorResourceFactory; import org.springframework.http.client.reactive.ClientHttpConnector; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.web.reactive.function.client.WebClient; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.mock; /** * Tests for {@link ClientHttpConnectorAutoConfiguration} * * @author Brian Clozel */ +@SuppressWarnings("removal") class ClientHttpConnectorAutoConfigurationTests { - private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(ClientHttpConnectorAutoConfiguration.class)); - @Test - void whenReactorIsAvailableThenReactorBeansAreDefined() { - this.contextRunner.run((context) -> { - BeanDefinition customizerDefinition = context.getBeanFactory() - .getBeanDefinition("webClientHttpConnectorCustomizer"); - assertThat(customizerDefinition.isLazyInit()).isTrue(); - BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("webClientHttpConnector"); - assertThat(connectorDefinition.isLazyInit()).isTrue(); - assertThat(context).hasBean("reactorClientHttpConnectorFactory"); - assertThat(context).hasSingleBean(ReactorResourceFactory.class); - }); - } - - @Test - void whenReactorIsUnavailableThenHttpClientBeansAreDefined() { - this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class)).run((context) -> { - BeanDefinition customizerDefinition = context.getBeanFactory() - .getBeanDefinition("webClientHttpConnectorCustomizer"); - assertThat(customizerDefinition.isLazyInit()).isTrue(); - BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("webClientHttpConnector"); - assertThat(connectorDefinition.isLazyInit()).isTrue(); - assertThat(context).hasBean("httpComponentsClientHttpConnectorFactory"); - }); - } - - @Test - void whenReactorAndHttpClientBeansAreUnavailableThenJdkClientBeansAreDefined() { - this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class, HttpAsyncClients.class)) + void shouldApplyReactorNettyHttpClientMapper() { + new ReactiveWebApplicationContextRunner().withConfiguration(AutoConfigurations.of( + ClientHttpConnectorAutoConfiguration.class, + org.springframework.boot.autoconfigure.http.client.reactive.ClientHttpConnectorAutoConfiguration.class)) + .withUserConfiguration(CustomReactorNettyHttpClientMapper.class) .run((context) -> { - BeanDefinition customizerDefinition = context.getBeanFactory() - .getBeanDefinition("webClientHttpConnectorCustomizer"); - assertThat(customizerDefinition.isLazyInit()).isTrue(); - BeanDefinition connectorDefinition = context.getBeanFactory() - .getBeanDefinition("webClientHttpConnector"); - assertThat(connectorDefinition.isLazyInit()).isTrue(); - assertThat(context).hasBean("jdkClientHttpConnectorFactory"); + context.getBean(ClientHttpConnector.class); + assertThat(CustomReactorNettyHttpClientMapper.called).isTrue(); }); } - @Test - void shouldCreateHttpClientBeans() { - this.contextRunner.run((context) -> { - assertThat(context).hasSingleBean(ReactorResourceFactory.class); - assertThat(context).hasSingleBean(ClientHttpConnector.class); - WebClientCustomizer clientCustomizer = context.getBean(WebClientCustomizer.class); - WebClient.Builder builder = mock(WebClient.Builder.class); - clientCustomizer.customize(builder); - then(builder).should().clientConnector(any(ReactorClientHttpConnector.class)); - }); - } + static class CustomReactorNettyHttpClientMapper { - @Test - void shouldNotOverrideCustomClientConnector() { - this.contextRunner.withUserConfiguration(CustomClientHttpConnectorConfig.class).run((context) -> { - assertThat(context).hasSingleBean(ClientHttpConnector.class).hasBean("customConnector"); - WebClientCustomizer clientCustomizer = context.getBean(WebClientCustomizer.class); - WebClient.Builder builder = mock(WebClient.Builder.class); - clientCustomizer.customize(builder); - then(builder).should().clientConnector(any(ClientHttpConnector.class)); - }); - } - - @Test - void shouldNotOverrideCustomClientConnectorFactory() { - this.contextRunner.withUserConfiguration(CustomClientHttpConnectorFactoryConfig.class).run((context) -> { - assertThat(context).hasSingleBean(ClientHttpConnectorFactory.class) - .hasBean("customConnector") - .doesNotHaveBean(ReactorResourceFactory.class); - WebClientCustomizer clientCustomizer = context.getBean(WebClientCustomizer.class); - WebClient.Builder builder = mock(WebClient.Builder.class); - clientCustomizer.customize(builder); - then(builder).should().clientConnector(any(ClientHttpConnector.class)); - }); - } - - @Test - void shouldUseCustomReactorResourceFactory() { - this.contextRunner.withUserConfiguration(CustomReactorResourceConfig.class) - .run((context) -> assertThat(context).hasSingleBean(ClientHttpConnector.class) - .hasSingleBean(ReactorResourceFactory.class) - .hasBean("customReactorResourceFactory")); - } - - @Configuration(proxyBeanMethods = false) - static class CustomClientHttpConnectorConfig { + static boolean called = false; @Bean - ClientHttpConnector customConnector() { - return mock(ClientHttpConnector.class); - } - - } - - @Configuration(proxyBeanMethods = false) - static class CustomClientHttpConnectorFactoryConfig { - - @Bean - ClientHttpConnectorFactory customConnector() { - return (sslBundle) -> mock(ClientHttpConnector.class); - } - - } - - @Configuration(proxyBeanMethods = false) - static class CustomReactorResourceConfig { - - @Bean - ReactorResourceFactory customReactorResourceFactory() { - return new ReactorResourceFactory(); + ReactorNettyHttpClientMapper clientMapper() { + return (client) -> { + called = true; + return client.baseUrl("/test"); + }; } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java deleted file mode 100644 index 78b02ef97d..0000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactoryConfigurationTests.java +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright 2012-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.boot.autoconfigure.web.reactive.function.client; - -import org.junit.jupiter.api.Test; - -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.ssl.SslBundle; -import org.springframework.boot.ssl.SslBundleKey; -import org.springframework.boot.ssl.jks.JksSslStoreBundle; -import org.springframework.boot.ssl.jks.JksSslStoreDetails; -import org.springframework.boot.test.context.FilteredClassLoader; -import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; -import org.springframework.boot.testsupport.classpath.resources.WithPackageResources; -import org.springframework.context.annotation.Bean; -import org.springframework.http.client.reactive.HttpComponentsClientHttpConnector; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.spy; - -/** - * Tests for {@link ClientHttpConnectorFactoryConfiguration}. - * - * @author Phillip Webb - * @author Brian Clozel - * @author Moritz Halbritter - */ -class ClientHttpConnectorFactoryConfigurationTests { - - @Test - @WithPackageResources("test.jks") - void shouldApplyHttpClientMapper() { - JksSslStoreDetails storeDetails = JksSslStoreDetails.forLocation("classpath:test.jks"); - JksSslStoreBundle stores = new JksSslStoreBundle(storeDetails, storeDetails); - SslBundle sslBundle = spy(SslBundle.of(stores, SslBundleKey.of("password"))); - new ReactiveWebApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(ClientHttpConnectorFactoryConfiguration.ReactorNetty.class)) - .withUserConfiguration(CustomHttpClientMapper.class) - .run((context) -> { - context.getBean(ReactorClientHttpConnectorFactory.class).createClientHttpConnector(sslBundle); - assertThat(CustomHttpClientMapper.called).isTrue(); - then(sslBundle).should().getManagers(); - }); - } - - @Test - void shouldNotConfigureReactiveHttpClient5WhenHttpCore5ReactiveJarIsMissing() { - new ReactiveWebApplicationContextRunner() - .withClassLoader(new FilteredClassLoader("org.apache.hc.core5.reactive")) - .withConfiguration(AutoConfigurations.of(ClientHttpConnectorFactoryConfiguration.HttpClient5.class)) - .run((context) -> assertThat(context).doesNotHaveBean(HttpComponentsClientHttpConnector.class)); - } - - static class CustomHttpClientMapper { - - static boolean called = false; - - @Bean - ReactorNettyHttpClientMapper clientMapper() { - return (client) -> { - called = true; - return client.baseUrl("/test"); - }; - } - - } - -} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java deleted file mode 100644 index 951941d024..0000000000 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorClientHttpConnectorFactoryTests.java +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright 2012-2023 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.springframework.boot.autoconfigure.web.reactive.function.client; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; - -import org.springframework.http.client.ReactorResourceFactory; - -/** - * Tests for {@link ReactorClientHttpConnectorFactory}. - * - * @author Phillip Webb - */ -class ReactorClientHttpConnectorFactoryTests extends AbstractClientHttpConnectorFactoryTests { - - private ReactorResourceFactory resourceFactory; - - @BeforeEach - void setup() { - this.resourceFactory = new ReactorResourceFactory(); - this.resourceFactory.afterPropertiesSet(); - } - - @AfterEach - void teardown() { - this.resourceFactory.destroy(); - } - - @Override - protected ClientHttpConnectorFactory getFactory() { - return new ReactorClientHttpConnectorFactory(this.resourceFactory); - } - -} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorNettyHttpClientMapperTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorNettyHttpClientMapperTests.java index ff13a15f22..81c84bcc2e 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorNettyHttpClientMapperTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ReactorNettyHttpClientMapperTests.java @@ -31,6 +31,7 @@ import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException * * @author Phillip Webb */ +@SuppressWarnings("removal") class ReactorNettyHttpClientMapperTests { @Test diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfigurationTests.java index 853b1fb6ef..371565348b 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/WebClientAutoConfigurationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,7 +41,8 @@ import static org.mockito.Mockito.mock; class WebClientAutoConfigurationTests { private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(ClientHttpConnectorAutoConfiguration.class, + .withConfiguration(AutoConfigurations.of( + org.springframework.boot.autoconfigure.http.client.reactive.ClientHttpConnectorAutoConfiguration.class, WebClientAutoConfiguration.class, SslAutoConfiguration.class)); @Test diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/reactive/function/client/test.jks b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/org/springframework/boot/autoconfigure/web/reactive/function/client/test.jks deleted file mode 100644 index 0fc3e802f75461dd074facb9611d350db4d5960f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1276 zcmezO_TO6u1_mZ5W@O+hNi8nXP0YzmEM{O}OjTL=XFE`?-k{cikBv*4jgf^>i%F1? zk(GfZ`?F{4vBFug6<%MmmXJ+)mEgQoslXV6!5_H^yana6V1?kw1w zbE0Bi&GHlLHzfosgjrwL)qKcc5I;l0LF?W2lpQg%-cHp$l(#o)?Jkath1@gQN{hG8 zig@wKv#0R7vd_QC=JG%%Ffy=4=$RT=0v*d`(8R=M(8RcU0W%XL6BCP-)w&Y~JZv0V zZ64=rS(uqv84M~6g$xAPm_u3EggJBalM{0?@{3DgVjNh+*s+LlVG-lTBF2m)W*{fd zYiMC$VQ64zW@K(?5e4L0B5?=MWswHLZ0z7LVq$~_7BeF|vl9agPmO-znfkD()@R+bGrT$(AXmN24#zv*XRX z#$UNu(Lmln78u;Jd@N!tBKmU@J0!OJc3G%!N>OO@P1n+F-CorAVRmOQaA8six!iWP z)M3lXpnJ*TI=kIlH(Yxia-ls?xvctEx&P5B6()tKm`>%bo~@fX9{j%TtMU1G!|pw& zZ6BRjIqQ^`bIxR@OmMno&8^H%tpq36Esh&T(+MJ_la+#pV>+3scS% clientHttpConnectorBuilder(ProxySelector proxySelector) { + return ClientHttpConnectorBuilder.jdk().withHttpClientCustomizer((builder) -> builder.proxy(proxySelector)); + } + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/webclient/configuration/MyConnectorHttpConfiguration.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/webclient/configuration/MyConnectorHttpConfiguration.kt new file mode 100644 index 0000000000..0fa3385a24 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/io/restclient/webclient/configuration/MyConnectorHttpConfiguration.kt @@ -0,0 +1,17 @@ +package org.springframework.boot.docs.io.restclient.clienthttprequestfactory.configuration + +import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder; +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.net.ProxySelector +import java.net.http.HttpClient + +@Configuration(proxyBeanMethods = false) +class MyConnectorHttpConfiguration { + + @Bean + fun clientHttpConnectorBuilder(proxySelector: ProxySelector): ClientHttpConnectorBuilder<*> { + return ClientHttpConnectorBuilder.jdk().withHttpClientCustomizer { builder -> builder.proxy(proxySelector) } + } + +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot/build.gradle b/spring-boot-project/spring-boot/build.gradle index 5f475f5ab3..fb8fff7b9f 100644 --- a/spring-boot-project/spring-boot/build.gradle +++ b/spring-boot-project/spring-boot/build.gradle @@ -48,6 +48,7 @@ dependencies { exclude(group: "commons-logging", module: "commons-logging") } optional("org.apache.httpcomponents.client5:httpclient5") + optional("org.apache.httpcomponents.core5:httpcore5-reactive") optional("org.apache.logging.log4j:log4j-api") optional("org.apache.logging.log4j:log4j-core") optional("org.apache.logging.log4j:log4j-jul") @@ -60,6 +61,7 @@ dependencies { optional("org.crac:crac") optional("org.eclipse.jetty:jetty-alpn-conscrypt-server") optional("org.eclipse.jetty:jetty-client") + optional("org.eclipse.jetty:jetty-reactive-httpclient") optional("org.eclipse.jetty:jetty-util") optional("org.eclipse.jetty.ee10:jetty-ee10-servlets") optional("org.eclipse.jetty.ee10:jetty-ee10-webapp") diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/AbstractClientHttpRequestFactoryBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/AbstractClientHttpRequestFactoryBuilder.java index 5c82a10e95..1f68276c91 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/AbstractClientHttpRequestFactoryBuilder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/AbstractClientHttpRequestFactoryBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,20 +35,12 @@ import org.springframework.util.Assert; abstract class AbstractClientHttpRequestFactoryBuilder implements ClientHttpRequestFactoryBuilder { - private static final Consumer EMPTY_CUSTOMIZER = (t) -> { - }; - private final List> customizers; protected AbstractClientHttpRequestFactoryBuilder(List> customizers) { this.customizers = (customizers != null) ? customizers : Collections.emptyList(); } - @SuppressWarnings("unchecked") - protected static Consumer emptyCustomizer() { - return (Consumer) EMPTY_CUSTOMIZER; - } - protected final List> getCustomizers() { return this.customizers; } @@ -81,4 +73,9 @@ abstract class AbstractClientHttpRequestFactoryBuilder getFactory() { - return new JdkClientHttpConnectorFactory(); + private static final Consumer EMPTY_CUSTOMIZER = (t) -> { + }; + + private Empty() { + } + + @SuppressWarnings("unchecked") + static Consumer consumer() { + return (Consumer) EMPTY_CUSTOMIZER; } } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpClientSettings.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpClientSettings.java new file mode 100644 index 0000000000..ee2b1e4a32 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpClientSettings.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.time.Duration; + +import org.springframework.boot.ssl.SslBundle; + +/** + * Settings that can be applied when creating a blocking or reactive HTTP client. + * + * @param redirects the follow redirect strategy to use or null to redirect whenever the + * underlying library allows it + * @param connectTimeout the connect timeout + * @param readTimeout the read timeout + * @param sslBundle the SSL bundle providing SSL configuration + * @author Phillip Webb + * @since 3.5.0 + */ +public record HttpClientSettings(HttpRedirects redirects, Duration connectTimeout, Duration readTimeout, + SslBundle sslBundle) { + + static final HttpClientSettings DEFAULTS = new HttpClientSettings(null, null, null, null); + + public HttpClientSettings { + redirects = (redirects != null) ? redirects : HttpRedirects.FOLLOW_WHEN_POSSIBLE; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilder.java index ff8562ede0..5549f57a8c 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,34 +16,21 @@ package org.springframework.boot.http.client; -import java.net.URI; import java.time.Duration; import java.util.Collection; -import java.util.Collections; import java.util.List; -import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.function.Function; import org.apache.hc.client5.http.classic.HttpClient; import org.apache.hc.client5.http.config.RequestConfig; -import org.apache.hc.client5.http.impl.DefaultRedirectStrategy; import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; -import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; -import org.apache.hc.client5.http.protocol.RedirectStrategy; -import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; -import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier; import org.apache.hc.client5.http.ssl.TlsSocketStrategy; -import org.apache.hc.core5.http.HttpRequest; -import org.apache.hc.core5.http.HttpResponse; import org.apache.hc.core5.http.io.SocketConfig; -import org.apache.hc.core5.http.protocol.HttpContext; import org.springframework.boot.context.properties.PropertyMapper; -import org.springframework.boot.http.client.ClientHttpRequestFactorySettings.Redirects; import org.springframework.boot.ssl.SslBundle; -import org.springframework.boot.ssl.SslOptions; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -59,56 +46,30 @@ import org.springframework.util.ClassUtils; public final class HttpComponentsClientHttpRequestFactoryBuilder extends AbstractClientHttpRequestFactoryBuilder { - private final Consumer httpClientCustomizer; - - private final Consumer connectionManagerCustomizer; - - private final Consumer socketConfigCustomizer; - - private final Consumer defaultRequestConfigCustomizer; - - private final Function tlsSocketStrategyFactory; + private final HttpComponentsHttpClientBuilder httpClientBuilder; HttpComponentsClientHttpRequestFactoryBuilder() { - this(Collections.emptyList(), emptyCustomizer(), emptyCustomizer(), emptyCustomizer(), emptyCustomizer(), - HttpComponentsClientHttpRequestFactoryBuilder::createTlsSocketStrategy); - } - - private static TlsSocketStrategy createTlsSocketStrategy(SslBundle sslBundle) { - SslOptions options = sslBundle.getOptions(); - return new DefaultClientTlsStrategy(sslBundle.createSslContext(), options.getEnabledProtocols(), - options.getCiphers(), null, new DefaultHostnameVerifier()); + this(null, new HttpComponentsHttpClientBuilder()); } private HttpComponentsClientHttpRequestFactoryBuilder( List> customizers, - Consumer httpClientCustomizer, - Consumer connectionManagerCustomizer, - Consumer socketConfigCustomizer, - Consumer defaultRequestConfigCustomizer, - Function tlsSocketStrategyFactory) { + HttpComponentsHttpClientBuilder httpClientBuilder) { super(customizers); - this.httpClientCustomizer = httpClientCustomizer; - this.connectionManagerCustomizer = connectionManagerCustomizer; - this.socketConfigCustomizer = socketConfigCustomizer; - this.defaultRequestConfigCustomizer = defaultRequestConfigCustomizer; - this.tlsSocketStrategyFactory = tlsSocketStrategyFactory; + this.httpClientBuilder = httpClientBuilder; } @Override public HttpComponentsClientHttpRequestFactoryBuilder withCustomizer( Consumer customizer) { - return new HttpComponentsClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), - this.httpClientCustomizer, this.connectionManagerCustomizer, this.socketConfigCustomizer, - this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory); + return new HttpComponentsClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), this.httpClientBuilder); } @Override public HttpComponentsClientHttpRequestFactoryBuilder withCustomizers( Collection> customizers) { return new HttpComponentsClientHttpRequestFactoryBuilder(mergedCustomizers(customizers), - this.httpClientCustomizer, this.connectionManagerCustomizer, this.socketConfigCustomizer, - this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory); + this.httpClientBuilder); } /** @@ -121,8 +82,7 @@ public final class HttpComponentsClientHttpRequestFactoryBuilder Consumer httpClientCustomizer) { Assert.notNull(httpClientCustomizer, "'httpClientCustomizer' must not be null"); return new HttpComponentsClientHttpRequestFactoryBuilder(getCustomizers(), - this.httpClientCustomizer.andThen(httpClientCustomizer), this.connectionManagerCustomizer, - this.socketConfigCustomizer, this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory); + this.httpClientBuilder.withCustomizer(httpClientCustomizer)); } /** @@ -135,9 +95,8 @@ public final class HttpComponentsClientHttpRequestFactoryBuilder public HttpComponentsClientHttpRequestFactoryBuilder withConnectionManagerCustomizer( Consumer connectionManagerCustomizer) { Assert.notNull(connectionManagerCustomizer, "'connectionManagerCustomizer' must not be null"); - return new HttpComponentsClientHttpRequestFactoryBuilder(getCustomizers(), this.httpClientCustomizer, - this.connectionManagerCustomizer.andThen(connectionManagerCustomizer), this.socketConfigCustomizer, - this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory); + return new HttpComponentsClientHttpRequestFactoryBuilder(getCustomizers(), + this.httpClientBuilder.withConnectionManagerCustomizer(connectionManagerCustomizer)); } /** @@ -150,9 +109,8 @@ public final class HttpComponentsClientHttpRequestFactoryBuilder public HttpComponentsClientHttpRequestFactoryBuilder withSocketConfigCustomizer( Consumer socketConfigCustomizer) { Assert.notNull(socketConfigCustomizer, "'socketConfigCustomizer' must not be null"); - return new HttpComponentsClientHttpRequestFactoryBuilder(getCustomizers(), this.httpClientCustomizer, - this.connectionManagerCustomizer, this.socketConfigCustomizer.andThen(socketConfigCustomizer), - this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory); + return new HttpComponentsClientHttpRequestFactoryBuilder(getCustomizers(), + this.httpClientBuilder.withSocketConfigCustomizer(socketConfigCustomizer)); } /** @@ -165,9 +123,8 @@ public final class HttpComponentsClientHttpRequestFactoryBuilder public HttpComponentsClientHttpRequestFactoryBuilder withTlsSocketStrategyFactory( Function tlsSocketStrategyFactory) { Assert.notNull(tlsSocketStrategyFactory, "'tlsSocketStrategyFactory' must not be null"); - return new HttpComponentsClientHttpRequestFactoryBuilder(getCustomizers(), this.httpClientCustomizer, - this.connectionManagerCustomizer, this.socketConfigCustomizer, this.defaultRequestConfigCustomizer, - tlsSocketStrategyFactory); + return new HttpComponentsClientHttpRequestFactoryBuilder(getCustomizers(), + this.httpClientBuilder.withTlsSocketStrategyFactory(tlsSocketStrategyFactory)); } /** @@ -181,87 +138,20 @@ public final class HttpComponentsClientHttpRequestFactoryBuilder public HttpComponentsClientHttpRequestFactoryBuilder withDefaultRequestConfigCustomizer( Consumer defaultRequestConfigCustomizer) { Assert.notNull(defaultRequestConfigCustomizer, "'defaultRequestConfigCustomizer' must not be null"); - return new HttpComponentsClientHttpRequestFactoryBuilder(getCustomizers(), this.httpClientCustomizer, - this.connectionManagerCustomizer, this.socketConfigCustomizer, - this.defaultRequestConfigCustomizer.andThen(defaultRequestConfigCustomizer), - this.tlsSocketStrategyFactory); + return new HttpComponentsClientHttpRequestFactoryBuilder(getCustomizers(), + this.httpClientBuilder.withDefaultRequestConfigCustomizer(defaultRequestConfigCustomizer)); } @Override protected HttpComponentsClientHttpRequestFactory createClientHttpRequestFactory( ClientHttpRequestFactorySettings settings) { - HttpClient httpClient = createHttpClient(settings); + HttpClient httpClient = this.httpClientBuilder.build(asHttpClientSettings(settings.withConnectTimeout(null))); HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(httpClient); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); map.from(settings::connectTimeout).asInt(Duration::toMillis).to(factory::setConnectTimeout); return factory; } - private HttpClient createHttpClient(ClientHttpRequestFactorySettings settings) { - HttpClientBuilder builder = HttpClientBuilder.create() - .useSystemProperties() - .setRedirectStrategy(asRedirectStrategy(settings.redirects())) - .setConnectionManager(createConnectionManager(settings)) - .setDefaultRequestConfig(createDefaultRequestConfig()); - this.httpClientCustomizer.accept(builder); - return builder.build(); - } - - private RedirectStrategy asRedirectStrategy(Redirects redirects) { - return switch (redirects) { - case FOLLOW_WHEN_POSSIBLE, FOLLOW -> DefaultRedirectStrategy.INSTANCE; - case DONT_FOLLOW -> NoFollowRedirectStrategy.INSTANCE; - }; - } - - private PoolingHttpClientConnectionManager createConnectionManager(ClientHttpRequestFactorySettings settings) { - PoolingHttpClientConnectionManagerBuilder builder = PoolingHttpClientConnectionManagerBuilder.create() - .useSystemProperties(); - PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); - builder.setDefaultSocketConfig(createSocketConfig(settings)); - map.from(settings::sslBundle).as(this.tlsSocketStrategyFactory).to(builder::setTlsSocketStrategy); - this.connectionManagerCustomizer.accept(builder); - return builder.build(); - } - - private SocketConfig createSocketConfig(ClientHttpRequestFactorySettings settings) { - SocketConfig.Builder builder = SocketConfig.custom(); - PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); - map.from(settings::readTimeout) - .asInt(Duration::toMillis) - .to((timeout) -> builder.setSoTimeout(timeout, TimeUnit.MILLISECONDS)); - this.socketConfigCustomizer.accept(builder); - return builder.build(); - } - - private RequestConfig createDefaultRequestConfig() { - RequestConfig.Builder builder = RequestConfig.custom(); - this.defaultRequestConfigCustomizer.accept(builder); - return builder.build(); - } - - /** - * {@link RedirectStrategy} that never follows redirects. - */ - private static final class NoFollowRedirectStrategy implements RedirectStrategy { - - private static final RedirectStrategy INSTANCE = new NoFollowRedirectStrategy(); - - private NoFollowRedirectStrategy() { - } - - @Override - public boolean isRedirected(HttpRequest request, HttpResponse response, HttpContext context) { - return false; - } - - @Override - public URI getLocationURI(HttpRequest request, HttpResponse response, HttpContext context) { - return null; - } - - } - static class Classes { static final String HTTP_CLIENTS = "org.apache.hc.client5.http.impl.classic.HttpClients"; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpAsyncClientBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpAsyncClientBuilder.java new file mode 100644 index 0000000000..9519e594fb --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpAsyncClientBuilder.java @@ -0,0 +1,193 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.apache.hc.client5.http.async.HttpAsyncClient; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.util.Assert; + +/** + * Builder that can be used to create a + * Apache HttpComponents + * {@link HttpAsyncClient}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Scott Frederick + * @since 3.5.0 + */ +public final class HttpComponentsHttpAsyncClientBuilder { + + private final Consumer customizer; + + private final Consumer connectionManagerCustomizer; + + private final Consumer connectionConfigCustomizer; + + private final Consumer defaultRequestConfigCustomizer; + + private final Function tlsStrategyFactory; + + public HttpComponentsHttpAsyncClientBuilder() { + this(Empty.consumer(), Empty.consumer(), Empty.consumer(), Empty.consumer(), + HttpComponentsSslBundleTlsStrategy::get); + } + + private HttpComponentsHttpAsyncClientBuilder(Consumer customizer, + Consumer connectionManagerCustomizer, + Consumer connectionConfigCustomizer, + Consumer defaultRequestConfigCustomizer, + Function tlsStrategyFactory) { + this.customizer = customizer; + this.connectionManagerCustomizer = connectionManagerCustomizer; + this.connectionConfigCustomizer = connectionConfigCustomizer; + this.defaultRequestConfigCustomizer = defaultRequestConfigCustomizer; + this.tlsStrategyFactory = tlsStrategyFactory; + } + + /** + * Return a new {@link HttpComponentsHttpAsyncClientBuilder} that applies additional + * customization to the underlying {@link HttpAsyncClientBuilder}. + * @param customizer the customizer to apply + * @return a new {@link HttpComponentsHttpAsyncClientBuilder} instance + */ + public HttpComponentsHttpAsyncClientBuilder withCustomizer(Consumer customizer) { + Assert.notNull(customizer, "'customizer' must not be null"); + return new HttpComponentsHttpAsyncClientBuilder(this.customizer.andThen(customizer), + this.connectionManagerCustomizer, this.connectionConfigCustomizer, this.defaultRequestConfigCustomizer, + this.tlsStrategyFactory); + } + + /** + * Return a new {@link HttpComponentsHttpAsyncClientBuilder} that applies additional + * customization to the underlying {@link PoolingAsyncClientConnectionManagerBuilder}. + * @param connectionManagerCustomizer the customizer to apply + * @return a new {@link HttpComponentsHttpAsyncClientBuilder} instance + */ + public HttpComponentsHttpAsyncClientBuilder withConnectionManagerCustomizer( + Consumer connectionManagerCustomizer) { + Assert.notNull(connectionManagerCustomizer, "'connectionManagerCustomizer' must not be null"); + return new HttpComponentsHttpAsyncClientBuilder(this.customizer, + this.connectionManagerCustomizer.andThen(connectionManagerCustomizer), this.connectionConfigCustomizer, + this.defaultRequestConfigCustomizer, this.tlsStrategyFactory); + } + + /** + * Return a new {@link HttpComponentsHttpAsyncClientBuilder} that applies additional + * customization to the underlying + * {@link org.apache.hc.client5.http.config.ConnectionConfig.Builder}. + * @param connectionConfigCustomizer the customizer to apply + * @return a new {@link HttpComponentsHttpAsyncClientBuilder} instance + */ + public HttpComponentsHttpAsyncClientBuilder withConnectionConfigCustomizer( + Consumer connectionConfigCustomizer) { + Assert.notNull(connectionConfigCustomizer, "'connectionConfigCustomizer' must not be null"); + return new HttpComponentsHttpAsyncClientBuilder(this.customizer, this.connectionManagerCustomizer, + this.connectionConfigCustomizer.andThen(connectionConfigCustomizer), + this.defaultRequestConfigCustomizer, this.tlsStrategyFactory); + } + + /** + * Return a new {@link HttpComponentsHttpAsyncClientBuilder} with a replacement + * {@link TlsStrategy} factory. + * @param tlsStrategyFactory the new factory used to create a {@link TlsStrategy} for + * a given {@link SslBundle} + * @return a new {@link HttpComponentsHttpAsyncClientBuilder} instance + */ + public HttpComponentsHttpAsyncClientBuilder withTlsStrategyFactory( + Function tlsStrategyFactory) { + Assert.notNull(tlsStrategyFactory, "'tlsStrategyFactory' must not be null"); + return new HttpComponentsHttpAsyncClientBuilder(this.customizer, this.connectionManagerCustomizer, + this.connectionConfigCustomizer, this.defaultRequestConfigCustomizer, tlsStrategyFactory); + } + + /** + * Return a new {@link HttpComponentsHttpAsyncClientBuilder} that applies additional + * customization to the underlying + * {@link org.apache.hc.client5.http.config.RequestConfig.Builder} used for default + * requests. + * @param defaultRequestConfigCustomizer the customizer to apply + * @return a new {@link HttpComponentsHttpAsyncClientBuilder} instance + */ + public HttpComponentsHttpAsyncClientBuilder withDefaultRequestConfigCustomizer( + Consumer defaultRequestConfigCustomizer) { + Assert.notNull(defaultRequestConfigCustomizer, "'defaultRequestConfigCustomizer' must not be null"); + return new HttpComponentsHttpAsyncClientBuilder(this.customizer, this.connectionManagerCustomizer, + this.connectionConfigCustomizer, + this.defaultRequestConfigCustomizer.andThen(defaultRequestConfigCustomizer), this.tlsStrategyFactory); + } + + /** + * Build a new {@link HttpAsyncClient} instance with the given settings applied. + * @param settings the settings to apply + * @return a new {@link CloseableHttpAsyncClient} instance + */ + public CloseableHttpAsyncClient build(HttpClientSettings settings) { + settings = (settings != null) ? settings : HttpClientSettings.DEFAULTS; + HttpAsyncClientBuilder builder = HttpAsyncClientBuilder.create() + .useSystemProperties() + .setRedirectStrategy(HttpComponentsRedirectStrategy.get(settings.redirects())) + .setConnectionManager(createConnectionManager(settings)) + .setDefaultRequestConfig(createDefaultRequestConfig()); + this.customizer.accept(builder); + return builder.build(); + } + + private PoolingAsyncClientConnectionManager createConnectionManager(HttpClientSettings settings) { + PoolingAsyncClientConnectionManagerBuilder builder = PoolingAsyncClientConnectionManagerBuilder.create() + .useSystemProperties(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + builder.setDefaultConnectionConfig(createConnectionConfig(settings)); + map.from(settings::sslBundle).as(this.tlsStrategyFactory).to(builder::setTlsStrategy); + this.connectionManagerCustomizer.accept(builder); + return builder.build(); + } + + private ConnectionConfig createConnectionConfig(HttpClientSettings settings) { + ConnectionConfig.Builder builder = ConnectionConfig.custom(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(settings::connectTimeout) + .as(Duration::toMillis) + .to((timeout) -> builder.setConnectTimeout(timeout, TimeUnit.MILLISECONDS)); + map.from(settings::readTimeout) + .asInt(Duration::toMillis) + .to((timeout) -> builder.setSocketTimeout(timeout, TimeUnit.MILLISECONDS)); + this.connectionConfigCustomizer.accept(builder); + return builder.build(); + } + + private RequestConfig createDefaultRequestConfig() { + RequestConfig.Builder builder = RequestConfig.custom(); + this.defaultRequestConfigCustomizer.accept(builder); + return builder.build(); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpClientBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpClientBuilder.java new file mode 100644 index 0000000000..eec8e0c05b --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsHttpClientBuilder.java @@ -0,0 +1,192 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.ssl.TlsSocketStrategy; +import org.apache.hc.core5.http.io.SocketConfig; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.util.Assert; + +/** + * Builder that can be used to create a + * Apache HttpComponents + * {@link HttpClient}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Scott Frederick + * @since 3.5.0 + */ +public final class HttpComponentsHttpClientBuilder { + + private final Consumer customizer; + + private final Consumer connectionManagerCustomizer; + + private final Consumer socketConfigCustomizer; + + private final Consumer defaultRequestConfigCustomizer; + + private final Function tlsSocketStrategyFactory; + + public HttpComponentsHttpClientBuilder() { + this(Empty.consumer(), Empty.consumer(), Empty.consumer(), Empty.consumer(), + HttpComponentsSslBundleTlsStrategy::get); + } + + private HttpComponentsHttpClientBuilder(Consumer customizer, + Consumer connectionManagerCustomizer, + Consumer socketConfigCustomizer, + Consumer defaultRequestConfigCustomizer, + Function tlsSocketStrategyFactory) { + this.customizer = customizer; + this.connectionManagerCustomizer = connectionManagerCustomizer; + this.socketConfigCustomizer = socketConfigCustomizer; + this.defaultRequestConfigCustomizer = defaultRequestConfigCustomizer; + this.tlsSocketStrategyFactory = tlsSocketStrategyFactory; + } + + /** + * Return a new {@link HttpComponentsHttpClientBuilder} that applies additional + * customization to the underlying {@link HttpClientBuilder}. + * @param customizer the customizer to apply + * @return a new {@link HttpComponentsHttpClientBuilder} instance + */ + public HttpComponentsHttpClientBuilder withCustomizer(Consumer customizer) { + Assert.notNull(customizer, "'customizer' must not be null"); + return new HttpComponentsHttpClientBuilder(this.customizer.andThen(customizer), + this.connectionManagerCustomizer, this.socketConfigCustomizer, this.defaultRequestConfigCustomizer, + this.tlsSocketStrategyFactory); + } + + /** + * Return a new {@link HttpComponentsHttpClientBuilder} that applies additional + * customization to the underlying {@link PoolingHttpClientConnectionManagerBuilder}. + * @param connectionManagerCustomizer the customizer to apply + * @return a new {@link HttpComponentsHttpClientBuilder} instance + */ + public HttpComponentsHttpClientBuilder withConnectionManagerCustomizer( + Consumer connectionManagerCustomizer) { + Assert.notNull(connectionManagerCustomizer, "'connectionManagerCustomizer' must not be null"); + return new HttpComponentsHttpClientBuilder(this.customizer, + this.connectionManagerCustomizer.andThen(connectionManagerCustomizer), this.socketConfigCustomizer, + this.defaultRequestConfigCustomizer, this.tlsSocketStrategyFactory); + } + + /** + * Return a new {@link HttpComponentsHttpClientBuilder} that applies additional + * customization to the underlying + * {@link org.apache.hc.core5.http.io.SocketConfig.Builder}. + * @param socketConfigCustomizer the customizer to apply + * @return a new {@link HttpComponentsHttpClientBuilder} instance + */ + public HttpComponentsHttpClientBuilder withSocketConfigCustomizer( + Consumer socketConfigCustomizer) { + Assert.notNull(socketConfigCustomizer, "'socketConfigCustomizer' must not be null"); + return new HttpComponentsHttpClientBuilder(this.customizer, this.connectionManagerCustomizer, + this.socketConfigCustomizer.andThen(socketConfigCustomizer), this.defaultRequestConfigCustomizer, + this.tlsSocketStrategyFactory); + } + + /** + * Return a new {@link HttpComponentsHttpClientBuilder} with a replacement + * {@link TlsSocketStrategy} factory. + * @param tlsSocketStrategyFactory the new factory used to create a + * {@link TlsSocketStrategy} for a given {@link SslBundle} + * @return a new {@link HttpComponentsHttpClientBuilder} instance + */ + public HttpComponentsHttpClientBuilder withTlsSocketStrategyFactory( + Function tlsSocketStrategyFactory) { + Assert.notNull(tlsSocketStrategyFactory, "'tlsSocketStrategyFactory' must not be null"); + return new HttpComponentsHttpClientBuilder(this.customizer, this.connectionManagerCustomizer, + this.socketConfigCustomizer, this.defaultRequestConfigCustomizer, tlsSocketStrategyFactory); + } + + /** + * Return a new {@link HttpComponentsHttpClientBuilder} that applies additional + * customization to the underlying + * {@link org.apache.hc.client5.http.config.RequestConfig.Builder} used for default + * requests. + * @param defaultRequestConfigCustomizer the customizer to apply + * @return a new {@link HttpComponentsHttpClientBuilder} instance + */ + public HttpComponentsHttpClientBuilder withDefaultRequestConfigCustomizer( + Consumer defaultRequestConfigCustomizer) { + Assert.notNull(defaultRequestConfigCustomizer, "'defaultRequestConfigCustomizer' must not be null"); + return new HttpComponentsHttpClientBuilder(this.customizer, this.connectionManagerCustomizer, + this.socketConfigCustomizer, + this.defaultRequestConfigCustomizer.andThen(defaultRequestConfigCustomizer), + this.tlsSocketStrategyFactory); + } + + /** + * Build a new {@link HttpClient} instance with the given settings applied. + * @param settings the settings to apply + * @return a new {@link HttpClient} instance + */ + public CloseableHttpClient build(HttpClientSettings settings) { + settings = (settings != null) ? settings : HttpClientSettings.DEFAULTS; + Assert.isTrue(settings.connectTimeout() == null, "'settings' must not have a 'connectTimeout'"); + HttpClientBuilder builder = HttpClientBuilder.create() + .useSystemProperties() + .setRedirectStrategy(HttpComponentsRedirectStrategy.get(settings.redirects())) + .setConnectionManager(createConnectionManager(settings)) + .setDefaultRequestConfig(createDefaultRequestConfig()); + this.customizer.accept(builder); + return builder.build(); + } + + private PoolingHttpClientConnectionManager createConnectionManager(HttpClientSettings settings) { + PoolingHttpClientConnectionManagerBuilder builder = PoolingHttpClientConnectionManagerBuilder.create() + .useSystemProperties(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + builder.setDefaultSocketConfig(createSocketConfig(settings)); + map.from(settings::sslBundle).as(this.tlsSocketStrategyFactory).to(builder::setTlsSocketStrategy); + this.connectionManagerCustomizer.accept(builder); + return builder.build(); + } + + private SocketConfig createSocketConfig(HttpClientSettings settings) { + SocketConfig.Builder builder = SocketConfig.custom(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(settings::readTimeout) + .asInt(Duration::toMillis) + .to((timeout) -> builder.setSoTimeout(timeout, TimeUnit.MILLISECONDS)); + this.socketConfigCustomizer.accept(builder); + return builder.build(); + } + + private RequestConfig createDefaultRequestConfig() { + RequestConfig.Builder builder = RequestConfig.custom(); + this.defaultRequestConfigCustomizer.accept(builder); + return builder.build(); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsRedirectStrategy.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsRedirectStrategy.java new file mode 100644 index 0000000000..cccb933e28 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsRedirectStrategy.java @@ -0,0 +1,70 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.URI; + +import org.apache.hc.client5.http.impl.DefaultRedirectStrategy; +import org.apache.hc.client5.http.protocol.RedirectStrategy; +import org.apache.hc.core5.http.HttpRequest; +import org.apache.hc.core5.http.HttpResponse; +import org.apache.hc.core5.http.protocol.HttpContext; + +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings.Redirects; + +/** + * Adapts {@link Redirects} to an + * Apache HttpComponents + * {@link RedirectStrategy}. + * + * @author Phillip Webb + */ +final class HttpComponentsRedirectStrategy { + + private HttpComponentsRedirectStrategy() { + } + + static RedirectStrategy get(HttpRedirects redirects) { + return switch (redirects) { + case FOLLOW_WHEN_POSSIBLE, FOLLOW -> DefaultRedirectStrategy.INSTANCE; + case DONT_FOLLOW -> NoFollowRedirectStrategy.INSTANCE; + }; + } + + /** + * {@link RedirectStrategy} that never follows redirects. + */ + private static final class NoFollowRedirectStrategy implements RedirectStrategy { + + private static final RedirectStrategy INSTANCE = new NoFollowRedirectStrategy(); + + private NoFollowRedirectStrategy() { + } + + @Override + public boolean isRedirected(HttpRequest request, HttpResponse response, HttpContext context) { + return false; + } + + @Override + public URI getLocationURI(HttpRequest request, HttpResponse response, HttpContext context) { + return null; + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsSslBundleTlsStrategy.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsSslBundleTlsStrategy.java new file mode 100644 index 0000000000..e7791a8790 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpComponentsSslBundleTlsStrategy.java @@ -0,0 +1,46 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import javax.net.ssl.SSLContext; + +import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; +import org.apache.hc.client5.http.ssl.DefaultHostnameVerifier; + +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslOptions; + +/** + * Adapts {@link SslBundle} to an + * Apache HttpComponents + * {@link DefaultClientTlsStrategy}. + * + * @author Phillip Webb + */ +final class HttpComponentsSslBundleTlsStrategy { + + private HttpComponentsSslBundleTlsStrategy() { + } + + static DefaultClientTlsStrategy get(SslBundle sslBundle) { + SslOptions options = sslBundle.getOptions(); + SSLContext sslContext = sslBundle.createSslContext(); + return new DefaultClientTlsStrategy(sslContext, options.getEnabledProtocols(), options.getCiphers(), null, + new DefaultHostnameVerifier()); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactoryTests.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpRedirects.java similarity index 55% rename from spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactoryTests.java rename to spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpRedirects.java index 6fb7e80f38..827064dc25 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/function/client/HttpComponentsClientHttpConnectorFactoryTests.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/HttpRedirects.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,18 +14,29 @@ * limitations under the License. */ -package org.springframework.boot.autoconfigure.web.reactive.function.client; +package org.springframework.boot.http.client; /** - * Tests for {@link HttpComponentsClientHttpConnectorFactory}. + * Redirect strategies support by HTTP clients. * * @author Phillip Webb + * @since 3.5.0 */ -class HttpComponentsClientHttpConnectorFactoryTests extends AbstractClientHttpConnectorFactoryTests { +public enum HttpRedirects { - @Override - protected ClientHttpConnectorFactory getFactory() { - return new HttpComponentsClientHttpConnectorFactory(); - } + /** + * Follow redirects (if the underlying library has support). + */ + FOLLOW_WHEN_POSSIBLE, + + /** + * Follow redirects (fail if the underlying library has no support). + */ + FOLLOW, + + /** + * Don't follow redirects (fail if the underlying library has no support). + */ + DONT_FOLLOW } diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilder.java index b94a89d536..9e20c07d3f 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,17 +17,11 @@ package org.springframework.boot.http.client; import java.net.http.HttpClient; -import java.net.http.HttpClient.Redirect; import java.util.Collection; import java.util.List; import java.util.function.Consumer; -import javax.net.ssl.SSLParameters; - import org.springframework.boot.context.properties.PropertyMapper; -import org.springframework.boot.http.client.ClientHttpRequestFactorySettings.Redirects; -import org.springframework.boot.ssl.SslBundle; -import org.springframework.boot.ssl.SslOptions; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -40,30 +34,30 @@ import org.springframework.util.ClassUtils; * @author Scott Frederick * @since 3.4.0 */ -public class JdkClientHttpRequestFactoryBuilder +public final class JdkClientHttpRequestFactoryBuilder extends AbstractClientHttpRequestFactoryBuilder { - private final Consumer httpClientCustomizer; + private final JdkHttpClientBuilder httpClientBuilder; JdkClientHttpRequestFactoryBuilder() { - this(null, emptyCustomizer()); + this(null, new JdkHttpClientBuilder()); } private JdkClientHttpRequestFactoryBuilder(List> customizers, - Consumer httpClientCustomizer) { + JdkHttpClientBuilder httpClientBuilder) { super(customizers); - this.httpClientCustomizer = httpClientCustomizer; + this.httpClientBuilder = httpClientBuilder; } @Override public JdkClientHttpRequestFactoryBuilder withCustomizer(Consumer customizer) { - return new JdkClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), this.httpClientCustomizer); + return new JdkClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), this.httpClientBuilder); } @Override public JdkClientHttpRequestFactoryBuilder withCustomizers( Collection> customizers) { - return new JdkClientHttpRequestFactoryBuilder(mergedCustomizers(customizers), this.httpClientCustomizer); + return new JdkClientHttpRequestFactoryBuilder(mergedCustomizers(customizers), this.httpClientBuilder); } /** @@ -76,44 +70,18 @@ public class JdkClientHttpRequestFactoryBuilder Consumer httpClientCustomizer) { Assert.notNull(httpClientCustomizer, "'httpClientCustomizer' must not be null"); return new JdkClientHttpRequestFactoryBuilder(getCustomizers(), - this.httpClientCustomizer.andThen(httpClientCustomizer)); + this.httpClientBuilder.withCustomizer(httpClientCustomizer)); } @Override protected JdkClientHttpRequestFactory createClientHttpRequestFactory(ClientHttpRequestFactorySettings settings) { - HttpClient httpClient = createHttpClient(settings); + HttpClient httpClient = this.httpClientBuilder.build(asHttpClientSettings(settings.withReadTimeout(null))); JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); map.from(settings::readTimeout).to(requestFactory::setReadTimeout); return requestFactory; } - private HttpClient createHttpClient(ClientHttpRequestFactorySettings settings) { - HttpClient.Builder builder = HttpClient.newBuilder(); - PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); - map.from(settings::connectTimeout).to(builder::connectTimeout); - map.from(settings::sslBundle).as(SslBundle::createSslContext).to(builder::sslContext); - map.from(settings::sslBundle).as(this::asSslParameters).to(builder::sslParameters); - map.from(settings::redirects).as(this::asHttpClientRedirect).to(builder::followRedirects); - this.httpClientCustomizer.accept(builder); - return builder.build(); - } - - private SSLParameters asSslParameters(SslBundle sslBundle) { - SslOptions options = sslBundle.getOptions(); - SSLParameters parameters = new SSLParameters(); - parameters.setCipherSuites(options.getCiphers()); - parameters.setProtocols(options.getEnabledProtocols()); - return parameters; - } - - private Redirect asHttpClientRedirect(Redirects redirects) { - return switch (redirects) { - case FOLLOW_WHEN_POSSIBLE, FOLLOW -> Redirect.NORMAL; - case DONT_FOLLOW -> Redirect.NEVER; - }; - } - static class Classes { static final String HTTP_CLIENT = "java.net.http.HttpClient"; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JdkHttpClientBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JdkHttpClientBuilder.java new file mode 100644 index 0000000000..0426cb73e5 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JdkHttpClientBuilder.java @@ -0,0 +1,94 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.net.http.HttpClient; +import java.net.http.HttpClient.Redirect; +import java.util.function.Consumer; + +import javax.net.ssl.SSLParameters; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslOptions; +import org.springframework.util.Assert; + +/** + * Builder that can be used to create a JDK {@link HttpClient}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Scott Frederick + * @since 3.5.0 + */ +public final class JdkHttpClientBuilder { + + private final Consumer customizer; + + public JdkHttpClientBuilder() { + this(Empty.consumer()); + } + + private JdkHttpClientBuilder(Consumer customizer) { + this.customizer = customizer; + } + + /** + * Return a new {@link JdkHttpClientBuilder} that applies additional customization to + * the underlying {@link java.net.http.HttpClient.Builder}. + * @param customizer the customizer to apply + * @return a new {@link JdkHttpClientBuilder} instance + */ + public JdkHttpClientBuilder withCustomizer(Consumer customizer) { + Assert.notNull(customizer, "'customizer' must not be null"); + return new JdkHttpClientBuilder(this.customizer.andThen(customizer)); + } + + /** + * Build a new {@link HttpClient} instance with the given settings applied. + * @param settings the settings to apply + * @return a new {@link HttpClient} instance + */ + public HttpClient build(HttpClientSettings settings) { + settings = (settings != null) ? settings : HttpClientSettings.DEFAULTS; + Assert.isTrue(settings.readTimeout() == null, "'settings' must not have a 'readTimeout'"); + HttpClient.Builder builder = HttpClient.newBuilder(); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(settings::redirects).as(this::asHttpClientRedirect).to(builder::followRedirects); + map.from(settings::connectTimeout).to(builder::connectTimeout); + map.from(settings::sslBundle).as(SslBundle::createSslContext).to(builder::sslContext); + map.from(settings::sslBundle).as(this::asSslParameters).to(builder::sslParameters); + this.customizer.accept(builder); + return builder.build(); + } + + private SSLParameters asSslParameters(SslBundle sslBundle) { + SslOptions options = sslBundle.getOptions(); + SSLParameters parameters = new SSLParameters(); + parameters.setCipherSuites(options.getCiphers()); + parameters.setProtocols(options.getEnabledProtocols()); + return parameters; + } + + private Redirect asHttpClientRedirect(HttpRedirects redirects) { + return switch (redirects) { + case FOLLOW_WHEN_POSSIBLE, FOLLOW -> Redirect.NORMAL; + case DONT_FOLLOW -> Redirect.NEVER; + }; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilder.java index a93a67ca96..25b63f9951 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,19 +21,11 @@ import java.util.Collection; import java.util.List; import java.util.function.Consumer; -import javax.net.ssl.SSLContext; - import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.HttpClientTransport; -import org.eclipse.jetty.client.transport.HttpClientTransportDynamic; -import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP; import org.eclipse.jetty.io.ClientConnector; -import org.eclipse.jetty.util.ssl.SslContextFactory; import org.springframework.boot.context.properties.PropertyMapper; -import org.springframework.boot.http.client.ClientHttpRequestFactorySettings.Redirects; -import org.springframework.boot.ssl.SslBundle; -import org.springframework.boot.ssl.SslOptions; import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -49,36 +41,27 @@ import org.springframework.util.ClassUtils; public final class JettyClientHttpRequestFactoryBuilder extends AbstractClientHttpRequestFactoryBuilder { - private final Consumer httpClientCustomizer; - - private final Consumer httpClientTransportCustomizer; - - private final Consumer clientConnectorCustomizerCustomizer; + private final JettyHttpClientBuilder httpClientBuilder; JettyClientHttpRequestFactoryBuilder() { - this(null, emptyCustomizer(), emptyCustomizer(), emptyCustomizer()); + this(null, new JettyHttpClientBuilder()); } private JettyClientHttpRequestFactoryBuilder(List> customizers, - Consumer httpClientCustomizer, Consumer httpClientTransportCustomizer, - Consumer clientConnectorCustomizerCustomizer) { + JettyHttpClientBuilder httpClientBuilder) { super(customizers); - this.httpClientCustomizer = httpClientCustomizer; - this.httpClientTransportCustomizer = httpClientTransportCustomizer; - this.clientConnectorCustomizerCustomizer = clientConnectorCustomizerCustomizer; + this.httpClientBuilder = httpClientBuilder; } @Override public JettyClientHttpRequestFactoryBuilder withCustomizer(Consumer customizer) { - return new JettyClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), this.httpClientCustomizer, - this.httpClientTransportCustomizer, this.clientConnectorCustomizerCustomizer); + return new JettyClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), this.httpClientBuilder); } @Override public JettyClientHttpRequestFactoryBuilder withCustomizers( Collection> customizers) { - return new JettyClientHttpRequestFactoryBuilder(mergedCustomizers(customizers), this.httpClientCustomizer, - this.httpClientTransportCustomizer, this.clientConnectorCustomizerCustomizer); + return new JettyClientHttpRequestFactoryBuilder(mergedCustomizers(customizers), this.httpClientBuilder); } /** @@ -90,8 +73,7 @@ public final class JettyClientHttpRequestFactoryBuilder public JettyClientHttpRequestFactoryBuilder withHttpClientCustomizer(Consumer httpClientCustomizer) { Assert.notNull(httpClientCustomizer, "'httpClientCustomizer' must not be null"); return new JettyClientHttpRequestFactoryBuilder(getCustomizers(), - this.httpClientCustomizer.andThen(httpClientCustomizer), this.httpClientTransportCustomizer, - this.clientConnectorCustomizerCustomizer); + this.httpClientBuilder.withCustomizer(httpClientCustomizer)); } /** @@ -103,9 +85,8 @@ public final class JettyClientHttpRequestFactoryBuilder public JettyClientHttpRequestFactoryBuilder withHttpClientTransportCustomizer( Consumer httpClientTransportCustomizer) { Assert.notNull(httpClientTransportCustomizer, "'httpClientTransportCustomizer' must not be null"); - return new JettyClientHttpRequestFactoryBuilder(getCustomizers(), this.httpClientCustomizer, - this.httpClientTransportCustomizer.andThen(httpClientTransportCustomizer), - this.clientConnectorCustomizerCustomizer); + return new JettyClientHttpRequestFactoryBuilder(getCustomizers(), + this.httpClientBuilder.withHttpClientTransportCustomizer(httpClientTransportCustomizer)); } /** @@ -117,68 +98,20 @@ public final class JettyClientHttpRequestFactoryBuilder public JettyClientHttpRequestFactoryBuilder withClientConnectorCustomizerCustomizer( Consumer clientConnectorCustomizerCustomizer) { Assert.notNull(clientConnectorCustomizerCustomizer, "'clientConnectorCustomizerCustomizer' must not be null"); - return new JettyClientHttpRequestFactoryBuilder(getCustomizers(), this.httpClientCustomizer, - this.httpClientTransportCustomizer, - this.clientConnectorCustomizerCustomizer.andThen(clientConnectorCustomizerCustomizer)); + return new JettyClientHttpRequestFactoryBuilder(getCustomizers(), + this.httpClientBuilder.withClientConnectorCustomizerCustomizer(clientConnectorCustomizerCustomizer)); } @Override protected JettyClientHttpRequestFactory createClientHttpRequestFactory(ClientHttpRequestFactorySettings settings) { - JettyClientHttpRequestFactory requestFactory = createRequestFactory(settings); + HttpClient httpClient = this.httpClientBuilder.build(asHttpClientSettings(settings.withTimeouts(null, null))); + JettyClientHttpRequestFactory requestFactory = new JettyClientHttpRequestFactory(httpClient); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); map.from(settings::connectTimeout).asInt(Duration::toMillis).to(requestFactory::setConnectTimeout); map.from(settings::readTimeout).asInt(Duration::toMillis).to(requestFactory::setReadTimeout); return requestFactory; } - private JettyClientHttpRequestFactory createRequestFactory(ClientHttpRequestFactorySettings settings) { - HttpClientTransport transport = createTransport(settings); - this.httpClientTransportCustomizer.accept(transport); - HttpClient httpClient = new HttpClient(transport); - PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); - map.from(settings::redirects).as(this::followRedirects).to(httpClient::setFollowRedirects); - this.httpClientCustomizer.accept(httpClient); - return new JettyClientHttpRequestFactory(httpClient); - } - - private HttpClientTransport createTransport(ClientHttpRequestFactorySettings settings) { - ClientConnector connector = createClientConnector(settings.sslBundle()); - return (connector.getSslContextFactory() != null) ? new HttpClientTransportDynamic(connector) - : new HttpClientTransportOverHTTP(connector); - } - - private ClientConnector createClientConnector(SslBundle sslBundle) { - ClientConnector connector = new ClientConnector(); - if (sslBundle != null) { - connector.setSslContextFactory(createSslContextFactory(sslBundle)); - } - this.clientConnectorCustomizerCustomizer.accept(connector); - return connector; - } - - private SslContextFactory.Client createSslContextFactory(SslBundle sslBundle) { - SslOptions options = sslBundle.getOptions(); - SSLContext sslContext = sslBundle.createSslContext(); - SslContextFactory.Client factory = new SslContextFactory.Client(); - factory.setSslContext(sslContext); - if (options.getCiphers() != null) { - factory.setIncludeCipherSuites(options.getCiphers()); - factory.setExcludeCipherSuites(); - } - if (options.getEnabledProtocols() != null) { - factory.setIncludeProtocols(options.getEnabledProtocols()); - factory.setExcludeProtocols(); - } - return factory; - } - - private boolean followRedirects(Redirects redirects) { - return switch (redirects) { - case FOLLOW_WHEN_POSSIBLE, FOLLOW -> true; - case DONT_FOLLOW -> false; - }; - } - static class Classes { static final String HTTP_CLIENT = "org.eclipse.jetty.client.HttpClient"; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JettyHttpClientBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JettyHttpClientBuilder.java new file mode 100644 index 0000000000..9e27aed63b --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/JettyHttpClientBuilder.java @@ -0,0 +1,186 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import javax.net.ssl.SSLContext; + +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.HttpClientTransport; +import org.eclipse.jetty.client.Request; +import org.eclipse.jetty.client.transport.HttpClientTransportDynamic; +import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP; +import org.eclipse.jetty.io.ClientConnector; +import org.eclipse.jetty.util.ssl.SslContextFactory; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslOptions; +import org.springframework.util.Assert; + +/** + * Builder that can be used to create a Jetty {@link HttpClient}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Scott Frederick + * @since 3.5.0 + */ +public final class JettyHttpClientBuilder { + + private final Consumer customizer; + + private final Consumer httpClientTransportCustomizer; + + private final Consumer clientConnectorCustomizerCustomizer; + + public JettyHttpClientBuilder() { + this(Empty.consumer(), Empty.consumer(), Empty.consumer()); + } + + private JettyHttpClientBuilder(Consumer customizer, + Consumer httpClientTransportCustomizer, + Consumer clientConnectorCustomizerCustomizer) { + this.customizer = customizer; + this.httpClientTransportCustomizer = httpClientTransportCustomizer; + this.clientConnectorCustomizerCustomizer = clientConnectorCustomizerCustomizer; + } + + /** + * Return a new {@link JettyClientHttpRequestFactoryBuilder} that applies additional + * customization to the underlying {@link HttpClient}. + * @param customizer the customizer to apply + * @return a new {@link JettyClientHttpRequestFactoryBuilder} instance + */ + public JettyHttpClientBuilder withCustomizer(Consumer customizer) { + Assert.notNull(customizer, "'customizer' must not be null"); + return new JettyHttpClientBuilder(this.customizer.andThen(customizer), this.httpClientTransportCustomizer, + this.clientConnectorCustomizerCustomizer); + } + + /** + * Return a new {@link JettyClientHttpRequestFactoryBuilder} that applies additional + * customization to the underlying {@link HttpClientTransport}. + * @param httpClientTransportCustomizer the customizer to apply + * @return a new {@link JettyClientHttpRequestFactoryBuilder} instance + */ + public JettyHttpClientBuilder withHttpClientTransportCustomizer( + Consumer httpClientTransportCustomizer) { + Assert.notNull(httpClientTransportCustomizer, "'httpClientTransportCustomizer' must not be null"); + return new JettyHttpClientBuilder(this.customizer, + this.httpClientTransportCustomizer.andThen(httpClientTransportCustomizer), + this.clientConnectorCustomizerCustomizer); + } + + /** + * Return a new {@link JettyClientHttpRequestFactoryBuilder} that applies additional + * customization to the underlying {@link ClientConnector}. + * @param clientConnectorCustomizerCustomizer the customizer to apply + * @return a new {@link JettyClientHttpRequestFactoryBuilder} instance + */ + public JettyHttpClientBuilder withClientConnectorCustomizerCustomizer( + Consumer clientConnectorCustomizerCustomizer) { + Assert.notNull(clientConnectorCustomizerCustomizer, "'clientConnectorCustomizerCustomizer' must not be null"); + return new JettyHttpClientBuilder(this.customizer, this.httpClientTransportCustomizer, + this.clientConnectorCustomizerCustomizer.andThen(clientConnectorCustomizerCustomizer)); + } + + /** + * Build a new {@link HttpClient} instance with the given settings applied. + * @param settings the settings to apply + * @return a new {@link HttpClient} instance + */ + public HttpClient build(HttpClientSettings settings) { + settings = (settings != null) ? settings : HttpClientSettings.DEFAULTS; + HttpClientTransport transport = createTransport(settings); + this.httpClientTransportCustomizer.accept(transport); + HttpClient httpClient = createHttpClient(settings.readTimeout(), transport); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(settings::connectTimeout).as(Duration::toMillis).to(httpClient::setConnectTimeout); + map.from(settings::redirects).as(this::followRedirects).to(httpClient::setFollowRedirects); + this.customizer.accept(httpClient); + return httpClient; + } + + private HttpClient createHttpClient(Duration readTimeout, HttpClientTransport transport) { + return (readTimeout != null) ? new HttpClientWithReadTimeout(transport, readTimeout) + : new HttpClient(transport); + } + + private HttpClientTransport createTransport(HttpClientSettings settings) { + ClientConnector connector = createClientConnector(settings.sslBundle()); + return (connector.getSslContextFactory() != null) ? new HttpClientTransportDynamic(connector) + : new HttpClientTransportOverHTTP(connector); + } + + private ClientConnector createClientConnector(SslBundle sslBundle) { + ClientConnector connector = new ClientConnector(); + if (sslBundle != null) { + connector.setSslContextFactory(createSslContextFactory(sslBundle)); + } + this.clientConnectorCustomizerCustomizer.accept(connector); + return connector; + } + + private SslContextFactory.Client createSslContextFactory(SslBundle sslBundle) { + SslOptions options = sslBundle.getOptions(); + SSLContext sslContext = sslBundle.createSslContext(); + SslContextFactory.Client factory = new SslContextFactory.Client(); + factory.setSslContext(sslContext); + if (options.getCiphers() != null) { + factory.setIncludeCipherSuites(options.getCiphers()); + factory.setExcludeCipherSuites(); + } + if (options.getEnabledProtocols() != null) { + factory.setIncludeProtocols(options.getEnabledProtocols()); + factory.setExcludeProtocols(); + } + return factory; + } + + private boolean followRedirects(HttpRedirects redirects) { + return switch (redirects) { + case FOLLOW_WHEN_POSSIBLE, FOLLOW -> true; + case DONT_FOLLOW -> false; + }; + } + + /** + * {@link HttpClient} subclass that sets the read timeout. + */ + static class HttpClientWithReadTimeout extends HttpClient { + + private final Duration readTimeout; + + HttpClientWithReadTimeout(HttpClientTransport transport, Duration readTimeout) { + super(transport); + this.readTimeout = readTimeout; + } + + @Override + public org.eclipse.jetty.client.Request newRequest(java.net.URI uri) { + Request request = super.newRequest(uri); + request.timeout(this.readTimeout.toMillis(), TimeUnit.MILLISECONDS); + return request; + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/ReactorClientHttpRequestFactoryBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/ReactorClientHttpRequestFactoryBuilder.java index e3206cc0af..23a1c38bea 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/ReactorClientHttpRequestFactoryBuilder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/ReactorClientHttpRequestFactoryBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,21 +22,12 @@ import java.util.List; import java.util.function.Consumer; import java.util.function.UnaryOperator; -import javax.net.ssl.SSLException; - -import io.netty.handler.ssl.SslContextBuilder; import reactor.netty.http.client.HttpClient; -import reactor.netty.tcp.SslProvider.SslContextSpec; import org.springframework.boot.context.properties.PropertyMapper; -import org.springframework.boot.http.client.ClientHttpRequestFactorySettings.Redirects; -import org.springframework.boot.ssl.SslBundle; -import org.springframework.boot.ssl.SslManagerBundle; -import org.springframework.boot.ssl.SslOptions; import org.springframework.http.client.ReactorClientHttpRequestFactory; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; -import org.springframework.util.function.ThrowingConsumer; /** * Builder for {@link ClientHttpRequestFactoryBuilder#reactor()}. @@ -49,27 +40,27 @@ import org.springframework.util.function.ThrowingConsumer; public final class ReactorClientHttpRequestFactoryBuilder extends AbstractClientHttpRequestFactoryBuilder { - private final UnaryOperator httpClientCustomizer; + private final ReactorHttpClientBuilder httpClientBuilder; ReactorClientHttpRequestFactoryBuilder() { - this(null, UnaryOperator.identity()); + this(null, new ReactorHttpClientBuilder()); } private ReactorClientHttpRequestFactoryBuilder(List> customizers, - UnaryOperator httpClientCustomizer) { + ReactorHttpClientBuilder httpClientBuilder) { super(customizers); - this.httpClientCustomizer = httpClientCustomizer; + this.httpClientBuilder = httpClientBuilder; } @Override public ReactorClientHttpRequestFactoryBuilder withCustomizer(Consumer customizer) { - return new ReactorClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), this.httpClientCustomizer); + return new ReactorClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), this.httpClientBuilder); } @Override public ReactorClientHttpRequestFactoryBuilder withCustomizers( Collection> customizers) { - return new ReactorClientHttpRequestFactoryBuilder(mergedCustomizers(customizers), this.httpClientCustomizer); + return new ReactorClientHttpRequestFactoryBuilder(mergedCustomizers(customizers), this.httpClientBuilder); } /** @@ -82,52 +73,20 @@ public final class ReactorClientHttpRequestFactoryBuilder UnaryOperator httpClientCustomizer) { Assert.notNull(httpClientCustomizer, "'httpClientCustomizer' must not be null"); return new ReactorClientHttpRequestFactoryBuilder(getCustomizers(), - (t) -> httpClientCustomizer.apply(this.httpClientCustomizer.apply(t))); + this.httpClientBuilder.withHttpClientCustomizer(httpClientCustomizer)); } @Override protected ReactorClientHttpRequestFactory createClientHttpRequestFactory( ClientHttpRequestFactorySettings settings) { - ReactorClientHttpRequestFactory requestFactory = createRequestFactory(settings); + HttpClient httpClient = this.httpClientBuilder.build(asHttpClientSettings(settings.withTimeouts(null, null))); + ReactorClientHttpRequestFactory requestFactory = new ReactorClientHttpRequestFactory(httpClient); PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); map.from(settings::connectTimeout).asInt(Duration::toMillis).to(requestFactory::setConnectTimeout); map.from(settings::readTimeout).asInt(Duration::toMillis).to(requestFactory::setReadTimeout); return requestFactory; } - private ReactorClientHttpRequestFactory createRequestFactory(ClientHttpRequestFactorySettings settings) { - HttpClient httpClient = applyDefaults(HttpClient.create()); - httpClient = httpClient.followRedirect(followRedirects(settings.redirects())); - if (settings.sslBundle() != null) { - httpClient = httpClient.secure((ThrowingConsumer.of((spec) -> configureSsl(spec, settings.sslBundle())))); - } - httpClient = this.httpClientCustomizer.apply(httpClient); - return new ReactorClientHttpRequestFactory(httpClient); - } - - private boolean followRedirects(Redirects redirects) { - return switch (redirects) { - case FOLLOW_WHEN_POSSIBLE, FOLLOW -> true; - case DONT_FOLLOW -> false; - }; - } - - HttpClient applyDefaults(HttpClient httpClient) { - // Aligns with ReactorClientHttpRequestFactory defaults - return httpClient.compress(true); - } - - private void configureSsl(SslContextSpec spec, SslBundle sslBundle) throws SSLException { - SslOptions options = sslBundle.getOptions(); - SslManagerBundle managers = sslBundle.getManagers(); - SslContextBuilder builder = SslContextBuilder.forClient() - .keyManager(managers.getKeyManagerFactory()) - .trustManager(managers.getTrustManagerFactory()) - .ciphers(SslOptions.asSet(options.getCiphers())) - .protocols(options.getEnabledProtocols()); - spec.sslContext(builder.build()); - } - static class Classes { static final String HTTP_CLIENT = "reactor.netty.http.client.HttpClient"; diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/ReactorHttpClientBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/ReactorHttpClientBuilder.java new file mode 100644 index 0000000000..c1817dd1ae --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/ReactorHttpClientBuilder.java @@ -0,0 +1,114 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client; + +import java.time.Duration; +import java.util.function.UnaryOperator; + +import javax.net.ssl.SSLException; + +import io.netty.channel.ChannelOption; +import io.netty.handler.ssl.SslContextBuilder; +import reactor.netty.http.client.HttpClient; +import reactor.netty.tcp.SslProvider.SslContextSpec; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslManagerBundle; +import org.springframework.boot.ssl.SslOptions; +import org.springframework.util.Assert; +import org.springframework.util.function.ThrowingConsumer; + +/** + * Builder that can be used to create a Rector Netty {@link HttpClient}. + * + * @author Phillip Webb + * @author Andy Wilkinson + * @author Scott Frederick + * @since 3.5.0 + */ +public final class ReactorHttpClientBuilder { + + private final UnaryOperator customizer; + + public ReactorHttpClientBuilder() { + this(UnaryOperator.identity()); + } + + private ReactorHttpClientBuilder(UnaryOperator customizer) { + this.customizer = customizer; + } + + /** + * Return a new {@link ReactorHttpClientBuilder} that applies additional customization + * to the underlying {@link HttpClient}. + * @param customizer the customizer to apply + * @return a new {@link ReactorHttpClientBuilder} instance + */ + public ReactorHttpClientBuilder withHttpClientCustomizer(UnaryOperator customizer) { + Assert.notNull(customizer, "'customizer' must not be null"); + return new ReactorHttpClientBuilder((t) -> customizer.apply(this.customizer.apply(t))); + } + + /** + * Build a new {@link HttpClient} instance with the given settings applied. + * @param settings the settings to apply + * @return a new {@link HttpClient} instance + */ + public HttpClient build(HttpClientSettings settings) { + settings = (settings != null) ? settings : HttpClientSettings.DEFAULTS; + HttpClient httpClient = applyDefaults(HttpClient.create()); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + httpClient = map.from(settings::connectTimeout).to(httpClient, this::setConnectTimeout); + httpClient = map.from(settings::readTimeout).to(httpClient, HttpClient::responseTimeout); + httpClient = map.from(settings::redirects).as(this::followRedirects).to(httpClient, HttpClient::followRedirect); + httpClient = map.from(settings::sslBundle).to(httpClient, this::secure); + return this.customizer.apply(httpClient); + } + + HttpClient applyDefaults(HttpClient httpClient) { + // Aligns with Spring Framework defaults + return httpClient.compress(true); + } + + private HttpClient setConnectTimeout(HttpClient httpClient, Duration timeout) { + return httpClient.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, (int) timeout.toMillis()); + } + + private boolean followRedirects(HttpRedirects redirects) { + return switch (redirects) { + case FOLLOW_WHEN_POSSIBLE, FOLLOW -> true; + case DONT_FOLLOW -> false; + }; + } + + private HttpClient secure(HttpClient httpClient, SslBundle sslBundle) { + return httpClient.secure((ThrowingConsumer.of((spec) -> configureSsl(spec, sslBundle)))); + } + + private void configureSsl(SslContextSpec spec, SslBundle sslBundle) throws SSLException { + SslOptions options = sslBundle.getOptions(); + SslManagerBundle managers = sslBundle.getManagers(); + SslContextBuilder builder = SslContextBuilder.forClient() + .keyManager(managers.getKeyManagerFactory()) + .trustManager(managers.getTrustManagerFactory()) + .ciphers(SslOptions.asSet(options.getCiphers())) + .protocols(options.getEnabledProtocols()); + spec.sslContext(builder.build()); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/AbstractClientHttpConnectorBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/AbstractClientHttpConnectorBuilder.java new file mode 100644 index 0000000000..9b4e4fb3ac --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/AbstractClientHttpConnectorBuilder.java @@ -0,0 +1,82 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client.reactive; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +import org.springframework.boot.http.client.HttpClientSettings; +import org.springframework.boot.util.LambdaSafe; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.util.Assert; + +/** + * Internal base class used for {@link ClientHttpConnectorBuilder} implementations. + * + * @param the {@link ClientHttpConnector} type + * @author Phillip Webb + */ +abstract class AbstractClientHttpConnectorBuilder + implements ClientHttpConnectorBuilder { + + private final List> customizers; + + protected AbstractClientHttpConnectorBuilder(List> customizers) { + this.customizers = (customizers != null) ? customizers : Collections.emptyList(); + } + + protected final List> getCustomizers() { + return this.customizers; + } + + protected final List> mergedCustomizers(Consumer customizer) { + Assert.notNull(this.customizers, "'customizer' must not be null"); + return merge(this.customizers, List.of(customizer)); + } + + protected final List> mergedCustomizers(Collection> customizers) { + Assert.notNull(customizers, "'customizers' must not be null"); + Assert.noNullElements(customizers, "'customizers' must not contain null elements"); + return merge(this.customizers, customizers); + } + + private List merge(Collection list, Collection additional) { + List merged = new ArrayList<>(list); + merged.addAll(additional); + return List.copyOf(merged); + } + + @Override + @SuppressWarnings("unchecked") + public final T build(ClientHttpConnectorSettings settings) { + T connector = createClientHttpConnector((settings != null) ? settings : ClientHttpConnectorSettings.defaults()); + LambdaSafe.callbacks(Consumer.class, this.customizers, connector) + .invoke((consumer) -> consumer.accept(connector)); + return connector; + } + + protected abstract T createClientHttpConnector(ClientHttpConnectorSettings settings); + + protected final HttpClientSettings asHttpClientSettings(ClientHttpConnectorSettings settings) { + return (settings != null) ? new HttpClientSettings(settings.redirects(), settings.connectTimeout(), + settings.readTimeout(), settings.sslBundle()) : null; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/ClientHttpConnectorBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/ClientHttpConnectorBuilder.java new file mode 100644 index 0000000000..1046454a1c --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/ClientHttpConnectorBuilder.java @@ -0,0 +1,201 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client.reactive; + +import java.util.Collection; +import java.util.List; +import java.util.function.Consumer; + +import org.springframework.boot.util.LambdaSafe; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.HttpComponentsClientHttpConnector; +import org.springframework.http.client.reactive.JdkClientHttpConnector; +import org.springframework.http.client.reactive.JettyClientHttpConnector; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.util.Assert; + +/** + * Interface used to build a fully configured {@link ClientHttpConnector}. Builders for + * {@link #reactor() Reactor}, {@link #jetty() Jetty}, {@link #httpComponents() Apache + * HTTP Components} and {@link #jdk() JDK} can be obtained using the factory methods on + * this interface. The {@link #of(Class)} method may be used to instantiate based on the + * connector type. + * + * @param the {@link ClientHttpConnector} type + * @author Phillip Webb + * @since 3.5.0 + */ +@FunctionalInterface +public interface ClientHttpConnectorBuilder { + + /** + * Build a default configured {@link ClientHttpConnectorBuilder}. + * @return a default configured {@link ClientHttpConnectorBuilder}. + */ + default T build() { + return build(null); + } + + /** + * Build a fully configured {@link ClientHttpConnector}, applying the given + * {@code settings} if they are provided. + * @param settings the settings to apply or {@code null} + * @return a fully configured {@link ClientHttpConnector}. + */ + T build(ClientHttpConnectorSettings settings); + + /** + * Return a new {@link ClientHttpConnectorBuilder} that applies the given customizer + * to the {@link ClientHttpConnector} after it has been built. + * @param customizer the customizers to apply + * @return a new {@link ClientHttpConnectorBuilder} instance + */ + default ClientHttpConnectorBuilder withCustomizer(Consumer customizer) { + return withCustomizers(List.of(customizer)); + } + + /** + * Return a new {@link ClientHttpConnectorBuilder} that applies the given customizers + * to the {@link ClientHttpConnector} after it has been built. + * @param customizers the customizers to apply + * @return a new {@link ClientHttpConnectorBuilder} instance + */ + @SuppressWarnings("unchecked") + default ClientHttpConnectorBuilder withCustomizers(Collection> customizers) { + Assert.notNull(customizers, "'customizers' must not be null"); + Assert.noNullElements(customizers, "'customizers' must not contain null elements"); + return (settings) -> { + T factory = build(settings); + LambdaSafe.callbacks(Consumer.class, customizers, factory).invoke((consumer) -> consumer.accept(factory)); + return factory; + }; + } + + /** + * Return a {@link HttpComponentsClientHttpConnectorBuilder} that can be used to build + * a {@link HttpComponentsClientHttpConnector}. + * @return a new {@link HttpComponentsClientHttpConnectorBuilder} + */ + static HttpComponentsClientHttpConnectorBuilder httpComponents() { + return new HttpComponentsClientHttpConnectorBuilder(); + } + + /** + * Return a {@link JettyClientHttpConnectorBuilder} that can be used to build a + * {@link JettyClientHttpConnector}. + * @return a new {@link JettyClientHttpConnectorBuilder} + */ + static JettyClientHttpConnectorBuilder jetty() { + return new JettyClientHttpConnectorBuilder(); + } + + /** + * Return a {@link ReactorClientHttpConnectorBuilder} that can be used to build a + * {@link ReactorClientHttpConnector}. + * @return a new {@link ReactorClientHttpConnectorBuilder} + */ + static ReactorClientHttpConnectorBuilder reactor() { + return new ReactorClientHttpConnectorBuilder(); + } + + /** + * Return a {@link JdkClientHttpConnectorBuilder} that can be used to build a + * {@link JdkClientHttpConnector} . + * @return a new {@link JdkClientHttpConnectorBuilder} + */ + static JdkClientHttpConnectorBuilder jdk() { + return new JdkClientHttpConnectorBuilder(); + } + + /** + * Return a new {@link ClientHttpConnectorBuilder} for the given + * {@code requestFactoryType}. The following implementations are supported: + *

    + *
  • {@link ReactorClientHttpConnector}
  • + *
  • {@link JettyClientHttpConnector}
  • + *
  • {@link HttpComponentsClientHttpConnector}
  • + *
  • {@link JdkClientHttpConnector}
  • + *
+ * @param the {@link ClientHttpConnector} type + * @param clientHttpConnectorType the {@link ClientHttpConnector} type + * @return a new {@link ClientHttpConnectorBuilder} + */ + @SuppressWarnings("unchecked") + static ClientHttpConnectorBuilder of(Class clientHttpConnectorType) { + Assert.notNull(clientHttpConnectorType, "'requestFactoryType' must not be null"); + Assert.isTrue(clientHttpConnectorType != ClientHttpConnector.class, + "'clientHttpConnectorType' must be an implementation of ClientHttpConnector"); + if (clientHttpConnectorType == ReactorClientHttpConnector.class) { + return (ClientHttpConnectorBuilder) reactor(); + } + if (clientHttpConnectorType == JettyClientHttpConnector.class) { + return (ClientHttpConnectorBuilder) jetty(); + } + if (clientHttpConnectorType == HttpComponentsClientHttpConnector.class) { + return (ClientHttpConnectorBuilder) httpComponents(); + } + if (clientHttpConnectorType == JdkClientHttpConnector.class) { + return (ClientHttpConnectorBuilder) jdk(); + } + throw new IllegalArgumentException( + "'clientHttpConnectorType' %s is not supported".formatted(clientHttpConnectorType.getName())); + } + + /** + * Detect the most suitable {@link ClientHttpConnectorBuilder} based on the classpath. + * The methods favors builders in the following order: + *
    + *
  1. {@link #reactor()}
  2. + *
  3. {@link #jetty()}
  4. + *
  5. {@link #httpComponents()}
  6. + *
  7. {@link #jdk()}
  8. + *
+ * @return the most suitable {@link ClientHttpConnectorBuilder} for the classpath + */ + static ClientHttpConnectorBuilder detect() { + return detect(null); + } + + /** + * Detect the most suitable {@link ClientHttpConnectorBuilder} based on the classpath. + * The methods favors builders in the following order: + *
    + *
  1. {@link #reactor()}
  2. + *
  3. {@link #jetty()}
  4. + *
  5. {@link #httpComponents()}
  6. + *
  7. {@link #jdk()}
  8. + *
+ * @param classLoader the class loader to use for detection + * @return the most suitable {@link ClientHttpConnectorBuilder} for the classpath + */ + static ClientHttpConnectorBuilder detect(ClassLoader classLoader) { + if (ReactorClientHttpConnectorBuilder.Classes.present(classLoader)) { + return reactor(); + } + if (JettyClientHttpConnectorBuilder.Classes.present(classLoader)) { + return jetty(); + } + if (HttpComponentsClientHttpConnectorBuilder.Classes.present(classLoader)) { + return httpComponents(); + } + if (JdkClientHttpConnectorBuilder.Classes.present(classLoader)) { + return jdk(); + } + throw new IllegalStateException("Unable to detect any ClientHttpConnectorBuilder"); + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/ClientHttpConnectorSettings.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/ClientHttpConnectorSettings.java new file mode 100644 index 0000000000..6a316337e7 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/ClientHttpConnectorSettings.java @@ -0,0 +1,116 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client.reactive; + +import java.time.Duration; + +import org.springframework.boot.http.client.HttpRedirects; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.http.client.reactive.ClientHttpConnector; + +/** + * Settings that can be applied when creating a {@link ClientHttpConnector}. + * + * @param redirects the follow redirect strategy to use or null to redirect whenever the + * underlying library allows it + * @param connectTimeout the connect timeout + * @param readTimeout the read timeout + * @param sslBundle the SSL bundle providing SSL configuration + * @author Phillip Webb + * @since 3.5.0 + * @see ClientHttpConnectorBuilder + */ +public record ClientHttpConnectorSettings(HttpRedirects redirects, Duration connectTimeout, Duration readTimeout, + SslBundle sslBundle) { + + private static final ClientHttpConnectorSettings defaults = new ClientHttpConnectorSettings(null, null, null, null); + + public ClientHttpConnectorSettings { + redirects = (redirects != null) ? redirects : HttpRedirects.FOLLOW_WHEN_POSSIBLE; + } + + /** + * Return a new {@link ClientHttpConnectorSettings} instance with an updated connect + * timeout setting. + * @param connectTimeout the new connect timeout setting + * @return a new {@link ClientHttpConnectorSettings} instance + */ + public ClientHttpConnectorSettings withConnectTimeout(Duration connectTimeout) { + return new ClientHttpConnectorSettings(this.redirects, connectTimeout, this.readTimeout, this.sslBundle); + } + + /** + * Return a new {@link ClientHttpConnectorSettings} instance with an updated read + * timeout setting. + * @param readTimeout the new read timeout setting + * @return a new {@link ClientHttpConnectorSettings} instance + */ + public ClientHttpConnectorSettings withReadTimeout(Duration readTimeout) { + return new ClientHttpConnectorSettings(this.redirects, this.connectTimeout, readTimeout, this.sslBundle); + } + + /** + * Return a new {@link ClientHttpConnectorSettings} instance with an updated connect + * and read timeout setting. + * @param connectTimeout the new connect timeout setting + * @param readTimeout the new read timeout setting + * @return a new {@link ClientHttpConnectorSettings} instance + */ + public ClientHttpConnectorSettings withTimeouts(Duration connectTimeout, Duration readTimeout) { + return new ClientHttpConnectorSettings(this.redirects, connectTimeout, readTimeout, this.sslBundle); + } + + /** + * Return a new {@link ClientHttpConnectorSettings} instance with an updated SSL + * bundle setting. + * @param sslBundle the new SSL bundle setting + * @return a new {@link ClientHttpConnectorSettings} instance + */ + public ClientHttpConnectorSettings withSslBundle(SslBundle sslBundle) { + return new ClientHttpConnectorSettings(this.redirects, this.connectTimeout, this.readTimeout, sslBundle); + } + + /** + * Return a new {@link ClientHttpConnectorSettings} instance with an updated redirect + * setting. + * @param redirects the new redirects setting + * @return a new {@link ClientHttpConnectorSettings} instance + */ + public ClientHttpConnectorSettings withRedirects(HttpRedirects redirects) { + return new ClientHttpConnectorSettings(redirects, this.connectTimeout, this.readTimeout, this.sslBundle); + } + + /** + * Return a new {@link ClientHttpConnectorSettings} using defaults for all settings + * other than the provided SSL bundle. + * @param sslBundle the SSL bundle setting + * @return a new {@link ClientHttpConnectorSettings} instance + */ + public static ClientHttpConnectorSettings ofSslBundle(SslBundle sslBundle) { + return defaults().withSslBundle(sslBundle); + } + + /** + * Use defaults for the {@link ClientHttpConnector} which can differ depending on the + * implementation. + * @return default settings + */ + public static ClientHttpConnectorSettings defaults() { + return defaults; + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilder.java new file mode 100644 index 0000000000..bd8444473d --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilder.java @@ -0,0 +1,146 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client.reactive; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; + +import org.springframework.boot.http.client.HttpComponentsHttpAsyncClientBuilder; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.http.client.reactive.HttpComponentsClientHttpConnector; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * Builder for {@link ClientHttpConnectorBuilder#httpComponents()}. + * + * @author Phillip Webb + * @since 3.5.0 + */ +public final class HttpComponentsClientHttpConnectorBuilder + extends AbstractClientHttpConnectorBuilder { + + private final HttpComponentsHttpAsyncClientBuilder httpClientBuilder; + + HttpComponentsClientHttpConnectorBuilder() { + this(null, new HttpComponentsHttpAsyncClientBuilder()); + } + + private HttpComponentsClientHttpConnectorBuilder(List> customizers, + HttpComponentsHttpAsyncClientBuilder httpClientBuilder) { + super(customizers); + this.httpClientBuilder = httpClientBuilder; + } + + /** + * Return a new {@link HttpComponentsClientHttpConnectorBuilder} that applies + * additional customization to the underlying {@link HttpAsyncClientBuilder}. + * @param httpClientCustomizer the customizer to apply + * @return a new {@link HttpComponentsHttpAsyncClientBuilder} instance + */ + public HttpComponentsClientHttpConnectorBuilder withHttpClientCustomizer( + Consumer httpClientCustomizer) { + Assert.notNull(httpClientCustomizer, "'customizer' must not be null"); + return new HttpComponentsClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withCustomizer(httpClientCustomizer)); + } + + /** + * Return a new {@link HttpComponentsClientHttpConnectorBuilder} that applies + * additional customization to the underlying + * {@link PoolingAsyncClientConnectionManagerBuilder}. + * @param connectionManagerCustomizer the customizer to apply + * @return a new {@link HttpComponentsClientHttpConnectorBuilder} instance + */ + public HttpComponentsClientHttpConnectorBuilder withConnectionManagerCustomizer( + Consumer connectionManagerCustomizer) { + Assert.notNull(connectionManagerCustomizer, "'connectionManagerCustomizer' must not be null"); + return new HttpComponentsClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withConnectionManagerCustomizer(connectionManagerCustomizer)); + } + + /** + * Return a new {@link HttpComponentsClientHttpConnectorBuilder} that applies + * additional customization to the underlying + * {@link org.apache.hc.client5.http.config.ConnectionConfig.Builder}. + * @param connectionConfigCustomizer the customizer to apply + * @return a new {@link HttpComponentsClientHttpConnectorBuilder} instance + */ + public HttpComponentsClientHttpConnectorBuilder withConnectionConfigCustomizer( + Consumer connectionConfigCustomizer) { + Assert.notNull(connectionConfigCustomizer, "'connectionConfigCustomizer' must not be null"); + return new HttpComponentsClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withConnectionConfigCustomizer(connectionConfigCustomizer)); + } + + /** + * Return a new {@link HttpComponentsClientHttpConnectorBuilder} with a replacement + * {@link TlsStrategy} factory. + * @param tlsStrategyFactory the new factory used to create a {@link TlsStrategy} for + * a given {@link SslBundle} + * @return a new {@link HttpComponentsClientHttpConnectorBuilder} instance + */ + public HttpComponentsClientHttpConnectorBuilder withTlsSocketStrategyFactory( + Function tlsStrategyFactory) { + Assert.notNull(tlsStrategyFactory, "'tlsStrategyFactory' must not be null"); + return new HttpComponentsClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withTlsStrategyFactory(tlsStrategyFactory)); + } + + /** + * Return a new {@link HttpComponentsClientHttpConnectorBuilder} that applies + * additional customization to the underlying + * {@link org.apache.hc.client5.http.config.RequestConfig.Builder} used for default + * requests. + * @param defaultRequestConfigCustomizer the customizer to apply + * @return a new {@link HttpComponentsClientHttpConnectorBuilder} instance + */ + public HttpComponentsClientHttpConnectorBuilder withDefaultRequestConfigCustomizer( + Consumer defaultRequestConfigCustomizer) { + Assert.notNull(defaultRequestConfigCustomizer, "'defaultRequestConfigCustomizer' must not be null"); + return new HttpComponentsClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withDefaultRequestConfigCustomizer(defaultRequestConfigCustomizer)); + } + + @Override + protected HttpComponentsClientHttpConnector createClientHttpConnector(ClientHttpConnectorSettings settings) { + CloseableHttpAsyncClient client = this.httpClientBuilder.build(asHttpClientSettings(settings)); + return new HttpComponentsClientHttpConnector(client); + } + + static class Classes { + + static final String HTTP_CLIENTS = "org.apache.hc.client5.http.impl.async.HttpAsyncClients"; + + static final String REACTIVE_RESPONSE_CONSUMER = "org.apache.hc.core5.reactive.ReactiveResponseConsumer"; + + static boolean present(ClassLoader classLoader) { + return ClassUtils.isPresent(HTTP_CLIENTS, classLoader) + && ClassUtils.isPresent(REACTIVE_RESPONSE_CONSUMER, classLoader); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilder.java new file mode 100644 index 0000000000..39b2715f60 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilder.java @@ -0,0 +1,91 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client.reactive; + +import java.net.http.HttpClient; +import java.util.Collection; +import java.util.List; +import java.util.function.Consumer; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.http.client.JdkHttpClientBuilder; +import org.springframework.http.client.reactive.JdkClientHttpConnector; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * Builder for {@link ClientHttpConnectorBuilder#jdk()}. + * + * @author Phillip Webb + * @since 3.5.0 + */ +public final class JdkClientHttpConnectorBuilder extends AbstractClientHttpConnectorBuilder { + + private final JdkHttpClientBuilder httpClientBuilder; + + JdkClientHttpConnectorBuilder() { + this(null, new JdkHttpClientBuilder()); + } + + private JdkClientHttpConnectorBuilder(List> customizers, + JdkHttpClientBuilder httpClientBuilder) { + super(customizers); + this.httpClientBuilder = httpClientBuilder; + } + + @Override + public JdkClientHttpConnectorBuilder withCustomizer(Consumer customizer) { + return new JdkClientHttpConnectorBuilder(mergedCustomizers(customizer), this.httpClientBuilder); + } + + @Override + public JdkClientHttpConnectorBuilder withCustomizers(Collection> customizers) { + return new JdkClientHttpConnectorBuilder(mergedCustomizers(customizers), this.httpClientBuilder); + } + + /** + * Return a new {@link JdkClientHttpConnectorBuilder} that applies additional + * customization to the underlying {@link java.net.http.HttpClient.Builder}. + * @param httpClientCustomizer the customizer to apply + * @return a new {@link JdkClientHttpConnectorBuilder} instance + */ + public JdkClientHttpConnectorBuilder withHttpClientCustomizer(Consumer httpClientCustomizer) { + Assert.notNull(httpClientCustomizer, "'httpClientCustomizer' must not be null"); + return new JdkClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withCustomizer(httpClientCustomizer)); + } + + @Override + protected JdkClientHttpConnector createClientHttpConnector(ClientHttpConnectorSettings settings) { + HttpClient httpClient = this.httpClientBuilder.build(asHttpClientSettings(settings.withReadTimeout(null))); + JdkClientHttpConnector connector = new JdkClientHttpConnector(httpClient); + PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull(); + map.from(settings::readTimeout).to(connector::setReadTimeout); + return connector; + } + + static class Classes { + + static final String HTTP_CLIENT = "java.net.http.HttpClient"; + + static boolean present(ClassLoader classLoader) { + return ClassUtils.isPresent(HTTP_CLIENT, classLoader); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilder.java new file mode 100644 index 0000000000..f6da1a7923 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilder.java @@ -0,0 +1,120 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client.reactive; + +import java.util.Collection; +import java.util.List; +import java.util.function.Consumer; + +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.HttpClientTransport; +import org.eclipse.jetty.io.ClientConnector; + +import org.springframework.boot.http.client.JettyHttpClientBuilder; +import org.springframework.http.client.reactive.JettyClientHttpConnector; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * Builder for {@link ClientHttpConnectorBuilder#jetty()}. + * + * @author Phillip Webb + * @since 3.5.0 + */ +public final class JettyClientHttpConnectorBuilder + extends AbstractClientHttpConnectorBuilder { + + private final JettyHttpClientBuilder httpClientBuilder; + + JettyClientHttpConnectorBuilder() { + this(null, new JettyHttpClientBuilder()); + } + + private JettyClientHttpConnectorBuilder(List> customizers, + JettyHttpClientBuilder httpClientBuilder) { + super(customizers); + this.httpClientBuilder = httpClientBuilder; + } + + @Override + public JettyClientHttpConnectorBuilder withCustomizer(Consumer customizer) { + return new JettyClientHttpConnectorBuilder(mergedCustomizers(customizer), this.httpClientBuilder); + } + + @Override + public JettyClientHttpConnectorBuilder withCustomizers(Collection> customizers) { + return new JettyClientHttpConnectorBuilder(mergedCustomizers(customizers), this.httpClientBuilder); + } + + /** + * Return a new {@link JettyClientHttpConnectorBuilder} that applies additional + * customization to the underlying {@link HttpClient}. + * @param httpClientCustomizer the customizer to apply + * @return a new {@link JettyClientHttpConnectorBuilder} instance + */ + public JettyClientHttpConnectorBuilder withHttpClientCustomizer(Consumer httpClientCustomizer) { + Assert.notNull(httpClientCustomizer, "'httpClientCustomizer' must not be null"); + return new JettyClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withCustomizer(httpClientCustomizer)); + } + + /** + * Return a new {@link JettyClientHttpConnectorBuilder} that applies additional + * customization to the underlying {@link HttpClientTransport}. + * @param httpClientTransportCustomizer the customizer to apply + * @return a new {@link JettyClientHttpConnectorBuilder} instance + */ + public JettyClientHttpConnectorBuilder withHttpClientTransportCustomizer( + Consumer httpClientTransportCustomizer) { + Assert.notNull(httpClientTransportCustomizer, "'httpClientTransportCustomizer' must not be null"); + return new JettyClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withHttpClientTransportCustomizer(httpClientTransportCustomizer)); + } + + /** + * Return a new {@link JettyClientHttpConnectorBuilder} that applies additional + * customization to the underlying {@link ClientConnector}. + * @param clientConnectorCustomizerCustomizer the customizer to apply + * @return a new {@link JettyClientHttpConnectorBuilder} instance + */ + public JettyClientHttpConnectorBuilder withClientConnectorCustomizerCustomizer( + Consumer clientConnectorCustomizerCustomizer) { + Assert.notNull(clientConnectorCustomizerCustomizer, "'clientConnectorCustomizerCustomizer' must not be null"); + return new JettyClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withClientConnectorCustomizerCustomizer(clientConnectorCustomizerCustomizer)); + } + + @Override + protected JettyClientHttpConnector createClientHttpConnector(ClientHttpConnectorSettings settings) { + HttpClient httpClient = this.httpClientBuilder.build(asHttpClientSettings(settings)); + return new JettyClientHttpConnector(httpClient); + } + + static class Classes { + + static final String HTTP_CLIENT = "org.eclipse.jetty.client.HttpClient"; + + static final String REACTIVE_REQUEST = "org.eclipse.jetty.reactive.client.ReactiveRequest"; + + static boolean present(ClassLoader classLoader) { + return ClassUtils.isPresent(HTTP_CLIENT, classLoader) + && ClassUtils.isPresent(REACTIVE_REQUEST, classLoader); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilder.java new file mode 100644 index 0000000000..ee66942694 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilder.java @@ -0,0 +1,91 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client.reactive; + +import java.util.Collection; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.UnaryOperator; + +import reactor.netty.http.client.HttpClient; + +import org.springframework.boot.http.client.ReactorHttpClientBuilder; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * Builder for {@link ClientHttpConnectorBuilder#reactor()}. + * + * @author Phillip Webb + * @since 3.5.0 + */ +public final class ReactorClientHttpConnectorBuilder + extends AbstractClientHttpConnectorBuilder { + + private final ReactorHttpClientBuilder httpClientBuilder; + + ReactorClientHttpConnectorBuilder() { + this(null, new ReactorHttpClientBuilder()); + } + + private ReactorClientHttpConnectorBuilder(List> customizers, + ReactorHttpClientBuilder httpClientBuilder) { + super(customizers); + this.httpClientBuilder = httpClientBuilder; + } + + @Override + public ReactorClientHttpConnectorBuilder withCustomizer(Consumer customizer) { + return new ReactorClientHttpConnectorBuilder(mergedCustomizers(customizer), this.httpClientBuilder); + } + + @Override + public ReactorClientHttpConnectorBuilder withCustomizers( + Collection> customizers) { + return new ReactorClientHttpConnectorBuilder(mergedCustomizers(customizers), this.httpClientBuilder); + } + + /** + * Return a new {@link ReactorClientHttpConnectorBuilder} that applies additional + * customization to the underlying {@link HttpClient}. + * @param httpClientCustomizer the customizer to apply + * @return a new {@link ReactorClientHttpConnectorBuilder} instance + */ + public ReactorClientHttpConnectorBuilder withHttpClientCustomizer(UnaryOperator httpClientCustomizer) { + Assert.notNull(httpClientCustomizer, "'httpClientCustomizer' must not be null"); + return new ReactorClientHttpConnectorBuilder(getCustomizers(), + this.httpClientBuilder.withHttpClientCustomizer(httpClientCustomizer)); + } + + @Override + protected ReactorClientHttpConnector createClientHttpConnector(ClientHttpConnectorSettings settings) { + HttpClient httpClient = this.httpClientBuilder.build(asHttpClientSettings(settings)); + return new ReactorClientHttpConnector(httpClient); + } + + static class Classes { + + static final String HTTP_CLIENT = "reactor.netty.http.client.HttpClient"; + + static boolean present(ClassLoader classLoader) { + return ClassUtils.isPresent(HTTP_CLIENT, classLoader); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/package-info.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/package-info.java new file mode 100644 index 0000000000..783b2f3904 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/http/client/reactive/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Client-side reactive HTTP support classes. + */ +package org.springframework.boot.http.client.reactive; diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilderTests.java index 9305867243..bfa10c5091 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilderTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/HttpComponentsClientHttpRequestFactoryBuilderTests.java @@ -38,7 +38,8 @@ import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; /** - * Tests for {@link HttpComponentsClientHttpRequestFactoryBuilder}. + * Tests for {@link HttpComponentsClientHttpRequestFactoryBuilder} and + * {@link HttpComponentsHttpClientBuilder}. * * @author Phillip Webb * @author Andy Wilkinson diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilderTests.java index 4187708d61..876b9cd3b1 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilderTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/JdkClientHttpRequestFactoryBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,7 +25,7 @@ import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.test.util.ReflectionTestUtils; /** - * Tests for {@link JdkClientHttpRequestFactoryBuilder}. + * Tests for {@link JdkClientHttpRequestFactoryBuilder} and {@link JdkHttpClientBuilder}. * * @author Phillip Webb */ diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilderTests.java index d3a0b883b4..6fe17304d2 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilderTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/JettyClientHttpRequestFactoryBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,7 +25,8 @@ import org.springframework.http.client.JettyClientHttpRequestFactory; import org.springframework.test.util.ReflectionTestUtils; /** - * Tests for {@link JettyClientHttpRequestFactoryBuilder}. + * Tests for {@link JettyClientHttpRequestFactoryBuilder} and + * {@link JettyHttpClientBuilder}. * * @author Phillip Webb */ diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/ReactorClientHttpRequestFactoryBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/ReactorClientHttpRequestFactoryBuilderTests.java index 3025e698f4..858014132b 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/ReactorClientHttpRequestFactoryBuilderTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/ReactorClientHttpRequestFactoryBuilderTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,7 +31,8 @@ import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; /** - * Tests for {@link ReactorClientHttpRequestFactoryBuilder}. + * Tests for {@link ReactorClientHttpRequestFactoryBuilder} and + * {@link ReactorHttpClientBuilder}. * * @author Phillip Webb * @author Andy Wilkinson diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/AbstractClientHttpConnectorBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/AbstractClientHttpConnectorBuilderTests.java new file mode 100644 index 0000000000..7afc1f9c4a --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/AbstractClientHttpConnectorBuilderTests.java @@ -0,0 +1,255 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client.reactive; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.Duration; +import java.util.Set; +import java.util.function.Function; + +import javax.net.ssl.SSLHandshakeException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import org.springframework.boot.http.client.HttpRedirects; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundleKey; +import org.springframework.boot.ssl.SslOptions; +import org.springframework.boot.ssl.jks.JksSslStoreBundle; +import org.springframework.boot.ssl.jks.JksSslStoreDetails; +import org.springframework.boot.testsupport.classpath.resources.WithPackageResources; +import org.springframework.boot.testsupport.web.servlet.DirtiesUrlFactories; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.Ssl; +import org.springframework.boot.web.server.Ssl.ClientAuth; +import org.springframework.boot.web.server.WebServer; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.ExchangeFunctions; +import org.springframework.web.reactive.function.client.WebClientRequestException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +/** + * Base class for {@link ClientHttpConnectorBuilder} tests. + * + * @param The {@link ClientHttpConnector} type + * @author Phillip Webb + * @author Andy Wilkinson + */ +@DirtiesUrlFactories +abstract class AbstractClientHttpConnectorBuilderTests { + + private static final Function ALWAYS_FOUND = (method) -> HttpStatus.FOUND; + + private final Class connectorType; + + private final ClientHttpConnectorBuilder builder; + + AbstractClientHttpConnectorBuilderTests(Class connectorType, ClientHttpConnectorBuilder builder) { + this.connectorType = connectorType; + this.builder = builder; + } + + @Test + void buildReturnsConnectorOfExpectedType() { + T connector = this.builder.build(); + assertThat(connector).isInstanceOf(this.connectorType); + } + + @Test + void buildWhenHasConnectTimeout() { + ClientHttpConnectorSettings settings = ClientHttpConnectorSettings.defaults() + .withConnectTimeout(Duration.ofSeconds(60)); + T connector = this.builder.build(settings); + assertThat(connectTimeout(connector)).isEqualTo(Duration.ofSeconds(60).toMillis()); + } + + @Test + void buildWhenHadReadTimeout() { + ClientHttpConnectorSettings settings = ClientHttpConnectorSettings.defaults() + .withReadTimeout(Duration.ofSeconds(120)); + T connector = this.builder.build(settings); + assertThat(readTimeout(connector)).isEqualTo(Duration.ofSeconds(120).toMillis()); + } + + @ParameterizedTest + @WithPackageResources("test.jks") + @ValueSource(strings = { "GET", "POST" }) + void connectWithSslBundle(String httpMethod) throws Exception { + TomcatServletWebServerFactory webServerFactory = new TomcatServletWebServerFactory(0); + webServerFactory.setSsl(ssl()); + WebServer webServer = webServerFactory + .getWebServer((context) -> context.addServlet("test", TestServlet.class).addMapping("/")); + try { + webServer.start(); + int port = webServer.getPort(); + URI uri = new URI("https://localhost:%s".formatted(port)); + ClientHttpConnector insecureConnector = this.builder.build(); + ClientRequest insecureRequest = createRequest(httpMethod, uri); + assertThatExceptionOfType(WebClientRequestException.class) + .isThrownBy(() -> getResponse(insecureConnector, insecureRequest)) + .withCauseInstanceOf(SSLHandshakeException.class); + ClientHttpConnector secureConnector = this.builder + .build(ClientHttpConnectorSettings.ofSslBundle(sslBundle())); + ClientRequest secureRequest = createRequest(httpMethod, uri); + ClientResponse secureResponse = getResponse(secureConnector, secureRequest); + assertThat(secureResponse.bodyToMono(String.class).block()) + .contains("Received " + httpMethod + " request to /"); + } + finally { + webServer.stop(); + } + } + + @ParameterizedTest + @WithPackageResources("test.jks") + @ValueSource(strings = { "GET", "POST" }) + void connectWithSslBundleAndOptionsMismatch(String httpMethod) throws Exception { + TomcatServletWebServerFactory webServerFactory = new TomcatServletWebServerFactory(0); + webServerFactory.setSsl(ssl("TLS_AES_128_GCM_SHA256")); + WebServer webServer = webServerFactory + .getWebServer((context) -> context.addServlet("test", TestServlet.class).addMapping("/")); + try { + webServer.start(); + int port = webServer.getPort(); + URI uri = new URI("https://localhost:%s".formatted(port)); + ClientHttpConnector secureConnector = this.builder.build(ClientHttpConnectorSettings + .ofSslBundle(sslBundle(SslOptions.of(Set.of("TLS_AES_256_GCM_SHA384"), null)))); + ClientRequest secureRequest = createRequest(httpMethod, uri); + assertThatExceptionOfType(WebClientRequestException.class) + .isThrownBy(() -> getResponse(secureConnector, secureRequest)) + .withCauseInstanceOf(SSLHandshakeException.class); + } + finally { + webServer.stop(); + } + } + + @ParameterizedTest + @ValueSource(strings = { "GET", "POST", "PUT", "PATCH", "DELETE" }) + void redirectDefault(String httpMethod) throws Exception { + testRedirect(null, HttpMethod.valueOf(httpMethod), this::getExpectedRedirect); + } + + @ParameterizedTest + @ValueSource(strings = { "GET", "POST", "PUT", "PATCH", "DELETE" }) + void redirectFollow(String httpMethod) throws Exception { + ClientHttpConnectorSettings settings = ClientHttpConnectorSettings.defaults() + .withRedirects(HttpRedirects.FOLLOW); + testRedirect(settings, HttpMethod.valueOf(httpMethod), this::getExpectedRedirect); + } + + @ParameterizedTest + @ValueSource(strings = { "GET", "POST", "PUT", "PATCH", "DELETE" }) + void redirectDontFollow(String httpMethod) throws Exception { + ClientHttpConnectorSettings settings = ClientHttpConnectorSettings.defaults() + .withRedirects(HttpRedirects.DONT_FOLLOW); + testRedirect(settings, HttpMethod.valueOf(httpMethod), ALWAYS_FOUND); + } + + protected final void testRedirect(ClientHttpConnectorSettings settings, HttpMethod httpMethod, + Function expectedStatusForMethod) throws URISyntaxException { + HttpStatus expectedStatus = expectedStatusForMethod.apply(httpMethod); + TomcatServletWebServerFactory webServerFactory = new TomcatServletWebServerFactory(0); + WebServer webServer = webServerFactory + .getWebServer((context) -> context.addServlet("test", TestServlet.class).addMapping("/")); + try { + webServer.start(); + int port = webServer.getPort(); + URI uri = new URI("http://localhost:%s".formatted(port) + "/redirect"); + ClientHttpConnector connector = this.builder.build(settings); + ClientRequest request = createRequest(httpMethod, uri); + ClientResponse response = getResponse(connector, request); + assertThat(response.statusCode()).isEqualTo(expectedStatus); + if (expectedStatus == HttpStatus.OK) { + assertThat(response.bodyToMono(String.class).block()).contains("request to /redirected"); + } + } + finally { + webServer.stop(); + } + } + + private ClientRequest createRequest(String httpMethod, URI uri) { + return createRequest(HttpMethod.valueOf(httpMethod), uri); + } + + private ClientRequest createRequest(HttpMethod httpMethod, URI uri) { + return ClientRequest.create(httpMethod, uri).build(); + } + + private ClientResponse getResponse(ClientHttpConnector connector, ClientRequest request) { + return ExchangeFunctions.create(connector).exchange(request).block(); + } + + private Ssl ssl(String... ciphers) { + Ssl ssl = new Ssl(); + ssl.setClientAuth(ClientAuth.NEED); + ssl.setKeyPassword("password"); + ssl.setKeyStore("classpath:test.jks"); + ssl.setTrustStore("classpath:test.jks"); + if (ciphers.length > 0) { + ssl.setCiphers(ciphers); + } + return ssl; + } + + protected final SslBundle sslBundle() { + return sslBundle(SslOptions.NONE); + } + + protected final SslBundle sslBundle(SslOptions sslOptions) { + JksSslStoreDetails storeDetails = JksSslStoreDetails.forLocation("classpath:test.jks"); + JksSslStoreBundle stores = new JksSslStoreBundle(storeDetails, storeDetails); + return SslBundle.of(stores, SslBundleKey.of("password"), sslOptions); + } + + protected HttpStatus getExpectedRedirect(HttpMethod httpMethod) { + return HttpStatus.OK; + } + + protected abstract long connectTimeout(T connector); + + protected abstract long readTimeout(T connector); + + public static class TestServlet extends HttpServlet { + + @Override + public void service(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException { + if ("/redirect".equals(req.getRequestURI())) { + res.sendRedirect("/redirected"); + return; + } + res.getWriter().println("Received " + req.getMethod() + " request to " + req.getRequestURI()); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/ClientHttpConnectorBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/ClientHttpConnectorBuilderTests.java new file mode 100644 index 0000000000..a2132ab038 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/ClientHttpConnectorBuilderTests.java @@ -0,0 +1,158 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client.reactive; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.boot.testsupport.classpath.ClassPathExclusions; +import org.springframework.http.HttpMethod; +import org.springframework.http.client.reactive.ClientHttpConnector; +import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.http.client.reactive.ClientHttpResponse; +import org.springframework.http.client.reactive.HttpComponentsClientHttpConnector; +import org.springframework.http.client.reactive.JdkClientHttpConnector; +import org.springframework.http.client.reactive.JettyClientHttpConnector; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +/** + * Tests for {@link ClientHttpConnectorBuilder}. + * + * @author Phillip Webb + */ +class ClientHttpConnectorBuilderTests { + + @Test + void withCustomizerAppliesCustomizers() { + ClientHttpConnectorBuilder builder = (settings) -> new JdkClientHttpConnector(); + builder = builder.withCustomizer(this::setJdkReadTimeout); + JdkClientHttpConnector connector = builder.build(null); + assertThat(connector).extracting("readTimeout").isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void withCustomizersAppliesCustomizers() { + ClientHttpConnectorBuilder builder = (settings) -> new JdkClientHttpConnector(); + builder = builder.withCustomizers(List.of(this::setJdkReadTimeout)); + JdkClientHttpConnector connector = builder.build(null); + assertThat(connector).extracting("readTimeout").isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void reactorReturnsReactorFactoryBuilder() { + assertThat(ClientHttpConnectorBuilder.reactor()).isInstanceOf(ReactorClientHttpConnectorBuilder.class); + } + + @Test + void jettyReturnsJettyFactoryBuilder() { + assertThat(ClientHttpConnectorBuilder.jetty()).isInstanceOf(JettyClientHttpConnectorBuilder.class); + } + + @Test + void httpComponentsReturnsHttpComponentsFactoryBuilder() { + assertThat(ClientHttpConnectorBuilder.httpComponents()) + .isInstanceOf(HttpComponentsClientHttpConnectorBuilder.class); + } + + @Test + void jdkReturnsJdkFactoryBuilder() { + assertThat(ClientHttpConnectorBuilder.jdk()).isInstanceOf(JdkClientHttpConnectorBuilder.class); + } + + @Test + void ofWhenExactlyClientHttpRequestFactoryTypeThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> ClientHttpConnectorBuilder.of(ClientHttpConnector.class)) + .withMessage("'clientHttpConnectorType' must be an implementation of ClientHttpConnector"); + } + + @Test + void ofWhenReactorFactoryReturnsReactorFactoryBuilder() { + assertThat(ClientHttpConnectorBuilder.of(ReactorClientHttpConnector.class)) + .isInstanceOf(ReactorClientHttpConnectorBuilder.class); + } + + @Test + void ofWhenJettyFactoryReturnsReactorFactoryBuilder() { + assertThat(ClientHttpConnectorBuilder.of(JettyClientHttpConnector.class)) + .isInstanceOf(JettyClientHttpConnectorBuilder.class); + } + + @Test + void ofWhenHttpComponentsFactoryReturnsHttpComponentsFactoryBuilder() { + assertThat(ClientHttpConnectorBuilder.of(HttpComponentsClientHttpConnector.class)) + .isInstanceOf(HttpComponentsClientHttpConnectorBuilder.class); + } + + @Test + void ofWhenJdkFactoryReturnsJdkFactoryBuilder() { + assertThat(ClientHttpConnectorBuilder.of(JdkClientHttpConnector.class)) + .isInstanceOf(JdkClientHttpConnectorBuilder.class); + } + + @Test + void ofWhenUnknownTypeThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> ClientHttpConnectorBuilder.of(TestClientHttpConnector.class)) + .withMessage("'clientHttpConnectorType' " + TestClientHttpConnector.class.getName() + " is not supported"); + } + + @Test + void detectWhenReactor() { + assertThat(ClientHttpConnectorBuilder.detect()).isInstanceOf(ReactorClientHttpConnectorBuilder.class); + } + + @Test + @ClassPathExclusions({ "reactor-netty-http-*.jar" }) + void detectWhenJetty() { + assertThat(ClientHttpConnectorBuilder.detect()).isInstanceOf(JettyClientHttpConnectorBuilder.class); + } + + @Test + @ClassPathExclusions({ "reactor-netty-http-*.jar", "jetty-client-*.jar" }) + void detectWhenHttpComponents() { + assertThat(ClientHttpConnectorBuilder.detect()).isInstanceOf(HttpComponentsClientHttpConnectorBuilder.class); + } + + @Test + @ClassPathExclusions({ "reactor-netty-http-*.jar", "jetty-client-*.jar", "httpclient5-*.jar" }) + void detectWhenJdk() { + assertThat(ClientHttpConnectorBuilder.detect()).isInstanceOf(JdkClientHttpConnectorBuilder.class); + } + + private void setJdkReadTimeout(JdkClientHttpConnector factory) { + factory.setReadTimeout(Duration.ofSeconds(5)); + } + + public static class TestClientHttpConnector implements ClientHttpConnector { + + @Override + public Mono connect(HttpMethod method, URI uri, + Function> requestCallback) { + return null; + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilderTests.java new file mode 100644 index 0000000000..95784ddda4 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/HttpComponentsClientHttpConnectorBuilderTests.java @@ -0,0 +1,115 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client.reactive; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import org.apache.hc.client5.http.HttpRoute; +import org.apache.hc.client5.http.async.HttpAsyncClient; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; +import org.apache.hc.core5.function.Resolver; +import org.apache.hc.core5.http.nio.ssl.TlsStrategy; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.http.client.HttpComponentsHttpAsyncClientBuilder; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.testsupport.classpath.resources.WithPackageResources; +import org.springframework.http.client.reactive.HttpComponentsClientHttpConnector; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link HttpComponentsClientHttpConnectorBuilder} and + * {@link HttpComponentsHttpAsyncClientBuilder}. + * + * @author Phillip Webb + */ +class HttpComponentsClientHttpConnectorBuilderTests + extends AbstractClientHttpConnectorBuilderTests { + + HttpComponentsClientHttpConnectorBuilderTests() { + super(HttpComponentsClientHttpConnector.class, ClientHttpConnectorBuilder.httpComponents()); + } + + @Test + void withCustomizers() { + TestCustomizer httpClientCustomizer1 = new TestCustomizer<>(); + TestCustomizer httpClientCustomizer2 = new TestCustomizer<>(); + TestCustomizer connectionManagerCustomizer = new TestCustomizer<>(); + TestCustomizer connectionConfigCustomizer1 = new TestCustomizer<>(); + TestCustomizer connectionConfigCustomizer2 = new TestCustomizer<>(); + TestCustomizer defaultRequestConfigCustomizer = new TestCustomizer<>(); + TestCustomizer defaultRequestConfigCustomizer1 = new TestCustomizer<>(); + ClientHttpConnectorBuilder.httpComponents() + .withHttpClientCustomizer(httpClientCustomizer1) + .withHttpClientCustomizer(httpClientCustomizer2) + .withConnectionManagerCustomizer(connectionManagerCustomizer) + .withConnectionConfigCustomizer(connectionConfigCustomizer1) + .withConnectionConfigCustomizer(connectionConfigCustomizer2) + .withDefaultRequestConfigCustomizer(defaultRequestConfigCustomizer) + .withDefaultRequestConfigCustomizer(defaultRequestConfigCustomizer1) + .build(); + httpClientCustomizer1.assertCalled(); + httpClientCustomizer2.assertCalled(); + connectionManagerCustomizer.assertCalled(); + connectionConfigCustomizer1.assertCalled(); + connectionConfigCustomizer2.assertCalled(); + defaultRequestConfigCustomizer.assertCalled(); + defaultRequestConfigCustomizer1.assertCalled(); + } + + @Test + @WithPackageResources("test.jks") + void withTlsSocketStrategyFactory() { + ClientHttpConnectorSettings settings = ClientHttpConnectorSettings.ofSslBundle(sslBundle()); + List bundles = new ArrayList<>(); + Function tlsSocketStrategyFactory = (bundle) -> { + bundles.add(bundle); + return (sessionLayer, host, localAddress, remoteAddress, attachment, handshakeTimeout) -> false; + }; + ClientHttpConnectorBuilder.httpComponents() + .withTlsSocketStrategyFactory(tlsSocketStrategyFactory) + .build(settings); + assertThat(bundles).contains(settings.sslBundle()); + } + + @Override + protected long connectTimeout(HttpComponentsClientHttpConnector connector) { + return getConnectorConfig(connector).getConnectTimeout().toMilliseconds(); + } + + @Override + protected long readTimeout(HttpComponentsClientHttpConnector connector) { + return getConnectorConfig(connector).getSocketTimeout().toMilliseconds(); + } + + @SuppressWarnings("unchecked") + private ConnectionConfig getConnectorConfig(HttpComponentsClientHttpConnector connector) { + HttpAsyncClient httpClient = (HttpAsyncClient) ReflectionTestUtils.getField(connector, "client"); + Object manager = ReflectionTestUtils.getField(httpClient, "manager"); + ConnectionConfig connectorConfig = ((Resolver) ReflectionTestUtils + .getField(manager, "connectionConfigResolver")).resolve(null); + return connectorConfig; + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilderTests.java new file mode 100644 index 0000000000..4187e4720e --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/JdkClientHttpConnectorBuilderTests.java @@ -0,0 +1,64 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client.reactive; + +import java.net.http.HttpClient; +import java.time.Duration; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.JdkHttpClientBuilder; +import org.springframework.http.client.reactive.JdkClientHttpConnector; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Tests for {@link JdkClientHttpConnectorBuilder} and {@link JdkHttpClientBuilder}. + * + * @author Phillip Webb + */ +class JdkClientHttpConnectorBuilderTests extends AbstractClientHttpConnectorBuilderTests { + + JdkClientHttpConnectorBuilderTests() { + super(JdkClientHttpConnector.class, ClientHttpConnectorBuilder.jdk()); + } + + @Test + void withCustomizers() { + TestCustomizer httpClientCustomizer1 = new TestCustomizer<>(); + TestCustomizer httpClientCustomizer2 = new TestCustomizer<>(); + ClientHttpRequestFactoryBuilder.jdk() + .withHttpClientCustomizer(httpClientCustomizer1) + .withHttpClientCustomizer(httpClientCustomizer2) + .build(); + httpClientCustomizer1.assertCalled(); + httpClientCustomizer2.assertCalled(); + } + + @Override + protected long connectTimeout(JdkClientHttpConnector connector) { + HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient"); + return httpClient.connectTimeout().get().toMillis(); + } + + @Override + protected long readTimeout(JdkClientHttpConnector connector) { + Duration readTimeout = (Duration) ReflectionTestUtils.getField(connector, "readTimeout"); + return readTimeout.toMillis(); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilderTests.java new file mode 100644 index 0000000000..c93ef6ece6 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/JettyClientHttpConnectorBuilderTests.java @@ -0,0 +1,71 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client.reactive; + +import java.time.Duration; + +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.HttpClientTransport; +import org.eclipse.jetty.io.ClientConnector; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.JettyHttpClientBuilder; +import org.springframework.http.client.reactive.JettyClientHttpConnector; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * Tests for {@link JettyClientHttpConnectorBuilder} and {@link JettyHttpClientBuilder}. + * + * @author Phillip Webb + */ +class JettyClientHttpConnectorBuilderTests extends AbstractClientHttpConnectorBuilderTests { + + JettyClientHttpConnectorBuilderTests() { + super(JettyClientHttpConnector.class, ClientHttpConnectorBuilder.jetty()); + } + + @Test + void withCustomizers() { + TestCustomizer httpClientCustomizer1 = new TestCustomizer<>(); + TestCustomizer httpClientCustomizer2 = new TestCustomizer<>(); + TestCustomizer httpClientTransportCustomizer = new TestCustomizer<>(); + TestCustomizer clientConnectorCustomizerCustomizer = new TestCustomizer<>(); + ClientHttpRequestFactoryBuilder.jetty() + .withHttpClientCustomizer(httpClientCustomizer1) + .withHttpClientCustomizer(httpClientCustomizer2) + .withHttpClientTransportCustomizer(httpClientTransportCustomizer) + .withClientConnectorCustomizerCustomizer(clientConnectorCustomizerCustomizer) + .build(); + httpClientCustomizer1.assertCalled(); + httpClientCustomizer2.assertCalled(); + httpClientTransportCustomizer.assertCalled(); + clientConnectorCustomizerCustomizer.assertCalled(); + } + + @Override + protected long connectTimeout(JettyClientHttpConnector connector) { + return ((HttpClient) ReflectionTestUtils.getField(connector, "httpClient")).getConnectTimeout(); + } + + @Override + protected long readTimeout(JettyClientHttpConnector connector) { + HttpClient httpClient = (HttpClient) ReflectionTestUtils.getField(connector, "httpClient"); + return ((Duration) ReflectionTestUtils.getField(httpClient, "readTimeout")).toMillis(); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilderTests.java new file mode 100644 index 0000000000..ff5caf46be --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/ReactorClientHttpConnectorBuilderTests.java @@ -0,0 +1,79 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.http.client.reactive; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.UnaryOperator; + +import io.netty.channel.ChannelOption; +import org.junit.jupiter.api.Test; +import reactor.netty.http.client.HttpClient; + +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.ReactorHttpClientBuilder; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ReactorClientHttpConnectorBuilder} and + * {@link ReactorHttpClientBuilder}. + * + * @author Phillip Webb + */ +class ReactorClientHttpConnectorBuilderTests + extends AbstractClientHttpConnectorBuilderTests { + + ReactorClientHttpConnectorBuilderTests() { + super(ReactorClientHttpConnector.class, ClientHttpConnectorBuilder.reactor()); + } + + @Test + void withCustomizers() { + List httpClients = new ArrayList<>(); + UnaryOperator httpClientCustomizer1 = (httpClient) -> { + httpClients.add(httpClient); + return httpClient; + }; + UnaryOperator httpClientCustomizer2 = (httpClient) -> { + httpClients.add(httpClient); + return httpClient; + }; + ClientHttpRequestFactoryBuilder.reactor() + .withHttpClientCustomizer(httpClientCustomizer1) + .withHttpClientCustomizer(httpClientCustomizer2) + .build(); + assertThat(httpClients).hasSize(2); + } + + @Override + protected long connectTimeout(ReactorClientHttpConnector connector) { + return (int) ((HttpClient) ReflectionTestUtils.getField(connector, "httpClient")).configuration() + .options() + .get(ChannelOption.CONNECT_TIMEOUT_MILLIS); + } + + @Override + protected long readTimeout(ReactorClientHttpConnector connector) { + return (int) ((HttpClient) ReflectionTestUtils.getField(connector, "httpClient")).configuration() + .responseTimeout() + .toMillis(); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactory.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/TestCustomizer.java similarity index 50% rename from spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactory.java rename to spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/TestCustomizer.java index fa3601a8c2..66950f199d 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/function/client/ClientHttpConnectorFactory.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/http/client/reactive/TestCustomizer.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2023 the original author or authors. + * Copyright 2012-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,24 +14,29 @@ * limitations under the License. */ -package org.springframework.boot.autoconfigure.web.reactive.function.client; +package org.springframework.boot.http.client.reactive; -import org.springframework.boot.ssl.SslBundle; -import org.springframework.http.client.reactive.ClientHttpConnector; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; /** - * Internal factory used to create {@link ClientHttpConnector} instances. + * Test customizer that can assert that it has been called. * - * @param the {@link ClientHttpConnector} type + * @param type being customized * @author Phillip Webb */ -@FunctionalInterface -interface ClientHttpConnectorFactory { +class TestCustomizer implements Consumer { - default T createClientHttpConnector() { - return createClientHttpConnector(null); + private boolean called; + + @Override + public void accept(T t) { + this.called = true; } - T createClientHttpConnector(SslBundle sslBundle); + void assertCalled() { + assertThat(this.called).isTrue(); + } } diff --git a/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/http/client/reactive/test.jks b/spring-boot-project/spring-boot/src/test/resources/org/springframework/boot/http/client/reactive/test.jks new file mode 100644 index 0000000000000000000000000000000000000000..74279d80fbac881c51e7a0fadebd9832e904240d GIT binary patch literal 6536 zcmeI0c`(~++sBhg1SKS>wY8N}TkN5SB3f&Rs-^bOG?ZFmS7NK3T3aeDe?O zAP7VcpyJpH%awavkxaDo=c06?wwZvy6A%E>Rptbeo|eWGOv?%~HIZRO!hidqV`V_& zT%E7G6WyF~G6*EgZ$T(4Jr?Kc&adI>?JmQ7ywGusftA(_S{~0*JN^A0acK`f~ zhLzsc-4W;N0YU&Y z;Xq~pG91VN3I_rK^%hFNu#-#uchOu0C81Yb1T$PF^G9e`u9?bJUu$OJQlq5tPF`o{ zC3MUU2pEf8zgeJuRSUD9YS`S)+l=JqiT0y?MvJPeTH!L9x9PNINX*lr6)fObjirmd zd20m0)h4Qz*qk{G=v32|qDC&WOEAyiaRA;`y6=M~8k3eqA7)Or`i(wOJzbF}=bgRI zSou~_x|~%4tmb~b?MY3tZSY9NMb%(tRMQYE?kk5?vW<2$j~?2>GqVDJCf+{Bu3AvA z=3V?q32>@J$gZc%y73(BD^q|rBX?(;j2_V~hd7hrHM^@0KOGoHpWLBoEQt8({;~@j z4UA8Gg$@@<0?_~gz&s-)26^guw@<;qC&6rH87)h$y*5_LS%c_Xn}OJSYu1mE62DEu z!NN$P@ISXZn&r4%|2lxt;E>~9{cFjOd+@mAKmdcblT_+MejJ`A$v0+V|&vU(Knyb-_UPUUok+VWdi<6$iGN~_!v|7168uQZBsX`dnWFp zDj6`y6FoFx6I>ToAswf;V_Y~?Nq6bB3`_6XfGjljNyaHeIK8`YGhG83nLX}{Hb2Yd zz#djXZKPex6<#SGMt^6f?v4>hF`wzw^Y+-Gk*|jex{Y$m*)Qh1_5JF#AbIPF+*luV zF>x~Ol6MD>RPur6^;*ajzgCec+%b+ayO<2mYN-0sv-6DHI`;0dT!{+@CeSiZVZ`7( zGP5=W52Sw`!}$5L%#=z}woIm>NHA0>)2rF{Z?`YYSPsvr6k4f4fQCT&8K-G#$sG3%A< zxpZ^Yx|?;wAs(V;g8f$U@@KBSnH6bmF_*Tu?6=#l^O?Z1?15TkytxuZeyi2nY8n5w zcK|K*kU!CUj)PZ-&~XhnPuS!sJqsSkExz4~5pcG3W9p`12!IH88KK5M7R zF_|OSdqiqM&A+{6K5$-t6?x?KAsQR)$WXZ$IcvxJnW&0=#9A;N*A(#wf z`aCKNX^n1;2%8WdTH{SiQ2+d`6kINwK<`kjAAPX6EukBSw;q@`iH1A*49{4ZY@UB{ znLS`>`G>$>K@Hl3)G5#cU7U=mHLPl#8`L}3A#g{rb}%?4p&aujNXtvt^F_5Y=i+T2 zRg&wtg_!|amO4}fi+Ad(@n&#Jw*R(FDP3z;(%iBaV3r8uW;`ObW;^+E3w{<_fK8;f za9AoE&u~~a&MRm=y(zM4%5pf-T+yr7@Bs1pMuC*xOp)k1HaR2lG)+5|tG6@&ubI0= z=IDHHS5kuIyAXfv&`y%C*dKQfNtCBOD|@fvJI%tAJ-kD0i=PIp=jLWi=Ld@QRFmXQ zd=k*Dhc!B=n>}o^gEi(deUV*Jr=}8h{LEb?%BYxAl$%u58C6dyover7MTgJugn4qO zreCn}PMMxNm6vG<$a=lgAqG#%=Xow)46?7qZ(6-rkTYl}+{XOf* zPMOGrjG6*Pu*yC$H%)x7+6E?8*#8RQMT+rBn&UCAT;wqW0HJCbZhGS%zuKnr3bZiL zo-fq&0&P?0-h6ne{7i(R)4J`{E)xn24~JjyKXA(T$nA$&VaaM`J>;y!qL-OZHL~V} zI`h-c5Tjxr_+%?Gan{@u?Xtoq)n(GJE$9Wm+_s79qJO7x>t^iPV)q&v^GU7d6rFX< z0>-Jf?Xt@QZtZ=FjA_{^g1X_F*uJs+hbrVZ=j99O0!w<_UecBBZ|X0;3eWYhq8S>3 z^BHZQLkf6Mo*;$-qY4Cx%Hu8w2r;6aK|7^J4n1UZ0)g4T$I@<+v~?f;ia$%WE4gc1 z;xeN)U-a8}S;>y0p`eO^Mv{Dcs%diRw;Rv5`za|?yQ!ZW@iH!$Y{w3}AvE(d=#l2w zNs_W=jq8AGK&xH!pb5))IX0AVdtU_(!dNa|+tF!_CD*WEr`zV5vO;gO*-JFfpZEOQhQ_B)J5b;G_cLN~qJ~W{ zVQfCGbc`&bpJ~nh;Ol|TvsB8L6}I0G$DeHTe`BqmZ1aM2yxXONbAWjG6@b!2Y9M=ic3T z(e|(f{lgSy2xToEQGKSp(Ro`XnicT+MtEonb_rDG#v#th?#DKyWu?mFj$&aLtI&qilAXJh4 zuiN!PTt~1+vr?EaHvaHWMpzVn5TuH?ea07uYZB62Yd}+|)Ga0Zxr%TizZMLs>N4s?`SRLbcj$qZ$azx?@TyaOz{m6YG+$W(m3nbt-UO` zv2FJ(#p2Mw-xImTIoFE{Olp2w;K%XE|GdN4w=U1a&$a4zcK1HK-wXMu!UNBbD7=UB%q=V}k|yh_hPdd>h}8UtEb`*lZ>e_0e{53g**(170 z;~+?qu(tCd`<5DBT$`j_h7dl?QYe__Elw+5c}LZ2UE}1VGQ32s=lD&xe6n7R_NmS8 z>b+sZKN>Na`quY=9$}cyWM-N8&}Tfj?Md%G4)lWu+7`5v%>Fj4gyGFBQLgijIrO_9 zRQeJV#Oc~>al9Ag>EL`$2cIm64vMAtlnz7|uIkg9(z|UO>0KLzg(_!b1Mg-|vi6_X zW)J;>Z?~70qJs)dNm(UepaSgj0gt3u1n3;K5og6O%JuSoExD0FdjX&q%s8)q5E z!;e>9jpt10hO}Iz491smj>PPoT{%@IRgmVLM7C^pEhMgFJ*xC@3liCzVy=Kgui=6z z6*XhwACVbnLfI=V7RMqh;;KT6<+2CA&^ZanLTljZ*(f{TVT_~S^M2A``^4Zyr7gPW zax~2LY7 z5fib}DfM!gQGex}df8B98E$Z*Sa?5?V`b^ju_-0tpT zXEQU0vilJoai-~%hXTa0wr1Q@W4zDY=6gUgKU?GY8H+Rlz*_hKH0lj5Bc=+nj0~{5 z9M|mNUGFZceZku6iutV7vcH~^zClzzC1LS!kFn*JhWW{~J;f_F1~S+nQM{YR-Ecf) zPlZv_N`5$sdcC9d!^2YkyDbWl1`YmvS9KvNr6Sdjrj{d09Y1my(;nf;*>^%BRav@y zIFf@@7`$HRfg-x675REhw%yJ+8yAGv?DD04FiJ|iaAIM?GW7mZmIuEk{i}*{sOG3?Da^m|K!^=>u_NREfN72C40WWtZhP%jCV8EHjTccLtlv6d)6!) zMC#HmKQH$f7_)?_1O}blaH0qYhCWh#D!Bjp;l3YJifDBDNrfS}O`iy_Z?n+d%m#wU zdvtb=4&36Cv}lrb*^OI=WN&x5Zu=4Hg@oZW&1y>+_kG9wz=U7K$S{2Z@i2O#Z-CG} zmic1y!AnDvN9RL9K_nZaSoL*Pgz(6_9^p?<>4;^WVohv~>pd8l&ZVFaNxo7k4~{Nv zNltt@cxk&M#l_#yX`|dSPv;`pJibX2!m<(;Be*+CYAZC+uNK>sJXS9V!2BoM{KbzI uQvcJB{VD4|{Nn%F9{wropR)e%kaZfqSV?_q|IPq@Qf~@?+SM7H@c9QQ6MxeH literal 0 HcmV?d00001