Create spring-boot-http-client-reactive module

This commit is contained in:
Andy Wilkinson
2025-05-07 11:31:34 +01:00
committed by Phillip Webb
parent 4471c0e644
commit 6e04fc0910
61 changed files with 95 additions and 382 deletions

View File

@@ -0,0 +1,29 @@
plugins {
id "java-library"
id "org.springframework.boot.auto-configuration"
id "org.springframework.boot.configuration-properties"
id "org.springframework.boot.deployed"
id "org.springframework.boot.optional-dependencies"
}
description = "Spring Boot Reactive HTTP Client"
dependencies {
api(project(":spring-boot-project:spring-boot"))
api("org.springframework:spring-web")
implementation(project(":spring-boot-project:spring-boot-http-codec"))
optional(project(":spring-boot-project:spring-boot-autoconfigure"))
optional(project(":spring-boot-project:spring-boot-reactor-netty"))
optional("org.apache.httpcomponents.client5:httpclient5")
optional("org.apache.httpcomponents.core5:httpcore5-reactive")
optional("org.eclipse.jetty:jetty-reactive-httpclient")
optional("org.springframework:spring-webflux")
testImplementation(project(":spring-boot-project:spring-boot-test"))
testImplementation(project(":spring-boot-project:spring-boot-tomcat"))
testImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support"))
testRuntimeOnly("ch.qos.logback:logback-classic")
}

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 method 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 method 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,117 @@
/*
* 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.Supplier;
import java.util.function.UnaryOperator;
import reactor.netty.http.client.HttpClient;
import org.springframework.boot.http.client.ReactorHttpClientBuilder;
import org.springframework.http.client.ReactorResourceFactory;
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 uses the given
* {@link ReactorResourceFactory} to create the underlying {@link HttpClient}.
* @param reactorResourceFactory the {@link ReactorResourceFactory} to use
* @return a new {@link ReactorClientHttpConnectorBuilder} instance
*/
public ReactorClientHttpConnectorBuilder withReactorResourceFactory(ReactorResourceFactory reactorResourceFactory) {
Assert.notNull(reactorResourceFactory, "'reactorResourceFactory' must not be null");
return new ReactorClientHttpConnectorBuilder(getCustomizers(),
this.httpClientBuilder.withReactorResourceFactory(reactorResourceFactory));
}
/**
* Return a new {@link ReactorClientHttpConnectorBuilder} that uses the given factory
* to create the underlying {@link HttpClient}.
* @param factory the factory to use
* @return a new {@link ReactorClientHttpConnectorBuilder} instance
*/
public ReactorClientHttpConnectorBuilder withHttpClientFactory(Supplier<HttpClient> factory) {
Assert.notNull(factory, "'factory' must not be null");
return new ReactorClientHttpConnectorBuilder(getCustomizers(),
this.httpClientBuilder.withHttpClientFactory(factory));
}
/**
* 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,156 @@
/*
* 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.autoconfigure;
import java.time.Duration;
import java.util.function.Supplier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.http.client.HttpRedirects;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings;
import org.springframework.http.client.reactive.ClientHttpConnector;
/**
* Base {@link ConfigurationProperties @ConfigurationProperties} for configuring a
* {@link ClientHttpConnector}.
*
* @author Phillip Webb
* @since 3.5.0
* @see ClientHttpConnectorSettings
*/
public abstract class AbstractClientHttpConnectorProperties {
/**
* Handling for HTTP redirects.
*/
private HttpRedirects redirects;
/**
* Default connect timeout for a client HTTP request.
*/
private Duration connectTimeout;
/**
* Default read timeout for a client HTTP request.
*/
private Duration readTimeout;
/**
* Default SSL configuration for a client HTTP request.
*/
private final Ssl ssl = new Ssl();
/**
* Default connector used for a client HTTP request.
*/
private Connector connector;
public HttpRedirects getRedirects() {
return this.redirects;
}
public void setRedirects(HttpRedirects redirects) {
this.redirects = redirects;
}
public Duration getConnectTimeout() {
return this.connectTimeout;
}
public void setConnectTimeout(Duration connectTimeout) {
this.connectTimeout = connectTimeout;
}
public Duration getReadTimeout() {
return this.readTimeout;
}
public void setReadTimeout(Duration readTimeout) {
this.readTimeout = readTimeout;
}
public Ssl getSsl() {
return this.ssl;
}
public Connector getConnector() {
return this.connector;
}
public void setConnector(Connector connector) {
this.connector = connector;
}
/**
* SSL configuration.
*/
public static class Ssl {
/**
* SSL bundle to use.
*/
private String bundle;
public String getBundle() {
return this.bundle;
}
public void setBundle(String bundle) {
this.bundle = bundle;
}
}
/**
* Supported factory types.
*/
public enum Connector {
/**
* Reactor-Netty.
*/
REACTOR(ClientHttpConnectorBuilder::reactor),
/**
* Jetty's HttpClient.
*/
JETTY(ClientHttpConnectorBuilder::jetty),
/**
* Apache HttpComponents HttpClient.
*/
HTTP_COMPONENTS(ClientHttpConnectorBuilder::httpComponents),
/**
* Java's HttpClient.
*/
JDK(ClientHttpConnectorBuilder::jdk);
private final Supplier<ClientHttpConnectorBuilder<?>> builderSupplier;
Connector(Supplier<ClientHttpConnectorBuilder<?>> builderSupplier) {
this.builderSupplier = builderSupplier;
}
ClientHttpConnectorBuilder<?> builder() {
return this.builderSupplier.get();
}
}
}

View File

@@ -0,0 +1,108 @@
/*
* 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.autoconfigure;
import java.util.List;
import reactor.core.publisher.Mono;
import org.springframework.beans.factory.BeanClassLoaderAware;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings;
import org.springframework.boot.reactor.netty.autoconfigure.ReactorNettyConfigurations;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.boot.util.LambdaSafe;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.client.reactive.ClientHttpConnector;
/**
* {@link EnableAutoConfiguration Auto-configuration} for
* {@link ClientHttpConnectorBuilder} and {@link ClientHttpConnectorSettings}.
*
* @author Phillip Webb
* @since 4.0.0
*/
@AutoConfiguration(after = SslAutoConfiguration.class)
@ConditionalOnClass({ ClientHttpConnector.class, Mono.class })
@Conditional(ConditionalOnClientHttpConnectorBuilderDetection.class)
@EnableConfigurationProperties(HttpReactiveClientProperties.class)
public class ClientHttpConnectorAutoConfiguration implements BeanClassLoaderAware {
private final ClientHttpConnectors connectors;
private ClassLoader beanClassLoader;
ClientHttpConnectorAutoConfiguration(ObjectProvider<SslBundles> sslBundles,
HttpReactiveClientProperties properties) {
this.connectors = new ClientHttpConnectors(sslBundles, properties);
}
@Override
public void setBeanClassLoader(ClassLoader classLoader) {
this.beanClassLoader = classLoader;
}
@Bean
@ConditionalOnMissingBean
ClientHttpConnectorBuilder<?> clientHttpConnectorBuilder(
ObjectProvider<ClientHttpConnectorBuilderCustomizer<?>> clientHttpConnectorBuilderCustomizers) {
ClientHttpConnectorBuilder<?> builder = this.connectors.builder(this.beanClassLoader);
return customize(builder, clientHttpConnectorBuilderCustomizers.orderedStream().toList());
}
@SuppressWarnings("unchecked")
private ClientHttpConnectorBuilder<?> customize(ClientHttpConnectorBuilder<?> builder,
List<ClientHttpConnectorBuilderCustomizer<?>> customizers) {
ClientHttpConnectorBuilder<?>[] builderReference = { builder };
LambdaSafe.callbacks(ClientHttpConnectorBuilderCustomizer.class, customizers, builderReference[0])
.invoke((customizer) -> builderReference[0] = customizer.customize(builderReference[0]));
return builderReference[0];
}
@Bean
@ConditionalOnMissingBean
ClientHttpConnectorSettings clientHttpConnectorSettings() {
return this.connectors.settings();
}
@Bean
@Lazy
@ConditionalOnMissingBean
ClientHttpConnector clientHttpConnector(ClientHttpConnectorBuilder<?> clientHttpConnectorBuilder,
ClientHttpConnectorSettings clientHttpRequestFactorySettings) {
return clientHttpConnectorBuilder.build(clientHttpRequestFactorySettings);
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(reactor.netty.http.client.HttpClient.class)
@Import(ReactorNettyConfigurations.ReactorResourceFactoryConfiguration.class)
static class ReactorNetty {
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.http.client.reactive.autoconfigure;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder;
/**
* Customizer that can be used to modify the auto-configured
* {@link ClientHttpConnectorBuilder} when its type matches.
*
* @param <B> the builder type
* @author Phillip Webb
* @since 3.5.0
*/
public interface ClientHttpConnectorBuilderCustomizer<B extends ClientHttpConnectorBuilder<?>> {
/**
* Customize the given builder.
* @param builder the builder to customize
* @return the customized builder
*/
B customize(B builder);
}

View File

@@ -0,0 +1,87 @@
/*
* 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.autoconfigure;
import java.time.Duration;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.boot.http.client.HttpRedirects;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings;
import org.springframework.boot.http.client.reactive.autoconfigure.AbstractClientHttpConnectorProperties.Connector;
import org.springframework.boot.http.client.reactive.autoconfigure.AbstractClientHttpConnectorProperties.Ssl;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.util.StringUtils;
/**
* Helper class to create {@link ClientHttpConnectorBuilder} and
* {@link ClientHttpConnectorSettings}.
*
* @author Phillip Webb
* @since 4.0.0
*/
public final class ClientHttpConnectors {
private final ObjectFactory<SslBundles> sslBundles;
private final AbstractClientHttpConnectorProperties[] orderedProperties;
public ClientHttpConnectors(ObjectFactory<SslBundles> sslBundles,
AbstractClientHttpConnectorProperties... orderedProperties) {
this.sslBundles = sslBundles;
this.orderedProperties = orderedProperties;
}
public ClientHttpConnectorBuilder<?> builder(ClassLoader classLoader) {
Connector connector = getProperty(AbstractClientHttpConnectorProperties::getConnector);
return (connector != null) ? connector.builder() : ClientHttpConnectorBuilder.detect(classLoader);
}
public ClientHttpConnectorSettings settings() {
HttpRedirects redirects = getProperty(AbstractClientHttpConnectorProperties::getRedirects);
Duration connectTimeout = getProperty(AbstractClientHttpConnectorProperties::getConnectTimeout);
Duration readTimeout = getProperty(AbstractClientHttpConnectorProperties::getReadTimeout);
String sslBundleName = getProperty(AbstractClientHttpConnectorProperties::getSsl, Ssl::getBundle,
StringUtils::hasText);
SslBundle sslBundle = (StringUtils.hasLength(sslBundleName))
? this.sslBundles.getObject().getBundle(sslBundleName) : null;
return new ClientHttpConnectorSettings(redirects, connectTimeout, readTimeout, sslBundle);
}
private <T> T getProperty(Function<AbstractClientHttpConnectorProperties, T> accessor) {
return getProperty(accessor, Function.identity(), Objects::nonNull);
}
private <P, T> T getProperty(Function<AbstractClientHttpConnectorProperties, P> accessor, Function<P, T> extractor,
Predicate<T> predicate) {
for (AbstractClientHttpConnectorProperties properties : this.orderedProperties) {
if (properties != null) {
P value = accessor.apply(properties);
T extracted = (value != null) ? extractor.apply(value) : null;
if (predicate.test(extracted)) {
return extracted;
}
}
}
return null;
}
}

View File

@@ -0,0 +1,44 @@
/*
* 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.autoconfigure;
import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
import org.springframework.boot.autoconfigure.condition.SpringBootCondition;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder;
import org.springframework.context.annotation.Condition;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.type.AnnotatedTypeMetadata;
/**
* {@link Condition} that checks that {@link ClientHttpConnectorBuilder} can be detected.
*
* @author Phillip Webb
*/
class ConditionalOnClientHttpConnectorBuilderDetection extends SpringBootCondition {
@Override
public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) {
try {
ClientHttpConnectorBuilder.detect(context.getClassLoader());
return ConditionOutcome.match("Detected ClientHttpConnectorBuilder");
}
catch (IllegalStateException ex) {
return ConditionOutcome.noMatch("Unable to detect ClientHttpConnectorBuilder");
}
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.http.client.reactive.autoconfigure;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings;
/**
* {@link ConfigurationProperties @ConfigurationProperties} to configure settings that
* apply to Spring's reactive client HTTP connectors.
*
* @author Phillip Webb
* @since 4.0.0
* @see ClientHttpConnectorSettings
*/
@ConfigurationProperties("spring.http.reactiveclient")
public class HttpReactiveClientProperties extends AbstractClientHttpConnectorProperties {
}

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.
*/
/**
* Auto-configuration for client-side reactive HTTP.
*/
package org.springframework.boot.http.client.reactive.autoconfigure;

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

@@ -0,0 +1,63 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.http.client.reactive.service.autoconfigure;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.http.client.reactive.autoconfigure.AbstractClientHttpConnectorProperties;
/**
* {@link AbstractClientHttpConnectorProperties} for reactive HTTP Service clients.
*
* @author Olga Maciaszek-Sharma
* @author Rossen Stoyanchev
* @author Phillip Webb
* @since 4.0.0
*/
public abstract class AbstractHttpReactiveClientServiceProperties extends AbstractClientHttpConnectorProperties {
/**
* Base url to set in the underlying HTTP client group. By default, set to
* {@code null}.
*/
private String baseUrl;
/**
* Default request headers for interface client group. By default, set to empty
* {@link Map}.
*/
private Map<String, List<String>> defaultHeader = new LinkedHashMap<>();
public String getBaseUrl() {
return this.baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public Map<String, List<String>> getDefaultHeader() {
return this.defaultHeader;
}
public void setDefaultHeader(Map<String, List<String>> defaultHeaders) {
this.defaultHeader = defaultHeaders;
}
}

View File

@@ -0,0 +1,55 @@
/*
* 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.service.autoconfigure;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Properties for Reactive HTTP Service clients.
*
* @author Olga Maciaszek-Sharma
* @author Rossen Stoyanchev
* @author Phillip Webb
* @since 4.0.0
*/
@ConfigurationProperties("spring.http.reactiveclient.service")
public class ReactiveHttpClientServiceProperties extends AbstractHttpReactiveClientServiceProperties {
/**
* Group settings.
*/
private Map<String, Group> group = new LinkedHashMap<>();
public Map<String, Group> getGroup() {
return this.group;
}
public void setGroup(Map<String, Group> group) {
this.group = group;
}
/**
* Properties for a single HTTP Service client group.
*/
public static class Group extends AbstractHttpReactiveClientServiceProperties {
}
}

View File

@@ -0,0 +1,80 @@
/*
* 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.service.autoconfigure;
import org.springframework.beans.factory.BeanClassLoaderAware;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings;
import org.springframework.boot.http.client.reactive.autoconfigure.ClientHttpConnectorAutoConfiguration;
import org.springframework.boot.http.client.reactive.autoconfigure.HttpReactiveClientProperties;
import org.springframework.boot.http.client.reactive.web.autoconfigure.WebClientAutoConfiguration;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.web.reactive.function.client.support.WebClientAdapter;
import org.springframework.web.service.registry.HttpServiceProxyRegistry;
import org.springframework.web.service.registry.ImportHttpServices;
/**
* AutoConfiguration for Spring reactive HTTP Service Clients.
* <p>
* This will result in the creation of reactive HTTP Service client beans defined by
* {@link ImportHttpServices @ImportHttpServices} annotations.
*
* @author Olga Maciaszek-Sharma
* @author Rossen Stoyanchev
* @author Phillip Webb
* @since 4.0.0
*/
@AutoConfiguration(after = { ClientHttpConnectorAutoConfiguration.class, WebClientAutoConfiguration.class })
@ConditionalOnClass(WebClientAdapter.class)
@ConditionalOnBean(HttpServiceProxyRegistry.class)
@EnableConfigurationProperties(ReactiveHttpClientServiceProperties.class)
public class ReactiveHttpServiceClientAutoConfiguration implements BeanClassLoaderAware {
private ClassLoader beanClassLoader;
ReactiveHttpServiceClientAutoConfiguration() {
}
@Override
public void setBeanClassLoader(ClassLoader classLoader) {
this.beanClassLoader = classLoader;
}
@Bean
WebClientPropertiesHttpServiceGroupConfigurer webClientPropertiesHttpServiceGroupConfigurer(
ObjectProvider<SslBundles> sslBundles, HttpReactiveClientProperties httpReactiveClientProperties,
ReactiveHttpClientServiceProperties serviceProperties,
ObjectProvider<ClientHttpConnectorBuilder<?>> clientConnectorBuilder,
ObjectProvider<ClientHttpConnectorSettings> clientConnectorSettings) {
return new WebClientPropertiesHttpServiceGroupConfigurer(this.beanClassLoader, sslBundles,
httpReactiveClientProperties, serviceProperties, clientConnectorBuilder, clientConnectorSettings);
}
@Bean
WebClientCustomizerHttpServiceGroupConfigurer webClientCustomizerHttpServiceGroupConfigurer(
ObjectProvider<WebClientCustomizer> customizers) {
return new WebClientCustomizerHttpServiceGroupConfigurer(customizers);
}
}

View File

@@ -0,0 +1,62 @@
/*
* 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.service.autoconfigure;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientHttpServiceGroupConfigurer;
import org.springframework.web.service.registry.HttpServiceGroup;
/**
* A {@link RestClientHttpServiceGroupConfigurer} to apply auto-configured
* {@link RestClientCustomizer} beans to the group's {@link RestClient}.
*
* @author Olga Maciaszek-Sharma
* @author Phillip Webb
*/
class WebClientCustomizerHttpServiceGroupConfigurer implements WebClientHttpServiceGroupConfigurer {
/**
* Allow user defined configurers to apply before / after ours.
*/
private static final int ORDER = 0;
private final ObjectProvider<WebClientCustomizer> customizers;
WebClientCustomizerHttpServiceGroupConfigurer(ObjectProvider<WebClientCustomizer> customizers) {
this.customizers = customizers;
}
@Override
public int getOrder() {
return ORDER;
}
@Override
public void configureGroups(Groups<WebClient.Builder> groups) {
groups.forEachClient(this::configureClient);
}
private void configureClient(HttpServiceGroup group, WebClient.Builder builder) {
this.customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
}
}

View File

@@ -0,0 +1,107 @@
/*
* 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.service.autoconfigure;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.context.properties.PropertyMapper;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings;
import org.springframework.boot.http.client.reactive.autoconfigure.ClientHttpConnectors;
import org.springframework.boot.http.client.reactive.autoconfigure.HttpReactiveClientProperties;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientHttpServiceGroupConfigurer;
import org.springframework.web.service.registry.HttpServiceGroup;
/**
* A {@link RestClientHttpServiceGroupConfigurer} that configures the group and its
* underlying {@link RestClient} using {@link HttpReactiveClientProperties}.
*
* @author Olga Maciaszek-Sharma
* @author Phillip Webb
*/
class WebClientPropertiesHttpServiceGroupConfigurer implements WebClientHttpServiceGroupConfigurer {
private final ClassLoader classLoader;
private final ObjectProvider<SslBundles> sslBundles;
private final HttpReactiveClientProperties clientProperties;
private final ReactiveHttpClientServiceProperties serviceProperties;
private final ObjectProvider<ClientHttpConnectorBuilder<?>> clientConnectorBuilder;
private final ObjectProvider<ClientHttpConnectorSettings> clientConnectorSettings;
WebClientPropertiesHttpServiceGroupConfigurer(ClassLoader classLoader, ObjectProvider<SslBundles> sslBundles,
HttpReactiveClientProperties clientProperties, ReactiveHttpClientServiceProperties serviceProperties,
ObjectProvider<ClientHttpConnectorBuilder<?>> clientConnectorBuilder,
ObjectProvider<ClientHttpConnectorSettings> clientConnectorSettings) {
this.classLoader = classLoader;
this.sslBundles = sslBundles;
this.clientProperties = clientProperties;
this.serviceProperties = serviceProperties;
this.clientConnectorBuilder = clientConnectorBuilder;
this.clientConnectorSettings = clientConnectorSettings;
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
@Override
public void configureGroups(Groups<WebClient.Builder> groups) {
groups.forEachClient(this::configureClient);
}
private void configureClient(HttpServiceGroup group, WebClient.Builder builder) {
ReactiveHttpClientServiceProperties.Group groupProperties = this.serviceProperties.getGroup().get(group.name());
builder.clientConnector(getClientConnector(groupProperties));
PropertyMapper map = PropertyMapper.get().alwaysApplyingWhenNonNull();
map.from(this.serviceProperties::getBaseUrl).whenHasText().to(builder::baseUrl);
map.from(this.serviceProperties::getDefaultHeader).as(this::putAllHeaders).to(builder::defaultHeaders);
if (groupProperties != null) {
map.from(groupProperties::getBaseUrl).whenHasText().to(builder::baseUrl);
map.from(groupProperties::getDefaultHeader).as(this::putAllHeaders).to(builder::defaultHeaders);
}
}
private Consumer<HttpHeaders> putAllHeaders(Map<String, List<String>> defaultHeaders) {
return (httpHeaders) -> httpHeaders.putAll(defaultHeaders);
}
private ClientHttpConnector getClientConnector(ReactiveHttpClientServiceProperties.Group groupProperties) {
ClientHttpConnectors connectors = new ClientHttpConnectors(this.sslBundles, groupProperties,
this.serviceProperties, this.clientProperties);
ClientHttpConnectorBuilder<?> builder = this.clientConnectorBuilder
.getIfAvailable(() -> connectors.builder(this.classLoader));
ClientHttpConnectorSettings settings = this.clientConnectorSettings.getIfAvailable(connectors::settings);
return builder.build(settings);
}
}

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.
*/
/**
* Auto-Configuration for Spring's Reactive HTTP Service Interface Clients.
*/
package org.springframework.boot.http.client.reactive.service.autoconfigure;

View File

@@ -0,0 +1,62 @@
/*
* 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.web.autoconfigure;
import java.util.function.Consumer;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
/**
* An auto-configured {@link WebClientSsl} implementation.
*
* @author Phillip Webb
*/
class AutoConfiguredWebClientSsl implements WebClientSsl {
private final ClientHttpConnectorBuilder<?> connectorBuilder;
private final ClientHttpConnectorSettings settings;
private final SslBundles sslBundles;
AutoConfiguredWebClientSsl(ClientHttpConnectorBuilder<?> connectorBuilder, ClientHttpConnectorSettings settings,
SslBundles sslBundles) {
this.connectorBuilder = connectorBuilder;
this.settings = settings;
this.sslBundles = sslBundles;
}
@Override
public Consumer<WebClient.Builder> fromBundle(String bundleName) {
return fromBundle(this.sslBundles.getBundle(bundleName));
}
@Override
public Consumer<WebClient.Builder> fromBundle(SslBundle bundle) {
return (builder) -> {
ClientHttpConnectorSettings settings = this.settings.withSslBundle(bundle);
ClientHttpConnector connector = this.connectorBuilder.build(settings);
builder.clientConnector(connector);
};
}
}

View File

@@ -0,0 +1,95 @@
/*
* 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.web.autoconfigure;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings;
import org.springframework.boot.http.client.reactive.autoconfigure.ClientHttpConnectorAutoConfiguration;
import org.springframework.boot.http.codec.CodecCustomizer;
import org.springframework.boot.http.codec.autoconfigure.CodecsAutoConfiguration;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Scope;
import org.springframework.core.annotation.Order;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
/**
* {@link EnableAutoConfiguration Auto-configuration} for {@link WebClient}.
* <p>
* This will produce a
* {@link org.springframework.web.reactive.function.client.WebClient.Builder
* WebClient.Builder} bean with the {@code prototype} scope, meaning each injection point
* will receive a newly cloned instance of the builder.
*
* @author Brian Clozel
* @author Phillip Webb
* @since 2.0.0
*/
@AutoConfiguration(after = { ClientHttpConnectorAutoConfiguration.class, CodecsAutoConfiguration.class })
@ConditionalOnClass(WebClient.class)
public class WebClientAutoConfiguration {
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@ConditionalOnMissingBean
public WebClient.Builder webClientBuilder(ObjectProvider<WebClientCustomizer> customizerProvider) {
WebClient.Builder builder = WebClient.builder();
customizerProvider.orderedStream().forEach((customizer) -> customizer.customize(builder));
return builder;
}
@Bean
@Lazy
@Order(0)
@ConditionalOnBean(ClientHttpConnector.class)
public WebClientCustomizer webClientHttpConnectorCustomizer(ClientHttpConnector clientHttpConnector) {
return (builder) -> builder.clientConnector(clientHttpConnector);
}
@Bean
@ConditionalOnMissingBean(WebClientSsl.class)
@ConditionalOnBean(SslBundles.class)
AutoConfiguredWebClientSsl webClientSsl(ClientHttpConnectorBuilder<?> clientHttpConnectorBuilder,
ClientHttpConnectorSettings clientHttpConnectorSettings, SslBundles sslBundles) {
return new AutoConfiguredWebClientSsl(clientHttpConnectorBuilder, clientHttpConnectorSettings, sslBundles);
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnBean(CodecCustomizer.class)
protected static class WebClientCodecsConfiguration {
@Bean
@ConditionalOnMissingBean
@Order(0)
public WebClientCodecCustomizer exchangeStrategiesCustomizer(ObjectProvider<CodecCustomizer> codecCustomizers) {
return new WebClientCodecCustomizer(codecCustomizers.orderedStream().toList());
}
}
}

View File

@@ -0,0 +1,45 @@
/*
* 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.web.autoconfigure;
import java.util.List;
import org.springframework.boot.http.codec.CodecCustomizer;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.web.reactive.function.client.WebClient;
/**
* {@link WebClientCustomizer} that configures codecs for the HTTP client.
*
* @author Brian Clozel
* @since 2.0.0
*/
public class WebClientCodecCustomizer implements WebClientCustomizer {
private final List<CodecCustomizer> codecCustomizers;
public WebClientCodecCustomizer(List<CodecCustomizer> codecCustomizers) {
this.codecCustomizers = codecCustomizers;
}
@Override
public void customize(WebClient.Builder webClientBuilder) {
webClientBuilder
.codecs((codecs) -> this.codecCustomizers.forEach((customizer) -> customizer.customize(codecs)));
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2012-2023 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.http.client.reactive.web.autoconfigure;
import java.util.function.Consumer;
import org.springframework.boot.ssl.NoSuchSslBundleException;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.http.client.reactive.ClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
/**
* Interface that can be used to {@link WebClient.Builder#apply apply} SSL configuration
* to a {@link org.springframework.web.reactive.function.client.WebClient.Builder
* WebClient.Builder}.
* <p>
* Typically used as follows: <pre class="code">
* &#064;Bean
* public MyBean myBean(WebClient.Builder webClientBuilder, WebClientSsl ssl) {
* WebClient webClient = webClientBuilder.apply(ssl.fromBundle("mybundle")).build();
* return new MyBean(webClient);
* }
* </pre> NOTE: Apply SSL configuration will replace any previously
* {@link WebClient.Builder#clientConnector configured} {@link ClientHttpConnector}.
*
* @author Phillip Webb
* @since 3.1.0
*/
public interface WebClientSsl {
/**
* Return a {@link Consumer} that will apply SSL configuration for the named
* {@link SslBundle} to a
* {@link org.springframework.web.reactive.function.client.WebClient.Builder
* WebClient.Builder}.
* @param bundleName the name of the SSL bundle to apply
* @return a {@link Consumer} to apply the configuration
* @throws NoSuchSslBundleException if a bundle with the provided name does not exist
*/
Consumer<WebClient.Builder> fromBundle(String bundleName) throws NoSuchSslBundleException;
/**
* Return a {@link Consumer} that will apply SSL configuration for the
* {@link SslBundle} to a
* {@link org.springframework.web.reactive.function.client.WebClient.Builder
* WebClient.Builder}.
* @param bundle the SSL bundle to apply
* @return a {@link Consumer} to apply the configuration
*/
Consumer<WebClient.Builder> fromBundle(SslBundle bundle);
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2012-2019 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Auto-configuration for Spring Framework's functional web client.
*/
package org.springframework.boot.http.client.reactive.web.autoconfigure;

View File

@@ -0,0 +1,3 @@
org.springframework.boot.http.client.reactive.autoconfigure.ClientHttpConnectorAutoConfiguration
org.springframework.boot.http.client.reactive.service.autoconfigure.ReactiveHttpServiceClientAutoConfiguration
org.springframework.boot.http.client.reactive.web.autoconfigure.WebClientAutoConfiguration

View File

@@ -0,0 +1,253 @@
/*
* 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.tomcat.servlet.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
*/
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,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,101 @@
/*
* 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.Supplier;
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.ReactorHttpClientBuilder;
import org.springframework.http.client.ReactorResourceFactory;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.spy;
/**
* Tests for {@link ReactorClientHttpConnectorBuilder} and
* {@link ReactorHttpClientBuilder}.
*
* @author Phillip Webb
*/
class ReactorClientHttpConnectorBuilderTests
extends AbstractClientHttpConnectorBuilderTests<ReactorClientHttpConnector> {
ReactorClientHttpConnectorBuilderTests() {
super(ReactorClientHttpConnector.class, ClientHttpConnectorBuilder.reactor());
}
@Test
void withHttpClientFactory() {
boolean[] called = new boolean[1];
Supplier<HttpClient> httpClientFactory = () -> {
called[0] = true;
return HttpClient.create();
};
ClientHttpConnectorBuilder.reactor().withHttpClientFactory(httpClientFactory).build();
assertThat(called).containsExactly(true);
}
@Test
void withReactorResourceFactory() {
ReactorResourceFactory resourceFactory = spy(new ReactorResourceFactory());
ClientHttpConnectorBuilder.reactor().withReactorResourceFactory(resourceFactory).build();
then(resourceFactory).should().getConnectionProvider();
then(resourceFactory).should().getLoopResources();
}
@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;
};
ClientHttpConnectorBuilder.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();
}
}

View File

@@ -0,0 +1,215 @@
/*
* 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.autoconfigure;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
import org.junit.jupiter.api.Test;
import reactor.netty.http.client.HttpClient;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
import org.springframework.boot.http.client.HttpRedirects;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings;
import org.springframework.boot.http.client.reactive.JdkClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.JettyClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.ReactorClientHttpConnectorBuilder;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.ReactorResourceFactory;
import org.springframework.http.client.reactive.ClientHttpConnector;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link ClientHttpConnectorAutoConfiguration}
*
* @author Brian Clozel
* @author Phillip Webb
*/
class ClientHttpConnectorAutoConfigurationTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration(
AutoConfigurations.of(ClientHttpConnectorAutoConfiguration.class, SslAutoConfiguration.class));
@Test
void whenReactorIsAvailableThenReactorBeansAreDefined() {
this.contextRunner.run((context) -> {
BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("clientHttpConnector");
assertThat(connectorDefinition.isLazyInit()).isTrue();
assertThat(context).hasSingleBean(ReactorResourceFactory.class);
assertThat(context.getBean(ClientHttpConnectorBuilder.class))
.isExactlyInstanceOf(ReactorClientHttpConnectorBuilder.class);
});
}
@Test
void whenReactorIsUnavailableThenJettyClientBeansAreDefined() {
this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class)).run((context) -> {
BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("clientHttpConnector");
assertThat(connectorDefinition.isLazyInit()).isTrue();
assertThat(context.getBean(ClientHttpConnectorBuilder.class))
.isExactlyInstanceOf(JettyClientHttpConnectorBuilder.class);
});
}
@Test
void whenReactorAndHttpClientAreUnavailableThenJettyClientBeansAreDefined() {
this.contextRunner.withClassLoader(new FilteredClassLoader(HttpClient.class, HttpAsyncClients.class))
.run((context) -> {
BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("clientHttpConnector");
assertThat(connectorDefinition.isLazyInit()).isTrue();
assertThat(context.getBean(ClientHttpConnectorBuilder.class))
.isExactlyInstanceOf(JettyClientHttpConnectorBuilder.class);
});
}
@Test
void whenReactorAndHttpClientAndJettyAreUnavailableThenJdkClientBeansAreDefined() {
this.contextRunner
.withClassLoader(new FilteredClassLoader(HttpClient.class, HttpAsyncClients.class,
org.eclipse.jetty.client.HttpClient.class))
.run((context) -> {
BeanDefinition connectorDefinition = context.getBeanFactory().getBeanDefinition("clientHttpConnector");
assertThat(connectorDefinition.isLazyInit()).isTrue();
assertThat(context.getBean(ClientHttpConnectorBuilder.class))
.isExactlyInstanceOf(JdkClientHttpConnectorBuilder.class);
});
}
@Test
void shouldNotOverrideCustomClientConnector() {
this.contextRunner.withUserConfiguration(CustomClientHttpConnectorConfig.class).run((context) -> {
assertThat(context).hasSingleBean(ClientHttpConnector.class);
assertThat(context).hasBean("customConnector");
});
}
@Test
void shouldUseCustomReactorResourceFactory() {
this.contextRunner.withUserConfiguration(CustomReactorResourceConfig.class).run((context) -> {
assertThat(context).hasSingleBean(ClientHttpConnector.class);
assertThat(context).hasSingleBean(ReactorResourceFactory.class);
assertThat(context).hasBean("customReactorResourceFactory");
});
}
@Test
void configuresDetectedClientHttpConnectorBuilderBuilder() {
this.contextRunner.run((context) -> assertThat(context).hasSingleBean(ClientHttpConnectorBuilder.class));
}
@Test
void configuresDefinedClientHttpConnectorBuilder() {
this.contextRunner.withPropertyValues("spring.http.reactiveclient.connector=jetty")
.run((context) -> assertThat(context.getBean(ClientHttpConnectorBuilder.class))
.isInstanceOf(JettyClientHttpConnectorBuilder.class));
}
@Test
void configuresClientHttpConnectorSettings() {
this.contextRunner.withPropertyValues(sslPropertyValues().toArray(String[]::new))
.withPropertyValues("spring.http.reactiveclient.redirects=dont-follow",
"spring.http.reactiveclient.connect-timeout=10s", "spring.http.reactiveclient.read-timeout=20s",
"spring.http.reactiveclient.ssl.bundle=test")
.run((context) -> {
ClientHttpConnectorSettings settings = context.getBean(ClientHttpConnectorSettings.class);
assertThat(settings.redirects()).isEqualTo(HttpRedirects.DONT_FOLLOW);
assertThat(settings.connectTimeout()).isEqualTo(Duration.ofSeconds(10));
assertThat(settings.readTimeout()).isEqualTo(Duration.ofSeconds(20));
assertThat(settings.sslBundle().getKey().getAlias()).isEqualTo("alias1");
});
}
@Test
void shouldBeConditionalOnAtLeastOneHttpConnectorClass() {
FilteredClassLoader classLoader = new FilteredClassLoader(reactor.netty.http.client.HttpClient.class,
org.eclipse.jetty.client.HttpClient.class, org.apache.hc.client5.http.impl.async.HttpAsyncClients.class,
java.net.http.HttpClient.class);
assertThatIllegalStateException().as("enough filtering")
.isThrownBy(() -> ClientHttpConnectorBuilder.detect(classLoader));
this.contextRunner.withClassLoader(classLoader)
.run((context) -> assertThat(context).doesNotHaveBean(ClientHttpConnectorSettings.class));
}
private List<String> sslPropertyValues() {
List<String> propertyValues = new ArrayList<>();
String location = "classpath:org/springframework/boot/autoconfigure/ssl/";
propertyValues.add("spring.ssl.bundle.pem.test.key.alias=alias1");
propertyValues.add("spring.ssl.bundle.pem.test.truststore.type=PKCS12");
propertyValues.add("spring.ssl.bundle.pem.test.truststore.certificate=" + location + "rsa-cert.pem");
propertyValues.add("spring.ssl.bundle.pem.test.truststore.private-key=" + location + "rsa-key.pem");
return propertyValues;
}
@Test
void clientHttpConnectorBuilderCustomizersAreApplied() {
this.contextRunner.withPropertyValues("spring.http.reactiveclient.connector=jdk")
.withUserConfiguration(ClientHttpConnectorBuilderCustomizersConfiguration.class)
.run((context) -> {
ClientHttpConnector connector = context.getBean(ClientHttpConnectorBuilder.class).build();
assertThat(connector).extracting("readTimeout").isEqualTo(Duration.ofSeconds(5));
});
}
@Configuration(proxyBeanMethods = false)
static class CustomClientHttpConnectorConfig {
@Bean
ClientHttpConnector customConnector() {
return mock(ClientHttpConnector.class);
}
}
@Configuration(proxyBeanMethods = false)
static class CustomReactorResourceConfig {
@Bean
ReactorResourceFactory customReactorResourceFactory() {
return new ReactorResourceFactory();
}
}
@Configuration(proxyBeanMethods = false)
static class ClientHttpConnectorBuilderCustomizersConfiguration {
@Bean
ClientHttpConnectorBuilderCustomizer<JdkClientHttpConnectorBuilder> jdkCustomizer() {
return (builder) -> builder.withCustomizer((connector) -> connector.setReadTimeout(Duration.ofSeconds(5)));
}
@Bean
ClientHttpConnectorBuilderCustomizer<JettyClientHttpConnectorBuilder> jettyCustomizer() {
return (builder) -> {
throw new IllegalStateException();
};
}
}
}

View File

@@ -0,0 +1,98 @@
/*
* 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.autoconfigure;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.boot.http.client.HttpRedirects;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings;
import org.springframework.boot.http.client.reactive.JettyClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.ReactorClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.autoconfigure.AbstractClientHttpConnectorProperties.Connector;
import org.springframework.boot.ssl.DefaultSslBundleRegistry;
import org.springframework.boot.ssl.SslBundle;
import org.springframework.boot.ssl.SslBundles;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link ClientHttpConnectors}.
*
* @author Phillip Webb
*/
class ClientHttpConnectorsTests {
private final DefaultSslBundleRegistry bundleRegistry = new DefaultSslBundleRegistry();
private ObjectFactory<SslBundles> sslBundles = () -> this.bundleRegistry;
@Test
void builderWhenHasConnectorPropertyReturnsFirst() {
TestProperties p1 = new TestProperties();
TestProperties p2 = new TestProperties();
p2.setConnector(Connector.JETTY);
TestProperties p3 = new TestProperties();
p3.setConnector(Connector.JDK);
ClientHttpConnectors connectors = new ClientHttpConnectors(this.sslBundles, p1, p2, p3);
assertThat(connectors.builder(null)).isInstanceOf(JettyClientHttpConnectorBuilder.class);
}
@Test
void buildWhenHasNoConnectorPropertyReturnsDetected() {
TestProperties properties = new TestProperties();
ClientHttpConnectors connectors = new ClientHttpConnectors(this.sslBundles, properties);
assertThat(connectors.builder(null)).isInstanceOf(ReactorClientHttpConnectorBuilder.class);
}
@Test
void settingsWhenHasNoSettingProperties() {
TestProperties properties = new TestProperties();
ClientHttpConnectors connectors = new ClientHttpConnectors(this.sslBundles, properties);
ClientHttpConnectorSettings settings = connectors.settings();
assertThat(settings).isEqualTo(new ClientHttpConnectorSettings(null, null, null, null));
}
@Test
void settingsWhenHasMultipleSettingProperties() {
this.bundleRegistry.registerBundle("p2", mock(SslBundle.class));
this.bundleRegistry.registerBundle("p3", mock(SslBundle.class));
TestProperties p1 = new TestProperties();
TestProperties p2 = new TestProperties();
p2.setRedirects(HttpRedirects.DONT_FOLLOW);
p2.setConnectTimeout(Duration.ofSeconds(1));
p2.setReadTimeout(Duration.ofSeconds(2));
p2.getSsl().setBundle("p2");
TestProperties p3 = new TestProperties();
p3.setRedirects(HttpRedirects.FOLLOW);
p3.setConnectTimeout(Duration.ofSeconds(10));
p3.setReadTimeout(Duration.ofSeconds(20));
p3.getSsl().setBundle("p3");
ClientHttpConnectors connectors = new ClientHttpConnectors(this.sslBundles, p1, p2, p3);
ClientHttpConnectorSettings settings = connectors.settings();
assertThat(settings).isEqualTo(new ClientHttpConnectorSettings(HttpRedirects.DONT_FOLLOW, Duration.ofSeconds(1),
Duration.ofSeconds(2), this.bundleRegistry.getBundle("p2")));
}
static class TestProperties extends AbstractClientHttpConnectorProperties {
}
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.http.client.reactive.autoconfigure;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.boot.http.client.reactive.HttpComponentsClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.JdkClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.JettyClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.ReactorClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.autoconfigure.AbstractClientHttpConnectorProperties.Connector;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link HttpReactiveClientProperties}.
*
* @author Phillip Webb
*/
class HttpReactiveClientSettingsPropertiesTests {
@Nested
class ConnectorTests {
@Test
void reactorBuilder() {
assertThat(Connector.REACTOR.builder()).isInstanceOf(ReactorClientHttpConnectorBuilder.class);
}
@Test
void jettyBuilder() {
assertThat(Connector.JETTY.builder()).isInstanceOf(JettyClientHttpConnectorBuilder.class);
}
@Test
void httpComponentsBuilder() {
assertThat(Connector.HTTP_COMPONENTS.builder())
.isInstanceOf(HttpComponentsClientHttpConnectorBuilder.class);
}
@Test
void jdkBuilder() {
assertThat(Connector.JDK.builder()).isInstanceOf(JdkClientHttpConnectorBuilder.class);
}
}
}

View File

@@ -0,0 +1,97 @@
/*
* 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.service.autoconfigure;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.http.client.HttpRedirects;
import org.springframework.boot.http.client.reactive.autoconfigure.AbstractClientHttpConnectorProperties.Connector;
import org.springframework.boot.http.client.reactive.service.autoconfigure.ReactiveHttpClientServiceProperties.Group;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.mock.env.MockEnvironment;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link ReactiveHttpClientServiceProperties}.
*
* @author Phillip Webb
*/
class ReactiveHttpClientServicePropertiesTests {
@Test
void bindProperties() {
MockEnvironment environment = new MockEnvironment();
environment.setProperty("spring.http.reactiveclient.service.base-url", "https://example.com");
environment.setProperty("spring.http.reactiveclient.service.default-header.secure", "very,somewhat");
environment.setProperty("spring.http.reactiveclient.service.default-header.test", "true");
environment.setProperty("spring.http.reactiveclient.service.connector", "jetty");
environment.setProperty("spring.http.reactiveclient.service.redirects", "dont-follow");
environment.setProperty("spring.http.reactiveclient.service.connect-timeout", "1s");
environment.setProperty("spring.http.reactiveclient.service.read-timeout", "2s");
environment.setProperty("spring.http.reactiveclient.service.ssl.bundle", "usual");
environment.setProperty("spring.http.reactiveclient.service.group.olga.base-url", "https://example.com/olga");
environment.setProperty("spring.http.reactiveclient.service.group.olga.default-header.secure", "nope");
environment.setProperty("spring.http.reactiveclient.service.group.olga.connector", "reactor");
environment.setProperty("spring.http.reactiveclient.service.group.olga.redirects", "follow");
environment.setProperty("spring.http.reactiveclient.service.group.olga.connect-timeout", "10s");
environment.setProperty("spring.http.reactiveclient.service.group.olga.read-timeout", "20s");
environment.setProperty("spring.http.reactiveclient.service.group.olga.ssl.bundle", "unusual");
environment.setProperty("spring.http.reactiveclient.service.group.rossen.base-url",
"https://example.com/rossen");
environment.setProperty("spring.http.reactiveclient.service.group.phil.base-url", "https://example.com/phil");
try (AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext()) {
applicationContext.setEnvironment(environment);
applicationContext.register(PropertiesConfiguration.class);
applicationContext.refresh();
ReactiveHttpClientServiceProperties properties = applicationContext
.getBean(ReactiveHttpClientServiceProperties.class);
assertThat(properties.getBaseUrl()).isEqualTo("https://example.com");
assertThat(properties.getDefaultHeader()).containsOnly(Map.entry("secure", List.of("very", "somewhat")),
Map.entry("test", List.of("true")));
assertThat(properties.getConnector()).isEqualTo(Connector.JETTY);
assertThat(properties.getRedirects()).isEqualTo(HttpRedirects.DONT_FOLLOW);
assertThat(properties.getConnectTimeout()).isEqualTo(Duration.ofSeconds(1));
assertThat(properties.getReadTimeout()).isEqualTo(Duration.ofSeconds(2));
assertThat(properties.getSsl().getBundle()).isEqualTo("usual");
assertThat(properties.getGroup()).containsOnlyKeys("olga", "rossen", "phil");
assertThat(properties.getGroup().get("olga").getBaseUrl()).isEqualTo("https://example.com/olga");
assertThat(properties.getGroup().get("rossen").getBaseUrl()).isEqualTo("https://example.com/rossen");
assertThat(properties.getGroup().get("phil").getBaseUrl()).isEqualTo("https://example.com/phil");
Group groupProperties = properties.getGroup().get("olga");
assertThat(groupProperties.getDefaultHeader()).containsOnly(Map.entry("secure", List.of("nope")));
assertThat(groupProperties.getConnector()).isEqualTo(Connector.REACTOR);
assertThat(groupProperties.getRedirects()).isEqualTo(HttpRedirects.FOLLOW);
assertThat(groupProperties.getConnectTimeout()).isEqualTo(Duration.ofSeconds(10));
assertThat(groupProperties.getReadTimeout()).isEqualTo(Duration.ofSeconds(20));
assertThat(groupProperties.getSsl().getBundle()).isEqualTo("unusual");
}
}
@Configuration
@EnableConfigurationProperties(ReactiveHttpClientServiceProperties.class)
static class PropertiesConfiguration {
}
}

View File

@@ -0,0 +1,223 @@
/*
* 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.service.autoconfigure;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Redirect;
import java.util.List;
import java.util.Map;
import org.assertj.core.extractor.Extractors;
import org.junit.jupiter.api.Test;
import org.springframework.aop.Advisor;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.http.client.HttpRedirects;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorBuilder;
import org.springframework.boot.http.client.reactive.ClientHttpConnectorSettings;
import org.springframework.boot.http.client.reactive.autoconfigure.ClientHttpConnectorAutoConfiguration;
import org.springframework.boot.http.client.reactive.web.autoconfigure.WebClientAutoConfiguration;
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientHttpServiceGroupConfigurer;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.registry.HttpServiceGroup.ClientType;
import org.springframework.web.service.registry.HttpServiceProxyRegistry;
import org.springframework.web.service.registry.ImportHttpServices;
import org.springframework.web.util.UriComponentsBuilder;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link ReactiveHttpServiceClientAutoConfiguration},
* {@link WebClientPropertiesHttpServiceGroupConfigurer} and
* {@link WebClientCustomizerHttpServiceGroupConfigurer}.
*
* @author Phillip Webb
*/
class ReactiveHttpServiceClientAutoConfigurationTests {
private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(ReactiveHttpServiceClientAutoConfiguration.class,
ClientHttpConnectorAutoConfiguration.class, WebClientAutoConfiguration.class));
@Test
void configuresClientFromProperties() {
this.contextRunner
.withPropertyValues("spring.http.reactiveclient.service.base-url=https://example.com",
"spring.http.reactiveclient.service.default-header.test=true",
"spring.http.reactiveclient.service.group.one.base-url=https://example.com/one",
"spring.http.reactiveclient.service.group.two.default-header.two=iam2")
.withUserConfiguration(HttpClientConfiguration.class)
.run((context) -> {
HttpServiceProxyRegistry serviceProxyRegistry = context.getBean(HttpServiceProxyRegistry.class);
assertThat(serviceProxyRegistry.getGroupNames()).containsOnly("one", "two");
TestClientOne clientOne = context.getBean(TestClientOne.class);
WebClient webClientOne = getWebClient(clientOne);
assertThat(getUriComponentsBuilder(webClientOne).toUriString()).isEqualTo("https://example.com/one");
assertThat(getHttpHeaders(webClientOne).headerSet())
.containsExactlyInAnyOrder(Map.entry("test", List.of("true")));
TestClientTwo clientTwo = context.getBean(TestClientTwo.class);
WebClient webClientTwo = getWebClient(clientTwo);
assertThat(getUriComponentsBuilder(webClientTwo).toUriString()).isEqualTo("https://example.com");
assertThat(getHttpHeaders(webClientTwo).headerSet())
.containsExactlyInAnyOrder(Map.entry("test", List.of("true")), Map.entry("two", List.of("iam2")));
});
}
@Test
void whenHasUserDefinedHttpConnectorBuilder() {
this.contextRunner.withPropertyValues("spring.http.reactiveclient.service.base-url=https://example.com")
.withUserConfiguration(HttpClientConfiguration.class, HttpConnectorBuilderConfiguration.class)
.run((context) -> {
TestClientOne clientOne = context.getBean(TestClientOne.class);
assertThat(getJdkHttpClient(clientOne).followRedirects()).isEqualTo(Redirect.NEVER);
});
}
@Test
void whenHasUserDefinedRequestFactorySettings() {
this.contextRunner
.withPropertyValues("spring.http.reactiveclient.service.base-url=https://example.com",
"spring.http.reactiveclient.connector=jdk")
.withUserConfiguration(HttpClientConfiguration.class, HttpConnectorSettingsConfiguration.class)
.run((context) -> {
TestClientOne clientOne = context.getBean(TestClientOne.class);
assertThat(getJdkHttpClient(clientOne).followRedirects()).isEqualTo(Redirect.NEVER);
});
}
@Test
void whenHasUserDefinedWebClientCustomizer() {
this.contextRunner.withPropertyValues("spring.http.reactiveclient.service.base-url=https://example.com")
.withUserConfiguration(HttpClientConfiguration.class, WebClientCustomizerConfiguration.class)
.run((context) -> {
TestClientOne clientOne = context.getBean(TestClientOne.class);
WebClient webClientOne = getWebClient(clientOne);
assertThat(getHttpHeaders(webClientOne).headerSet())
.containsExactlyInAnyOrder(Map.entry("customized", List.of("true")));
});
}
@Test
void whenHasUserDefinedHttpServiceGroupConfigurer() {
this.contextRunner.withPropertyValues("spring.http.reactiveclient.service.base-url=https://example.com")
.withUserConfiguration(HttpClientConfiguration.class, HttpServiceGroupConfigurerConfiguration.class)
.run((context) -> {
TestClientOne clientOne = context.getBean(TestClientOne.class);
WebClient webClientOne = getWebClient(clientOne);
assertThat(getHttpHeaders(webClientOne).headerSet())
.containsExactlyInAnyOrder(Map.entry("customizedgroup", List.of("true")));
});
}
@Test
void whenHasNoHttpServiceProxyRegistryBean() {
this.contextRunner.withPropertyValues("spring.http.client.reactiveclient.base-url=https://example.com")
.run((context) -> assertThat(context).doesNotHaveBean(HttpServiceProxyRegistry.class));
}
private HttpClient getJdkHttpClient(Object proxy) {
return (HttpClient) Extractors.byName("builder.connector.httpClient").apply(getWebClient(proxy));
}
private HttpHeaders getHttpHeaders(WebClient webClient) {
return (HttpHeaders) Extractors.byName("defaultHeaders").apply(webClient);
}
private UriComponentsBuilder getUriComponentsBuilder(WebClient webClient) {
return (UriComponentsBuilder) Extractors.byName("uriBuilderFactory.baseUri").apply(webClient);
}
private WebClient getWebClient(Object proxy) {
InvocationHandler handler = Proxy.getInvocationHandler(proxy);
Advisor[] advisors = (Advisor[]) Extractors.byName("advised.advisors").apply(handler);
Map<?, ?> serviceMethods = (Map<?, ?>) Extractors.byName("advice.httpServiceMethods").apply(advisors[0]);
Object serviceMethod = serviceMethods.values().iterator().next();
return (WebClient) Extractors.byName("responseFunction.responseFunction.arg$1.webClient").apply(serviceMethod);
}
@Configuration(proxyBeanMethods = false)
@ImportHttpServices(group = "one", types = TestClientOne.class, clientType = ClientType.WEB_CLIENT)
@ImportHttpServices(group = "two", types = TestClientTwo.class, clientType = ClientType.WEB_CLIENT)
static class HttpClientConfiguration {
}
@Configuration(proxyBeanMethods = false)
static class HttpConnectorBuilderConfiguration {
@Bean
ClientHttpConnectorBuilder<?> httpConnectorBuilder() {
return ClientHttpConnectorBuilder.jdk()
.withHttpClientCustomizer((httpClient) -> httpClient.followRedirects(Redirect.NEVER));
}
}
@Configuration(proxyBeanMethods = false)
static class HttpConnectorSettingsConfiguration {
@Bean
ClientHttpConnectorSettings httpConnectorSettings() {
return ClientHttpConnectorSettings.defaults().withRedirects(HttpRedirects.DONT_FOLLOW);
}
}
@Configuration(proxyBeanMethods = false)
static class WebClientCustomizerConfiguration {
@Bean
WebClientCustomizer webClientCustomizer() {
return (builder) -> builder.defaultHeader("customized", "true");
}
}
@Configuration(proxyBeanMethods = false)
static class HttpServiceGroupConfigurerConfiguration {
@Bean
WebClientHttpServiceGroupConfigurer restClientHttpServiceGroupConfigurer() {
return (groups) -> groups.filterByName("one")
.forEachClient((group, builder) -> builder.defaultHeader("customizedgroup", "true"));
}
}
interface TestClientOne {
@GetExchange("/hello")
String hello();
}
interface TestClientTwo {
@GetExchange("/there")
String there();
}
}

View File

@@ -0,0 +1,139 @@
/*
* 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.web.autoconfigure;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.ssl.SslAutoConfiguration;
import org.springframework.boot.http.codec.CodecCustomizer;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.boot.web.reactive.function.client.WebClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.codec.CodecConfigurer;
import org.springframework.web.reactive.function.client.WebClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link WebClientAutoConfiguration}
*
* @author Brian Clozel
*/
class WebClientAutoConfigurationTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(
org.springframework.boot.http.client.reactive.autoconfigure.ClientHttpConnectorAutoConfiguration.class,
WebClientAutoConfiguration.class, SslAutoConfiguration.class));
@Test
void shouldCreateBuilder() {
this.contextRunner.run((context) -> {
WebClient.Builder builder = context.getBean(WebClient.Builder.class);
WebClient webClient = builder.build();
assertThat(webClient).isNotNull();
});
}
@Test
void shouldCustomizeClientCodecs() {
this.contextRunner.withUserConfiguration(CodecConfiguration.class).run((context) -> {
WebClient.Builder builder = context.getBean(WebClient.Builder.class);
CodecCustomizer codecCustomizer = context.getBean(CodecCustomizer.class);
WebClientCodecCustomizer clientCustomizer = context.getBean(WebClientCodecCustomizer.class);
builder.build();
assertThat(clientCustomizer).isNotNull();
then(codecCustomizer).should().customize(any(CodecConfigurer.class));
});
}
@Test
void webClientShouldApplyCustomizers() {
this.contextRunner.withUserConfiguration(WebClientCustomizerConfig.class).run((context) -> {
WebClient.Builder builder = context.getBean(WebClient.Builder.class);
WebClientCustomizer customizer = context.getBean("webClientCustomizer", WebClientCustomizer.class);
builder.build();
then(customizer).should().customize(any(WebClient.Builder.class));
});
}
@Test
void shouldGetPrototypeScopedBean() {
this.contextRunner.withUserConfiguration(WebClientCustomizerConfig.class).run((context) -> {
WebClient.Builder firstBuilder = context.getBean(WebClient.Builder.class);
WebClient.Builder secondBuilder = context.getBean(WebClient.Builder.class);
assertThat(firstBuilder).isNotEqualTo(secondBuilder);
});
}
@Test
void shouldNotCreateClientBuilderIfAlreadyPresent() {
this.contextRunner.withUserConfiguration(WebClientCustomizerConfig.class, CustomWebClientBuilderConfig.class)
.run((context) -> {
WebClient.Builder builder = context.getBean(WebClient.Builder.class);
assertThat(builder).isInstanceOf(MyWebClientBuilder.class);
});
}
@Test
void shouldCreateWebClientSsl() {
this.contextRunner.run((context) -> {
WebClientSsl webClientSsl = context.getBean(WebClientSsl.class);
assertThat(webClientSsl).isInstanceOf(AutoConfiguredWebClientSsl.class);
});
}
@Configuration(proxyBeanMethods = false)
static class CodecConfiguration {
@Bean
CodecCustomizer myCodecCustomizer() {
return mock(CodecCustomizer.class);
}
}
@Configuration(proxyBeanMethods = false)
static class WebClientCustomizerConfig {
@Bean
WebClientCustomizer webClientCustomizer() {
return mock(WebClientCustomizer.class);
}
}
@Configuration(proxyBeanMethods = false)
static class CustomWebClientBuilderConfig {
@Bean
MyWebClientBuilder myWebClientBuilder() {
return mock(MyWebClientBuilder.class);
}
}
interface MyWebClientBuilder extends WebClient.Builder {
}
}