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
This commit is contained in:
Phillip Webb
2025-03-27 14:24:39 -07:00
parent 4034725e38
commit 983e7b637b
68 changed files with 3682 additions and 1182 deletions

View File

@@ -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")

View File

@@ -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<T extends ClientHttpRequestFactory>
implements ClientHttpRequestFactoryBuilder<T> {
private static final Consumer<?> EMPTY_CUSTOMIZER = (t) -> {
};
private final List<Consumer<T>> customizers;
protected AbstractClientHttpRequestFactoryBuilder(List<Consumer<T>> customizers) {
this.customizers = (customizers != null) ? customizers : Collections.emptyList();
}
@SuppressWarnings("unchecked")
protected static <T> Consumer<T> emptyCustomizer() {
return (Consumer<T>) EMPTY_CUSTOMIZER;
}
protected final List<Consumer<T>> getCustomizers() {
return this.customizers;
}
@@ -81,4 +73,9 @@ abstract class AbstractClientHttpRequestFactoryBuilder<T extends ClientHttpReque
protected abstract T createClientHttpRequestFactory(ClientHttpRequestFactorySettings settings);
protected final HttpClientSettings asHttpClientSettings(ClientHttpRequestFactorySettings settings) {
return (settings != null) ? new HttpClientSettings(settings.redirects().httpClientRedirects(),
settings.connectTimeout(), settings.readTimeout(), settings.sslBundle()) : null;
}
}

View File

@@ -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.
@@ -61,11 +61,21 @@ public record ClientHttpRequestFactorySettings(Redirects redirects, Duration con
* @param readTimeout the new read timeout setting
* @return a new {@link ClientHttpRequestFactorySettings} instance
*/
public ClientHttpRequestFactorySettings withReadTimeout(Duration readTimeout) {
return new ClientHttpRequestFactorySettings(this.redirects, this.connectTimeout, readTimeout, this.sslBundle);
}
/**
* Return a new {@link ClientHttpRequestFactorySettings} 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 ClientHttpRequestFactorySettings} instance
*/
public ClientHttpRequestFactorySettings withTimeouts(Duration connectTimeout, Duration readTimeout) {
return new ClientHttpRequestFactorySettings(this.redirects, connectTimeout, readTimeout, this.sslBundle);
}
/**
* Return a new {@link ClientHttpRequestFactorySettings} instance with an updated SSL
* bundle setting.
@@ -113,17 +123,27 @@ public record ClientHttpRequestFactorySettings(Redirects redirects, Duration con
/**
* Follow redirects (if the underlying library has support).
*/
FOLLOW_WHEN_POSSIBLE,
FOLLOW_WHEN_POSSIBLE(HttpRedirects.FOLLOW_WHEN_POSSIBLE),
/**
* Follow redirects (fail if the underlying library has no support).
*/
FOLLOW,
FOLLOW(HttpRedirects.FOLLOW),
/**
* Don't follow redirects (fail if the underlying library has no support).
*/
DONT_FOLLOW
DONT_FOLLOW(HttpRedirects.DONT_FOLLOW);
private final HttpRedirects httpClientRedirects;
Redirects(HttpRedirects httpClientRedirects) {
this.httpClientRedirects = httpClientRedirects;
}
HttpRedirects httpClientRedirects() {
return this.httpClientRedirects;
}
}

View File

@@ -0,0 +1,39 @@
/*
* 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.util.function.Consumer;
/**
* Helper for empty functional interfaces.
*
* @author Phillip Webb
*/
final class Empty {
private static final Consumer<?> EMPTY_CUSTOMIZER = (t) -> {
};
private Empty() {
}
@SuppressWarnings("unchecked")
static <T> Consumer<T> consumer() {
return (Consumer<T>) EMPTY_CUSTOMIZER;
}
}

View File

@@ -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;
}
}

View File

@@ -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<HttpComponentsClientHttpRequestFactory> {
private final Consumer<HttpClientBuilder> httpClientCustomizer;
private final Consumer<PoolingHttpClientConnectionManagerBuilder> connectionManagerCustomizer;
private final Consumer<SocketConfig.Builder> socketConfigCustomizer;
private final Consumer<RequestConfig.Builder> defaultRequestConfigCustomizer;
private final Function<SslBundle, TlsSocketStrategy> 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<Consumer<HttpComponentsClientHttpRequestFactory>> customizers,
Consumer<HttpClientBuilder> httpClientCustomizer,
Consumer<PoolingHttpClientConnectionManagerBuilder> connectionManagerCustomizer,
Consumer<SocketConfig.Builder> socketConfigCustomizer,
Consumer<RequestConfig.Builder> defaultRequestConfigCustomizer,
Function<SslBundle, TlsSocketStrategy> 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<HttpComponentsClientHttpRequestFactory> 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<Consumer<HttpComponentsClientHttpRequestFactory>> 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<HttpClientBuilder> 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<PoolingHttpClientConnectionManagerBuilder> 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<SocketConfig.Builder> 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<SslBundle, TlsSocketStrategy> 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<RequestConfig.Builder> 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";

View File

@@ -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
* <a href="https://hc.apache.org/httpcomponents-client-ga/">Apache HttpComponents</a>
* {@link HttpAsyncClient}.
*
* @author Phillip Webb
* @author Andy Wilkinson
* @author Scott Frederick
* @since 3.5.0
*/
public final class HttpComponentsHttpAsyncClientBuilder {
private final Consumer<HttpAsyncClientBuilder> customizer;
private final Consumer<PoolingAsyncClientConnectionManagerBuilder> connectionManagerCustomizer;
private final Consumer<ConnectionConfig.Builder> connectionConfigCustomizer;
private final Consumer<RequestConfig.Builder> defaultRequestConfigCustomizer;
private final Function<SslBundle, TlsStrategy> tlsStrategyFactory;
public HttpComponentsHttpAsyncClientBuilder() {
this(Empty.consumer(), Empty.consumer(), Empty.consumer(), Empty.consumer(),
HttpComponentsSslBundleTlsStrategy::get);
}
private HttpComponentsHttpAsyncClientBuilder(Consumer<HttpAsyncClientBuilder> customizer,
Consumer<PoolingAsyncClientConnectionManagerBuilder> connectionManagerCustomizer,
Consumer<ConnectionConfig.Builder> connectionConfigCustomizer,
Consumer<RequestConfig.Builder> defaultRequestConfigCustomizer,
Function<SslBundle, TlsStrategy> 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<HttpAsyncClientBuilder> 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<PoolingAsyncClientConnectionManagerBuilder> 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<ConnectionConfig.Builder> 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<SslBundle, TlsStrategy> 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<RequestConfig.Builder> 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();
}
}

View File

@@ -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
* <a href="https://hc.apache.org/httpcomponents-client-ga/">Apache HttpComponents</a>
* {@link HttpClient}.
*
* @author Phillip Webb
* @author Andy Wilkinson
* @author Scott Frederick
* @since 3.5.0
*/
public final class HttpComponentsHttpClientBuilder {
private final Consumer<HttpClientBuilder> customizer;
private final Consumer<PoolingHttpClientConnectionManagerBuilder> connectionManagerCustomizer;
private final Consumer<SocketConfig.Builder> socketConfigCustomizer;
private final Consumer<RequestConfig.Builder> defaultRequestConfigCustomizer;
private final Function<SslBundle, TlsSocketStrategy> tlsSocketStrategyFactory;
public HttpComponentsHttpClientBuilder() {
this(Empty.consumer(), Empty.consumer(), Empty.consumer(), Empty.consumer(),
HttpComponentsSslBundleTlsStrategy::get);
}
private HttpComponentsHttpClientBuilder(Consumer<HttpClientBuilder> customizer,
Consumer<PoolingHttpClientConnectionManagerBuilder> connectionManagerCustomizer,
Consumer<SocketConfig.Builder> socketConfigCustomizer,
Consumer<RequestConfig.Builder> defaultRequestConfigCustomizer,
Function<SslBundle, TlsSocketStrategy> 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<HttpClientBuilder> 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<PoolingHttpClientConnectionManagerBuilder> 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<SocketConfig.Builder> 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<SslBundle, TlsSocketStrategy> 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<RequestConfig.Builder> 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();
}
}

View File

@@ -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
* <a href="https://hc.apache.org/httpcomponents-client-ga/">Apache HttpComponents</a>
* {@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;
}
}
}

View File

@@ -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
* <a href="https://hc.apache.org/httpcomponents-client-ga/">Apache HttpComponents</a>
* {@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());
}
}

View File

@@ -0,0 +1,42 @@
/*
* 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;
/**
* Redirect strategies support by HTTP clients.
*
* @author Phillip Webb
* @since 3.5.0
*/
public enum HttpRedirects {
/**
* 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
}

View File

@@ -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<JdkClientHttpRequestFactory> {
private final Consumer<HttpClient.Builder> httpClientCustomizer;
private final JdkHttpClientBuilder httpClientBuilder;
JdkClientHttpRequestFactoryBuilder() {
this(null, emptyCustomizer());
this(null, new JdkHttpClientBuilder());
}
private JdkClientHttpRequestFactoryBuilder(List<Consumer<JdkClientHttpRequestFactory>> customizers,
Consumer<HttpClient.Builder> httpClientCustomizer) {
JdkHttpClientBuilder httpClientBuilder) {
super(customizers);
this.httpClientCustomizer = httpClientCustomizer;
this.httpClientBuilder = httpClientBuilder;
}
@Override
public JdkClientHttpRequestFactoryBuilder withCustomizer(Consumer<JdkClientHttpRequestFactory> customizer) {
return new JdkClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), this.httpClientCustomizer);
return new JdkClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), this.httpClientBuilder);
}
@Override
public JdkClientHttpRequestFactoryBuilder withCustomizers(
Collection<Consumer<JdkClientHttpRequestFactory>> customizers) {
return new JdkClientHttpRequestFactoryBuilder(mergedCustomizers(customizers), this.httpClientCustomizer);
return new JdkClientHttpRequestFactoryBuilder(mergedCustomizers(customizers), this.httpClientBuilder);
}
/**
@@ -76,44 +70,18 @@ public class JdkClientHttpRequestFactoryBuilder
Consumer<HttpClient.Builder> 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";

View File

@@ -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<HttpClient.Builder> customizer;
public JdkHttpClientBuilder() {
this(Empty.consumer());
}
private JdkHttpClientBuilder(Consumer<HttpClient.Builder> 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<HttpClient.Builder> 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;
};
}
}

View File

@@ -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<JettyClientHttpRequestFactory> {
private final Consumer<HttpClient> httpClientCustomizer;
private final Consumer<HttpClientTransport> httpClientTransportCustomizer;
private final Consumer<ClientConnector> clientConnectorCustomizerCustomizer;
private final JettyHttpClientBuilder httpClientBuilder;
JettyClientHttpRequestFactoryBuilder() {
this(null, emptyCustomizer(), emptyCustomizer(), emptyCustomizer());
this(null, new JettyHttpClientBuilder());
}
private JettyClientHttpRequestFactoryBuilder(List<Consumer<JettyClientHttpRequestFactory>> customizers,
Consumer<HttpClient> httpClientCustomizer, Consumer<HttpClientTransport> httpClientTransportCustomizer,
Consumer<ClientConnector> clientConnectorCustomizerCustomizer) {
JettyHttpClientBuilder httpClientBuilder) {
super(customizers);
this.httpClientCustomizer = httpClientCustomizer;
this.httpClientTransportCustomizer = httpClientTransportCustomizer;
this.clientConnectorCustomizerCustomizer = clientConnectorCustomizerCustomizer;
this.httpClientBuilder = httpClientBuilder;
}
@Override
public JettyClientHttpRequestFactoryBuilder withCustomizer(Consumer<JettyClientHttpRequestFactory> customizer) {
return new JettyClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), this.httpClientCustomizer,
this.httpClientTransportCustomizer, this.clientConnectorCustomizerCustomizer);
return new JettyClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), this.httpClientBuilder);
}
@Override
public JettyClientHttpRequestFactoryBuilder withCustomizers(
Collection<Consumer<JettyClientHttpRequestFactory>> 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<HttpClient> 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<HttpClientTransport> 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<ClientConnector> 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";

View File

@@ -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<HttpClient> customizer;
private final Consumer<HttpClientTransport> httpClientTransportCustomizer;
private final Consumer<ClientConnector> clientConnectorCustomizerCustomizer;
public JettyHttpClientBuilder() {
this(Empty.consumer(), Empty.consumer(), Empty.consumer());
}
private JettyHttpClientBuilder(Consumer<HttpClient> customizer,
Consumer<HttpClientTransport> httpClientTransportCustomizer,
Consumer<ClientConnector> 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<HttpClient> 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<HttpClientTransport> 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<ClientConnector> 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;
}
}
}

View File

@@ -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<ReactorClientHttpRequestFactory> {
private final UnaryOperator<HttpClient> httpClientCustomizer;
private final ReactorHttpClientBuilder httpClientBuilder;
ReactorClientHttpRequestFactoryBuilder() {
this(null, UnaryOperator.identity());
this(null, new ReactorHttpClientBuilder());
}
private ReactorClientHttpRequestFactoryBuilder(List<Consumer<ReactorClientHttpRequestFactory>> customizers,
UnaryOperator<HttpClient> httpClientCustomizer) {
ReactorHttpClientBuilder httpClientBuilder) {
super(customizers);
this.httpClientCustomizer = httpClientCustomizer;
this.httpClientBuilder = httpClientBuilder;
}
@Override
public ReactorClientHttpRequestFactoryBuilder withCustomizer(Consumer<ReactorClientHttpRequestFactory> customizer) {
return new ReactorClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), this.httpClientCustomizer);
return new ReactorClientHttpRequestFactoryBuilder(mergedCustomizers(customizer), this.httpClientBuilder);
}
@Override
public ReactorClientHttpRequestFactoryBuilder withCustomizers(
Collection<Consumer<ReactorClientHttpRequestFactory>> customizers) {
return new ReactorClientHttpRequestFactoryBuilder(mergedCustomizers(customizers), this.httpClientCustomizer);
return new ReactorClientHttpRequestFactoryBuilder(mergedCustomizers(customizers), this.httpClientBuilder);
}
/**
@@ -82,52 +73,20 @@ public final class ReactorClientHttpRequestFactoryBuilder
UnaryOperator<HttpClient> 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";

View File

@@ -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<HttpClient> customizer;
public ReactorHttpClientBuilder() {
this(UnaryOperator.identity());
}
private ReactorHttpClientBuilder(UnaryOperator<HttpClient> 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<HttpClient> 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());
}
}

View File

@@ -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 <T> the {@link ClientHttpConnector} type
* @author Phillip Webb
*/
abstract class AbstractClientHttpConnectorBuilder<T extends ClientHttpConnector>
implements ClientHttpConnectorBuilder<T> {
private final List<Consumer<T>> customizers;
protected AbstractClientHttpConnectorBuilder(List<Consumer<T>> customizers) {
this.customizers = (customizers != null) ? customizers : Collections.emptyList();
}
protected final List<Consumer<T>> getCustomizers() {
return this.customizers;
}
protected final List<Consumer<T>> mergedCustomizers(Consumer<T> customizer) {
Assert.notNull(this.customizers, "'customizer' must not be null");
return merge(this.customizers, List.of(customizer));
}
protected final List<Consumer<T>> mergedCustomizers(Collection<Consumer<T>> customizers) {
Assert.notNull(customizers, "'customizers' must not be null");
Assert.noNullElements(customizers, "'customizers' must not contain null elements");
return merge(this.customizers, customizers);
}
private <E> List<E> merge(Collection<E> list, Collection<? extends E> additional) {
List<E> 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;
}
}

View File

@@ -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 <T> the {@link ClientHttpConnector} type
* @author Phillip Webb
* @since 3.5.0
*/
@FunctionalInterface
public interface ClientHttpConnectorBuilder<T extends ClientHttpConnector> {
/**
* 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<T> withCustomizer(Consumer<T> 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<T> withCustomizers(Collection<Consumer<T>> 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:
* <ul>
* <li>{@link ReactorClientHttpConnector}</li>
* <li>{@link JettyClientHttpConnector}</li>
* <li>{@link HttpComponentsClientHttpConnector}</li>
* <li>{@link JdkClientHttpConnector}</li>
* </ul>
* @param <T> the {@link ClientHttpConnector} type
* @param clientHttpConnectorType the {@link ClientHttpConnector} type
* @return a new {@link ClientHttpConnectorBuilder}
*/
@SuppressWarnings("unchecked")
static <T extends ClientHttpConnector> ClientHttpConnectorBuilder<T> of(Class<T> 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<T>) reactor();
}
if (clientHttpConnectorType == JettyClientHttpConnector.class) {
return (ClientHttpConnectorBuilder<T>) jetty();
}
if (clientHttpConnectorType == HttpComponentsClientHttpConnector.class) {
return (ClientHttpConnectorBuilder<T>) httpComponents();
}
if (clientHttpConnectorType == JdkClientHttpConnector.class) {
return (ClientHttpConnectorBuilder<T>) 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:
* <ol>
* <li>{@link #reactor()}</li>
* <li>{@link #jetty()}</li>
* <li>{@link #httpComponents()}</li>
* <li>{@link #jdk()}</li>
* </ol>
* @return the most suitable {@link ClientHttpConnectorBuilder} for the classpath
*/
static ClientHttpConnectorBuilder<? extends ClientHttpConnector> detect() {
return detect(null);
}
/**
* Detect the most suitable {@link ClientHttpConnectorBuilder} based on the classpath.
* The methods favors builders in the following order:
* <ol>
* <li>{@link #reactor()}</li>
* <li>{@link #jetty()}</li>
* <li>{@link #httpComponents()}</li>
* <li>{@link #jdk()}</li>
* </ol>
* @param classLoader the class loader to use for detection
* @return the most suitable {@link ClientHttpConnectorBuilder} for the classpath
*/
static ClientHttpConnectorBuilder<? extends ClientHttpConnector> 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");
}
}

View File

@@ -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;
}
}

View File

@@ -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<HttpComponentsClientHttpConnector> {
private final HttpComponentsHttpAsyncClientBuilder httpClientBuilder;
HttpComponentsClientHttpConnectorBuilder() {
this(null, new HttpComponentsHttpAsyncClientBuilder());
}
private HttpComponentsClientHttpConnectorBuilder(List<Consumer<HttpComponentsClientHttpConnector>> 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<HttpAsyncClientBuilder> 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<PoolingAsyncClientConnectionManagerBuilder> 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<ConnectionConfig.Builder> 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<SslBundle, TlsStrategy> 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<RequestConfig.Builder> 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);
}
}
}

View File

@@ -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<JdkClientHttpConnector> {
private final JdkHttpClientBuilder httpClientBuilder;
JdkClientHttpConnectorBuilder() {
this(null, new JdkHttpClientBuilder());
}
private JdkClientHttpConnectorBuilder(List<Consumer<JdkClientHttpConnector>> customizers,
JdkHttpClientBuilder httpClientBuilder) {
super(customizers);
this.httpClientBuilder = httpClientBuilder;
}
@Override
public JdkClientHttpConnectorBuilder withCustomizer(Consumer<JdkClientHttpConnector> customizer) {
return new JdkClientHttpConnectorBuilder(mergedCustomizers(customizer), this.httpClientBuilder);
}
@Override
public JdkClientHttpConnectorBuilder withCustomizers(Collection<Consumer<JdkClientHttpConnector>> 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<HttpClient.Builder> 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);
}
}
}

View File

@@ -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<JettyClientHttpConnector> {
private final JettyHttpClientBuilder httpClientBuilder;
JettyClientHttpConnectorBuilder() {
this(null, new JettyHttpClientBuilder());
}
private JettyClientHttpConnectorBuilder(List<Consumer<JettyClientHttpConnector>> customizers,
JettyHttpClientBuilder httpClientBuilder) {
super(customizers);
this.httpClientBuilder = httpClientBuilder;
}
@Override
public JettyClientHttpConnectorBuilder withCustomizer(Consumer<JettyClientHttpConnector> customizer) {
return new JettyClientHttpConnectorBuilder(mergedCustomizers(customizer), this.httpClientBuilder);
}
@Override
public JettyClientHttpConnectorBuilder withCustomizers(Collection<Consumer<JettyClientHttpConnector>> 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<HttpClient> 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<HttpClientTransport> 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<ClientConnector> 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);
}
}
}

View File

@@ -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<ReactorClientHttpConnector> {
private final ReactorHttpClientBuilder httpClientBuilder;
ReactorClientHttpConnectorBuilder() {
this(null, new ReactorHttpClientBuilder());
}
private ReactorClientHttpConnectorBuilder(List<Consumer<ReactorClientHttpConnector>> customizers,
ReactorHttpClientBuilder httpClientBuilder) {
super(customizers);
this.httpClientBuilder = httpClientBuilder;
}
@Override
public ReactorClientHttpConnectorBuilder withCustomizer(Consumer<ReactorClientHttpConnector> customizer) {
return new ReactorClientHttpConnectorBuilder(mergedCustomizers(customizer), this.httpClientBuilder);
}
@Override
public ReactorClientHttpConnectorBuilder withCustomizers(
Collection<Consumer<ReactorClientHttpConnector>> 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<HttpClient> 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);
}
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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

View File

@@ -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 <T> The {@link ClientHttpConnector} type
* @author Phillip Webb
* @author Andy Wilkinson
*/
@DirtiesUrlFactories
abstract class AbstractClientHttpConnectorBuilderTests<T extends ClientHttpConnector> {
private static final Function<HttpMethod, HttpStatus> ALWAYS_FOUND = (method) -> HttpStatus.FOUND;
private final Class<T> connectorType;
private final ClientHttpConnectorBuilder<T> builder;
AbstractClientHttpConnectorBuilderTests(Class<T> connectorType, ClientHttpConnectorBuilder<T> 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<HttpMethod, HttpStatus> 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());
}
}
}

View File

@@ -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<JdkClientHttpConnector> 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<JdkClientHttpConnector> 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<ClientHttpResponse> connect(HttpMethod method, URI uri,
Function<? super ClientHttpRequest, Mono<Void>> requestCallback) {
return null;
}
}
}

View File

@@ -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<HttpComponentsClientHttpConnector> {
HttpComponentsClientHttpConnectorBuilderTests() {
super(HttpComponentsClientHttpConnector.class, ClientHttpConnectorBuilder.httpComponents());
}
@Test
void withCustomizers() {
TestCustomizer<HttpAsyncClientBuilder> httpClientCustomizer1 = new TestCustomizer<>();
TestCustomizer<HttpAsyncClientBuilder> httpClientCustomizer2 = new TestCustomizer<>();
TestCustomizer<PoolingAsyncClientConnectionManagerBuilder> connectionManagerCustomizer = new TestCustomizer<>();
TestCustomizer<ConnectionConfig.Builder> connectionConfigCustomizer1 = new TestCustomizer<>();
TestCustomizer<ConnectionConfig.Builder> connectionConfigCustomizer2 = new TestCustomizer<>();
TestCustomizer<RequestConfig.Builder> defaultRequestConfigCustomizer = new TestCustomizer<>();
TestCustomizer<RequestConfig.Builder> 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<SslBundle> bundles = new ArrayList<>();
Function<SslBundle, TlsStrategy> 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<HttpRoute, ConnectionConfig>) ReflectionTestUtils
.getField(manager, "connectionConfigResolver")).resolve(null);
return connectorConfig;
}
}

View File

@@ -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<JdkClientHttpConnector> {
JdkClientHttpConnectorBuilderTests() {
super(JdkClientHttpConnector.class, ClientHttpConnectorBuilder.jdk());
}
@Test
void withCustomizers() {
TestCustomizer<HttpClient.Builder> httpClientCustomizer1 = new TestCustomizer<>();
TestCustomizer<HttpClient.Builder> 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();
}
}

View File

@@ -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<JettyClientHttpConnector> {
JettyClientHttpConnectorBuilderTests() {
super(JettyClientHttpConnector.class, ClientHttpConnectorBuilder.jetty());
}
@Test
void withCustomizers() {
TestCustomizer<HttpClient> httpClientCustomizer1 = new TestCustomizer<>();
TestCustomizer<HttpClient> httpClientCustomizer2 = new TestCustomizer<>();
TestCustomizer<HttpClientTransport> httpClientTransportCustomizer = new TestCustomizer<>();
TestCustomizer<ClientConnector> 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();
}
}

View File

@@ -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<ReactorClientHttpConnector> {
ReactorClientHttpConnectorBuilderTests() {
super(ReactorClientHttpConnector.class, ClientHttpConnectorBuilder.reactor());
}
@Test
void withCustomizers() {
List<HttpClient> httpClients = new ArrayList<>();
UnaryOperator<HttpClient> httpClientCustomizer1 = (httpClient) -> {
httpClients.add(httpClient);
return httpClient;
};
UnaryOperator<HttpClient> 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();
}
}

View File

@@ -0,0 +1,42 @@
/*
* 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.function.Consumer;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Test customizer that can assert that it has been called.
*
* @param <T> type being customized
* @author Phillip Webb
*/
class TestCustomizer<T> implements Consumer<T> {
private boolean called;
@Override
public void accept(T t) {
this.called = true;
}
void assertCalled() {
assertThat(this.called).isTrue();
}
}