Support declarative HTTP Service clients

Add auto-configuration support for Spring Framework's HTTP Service
clients, supporting both `RestClient` and `WebClient` client types.

Configuration properties are provided under `spring.http.client.service`
and `spring.http.reactiveclient.service` names. Group specific
properties are available under `....service.group.<name>.`.

Auto-configuration support includes automatic registration of user
defined `HttpServiceGroupConfigurer` beans. `RestClientCustomizer` and
`WebClientCustomizer` beans are also supported.

Closes gh-31337

Co-authored-by: Rossen Stoyanchev <rossen.stoyanchev@broadcom.com>
Co-authored-by: Phillip Webb <phil.webb@broadcom.com>
This commit is contained in:
Olga Maciaszek-Sharma
2025-04-30 18:58:40 -07:00
committed by Phillip Webb
parent f1f54382ed
commit 8f6a9b9440
21 changed files with 1651 additions and 12 deletions

View File

@@ -21,7 +21,7 @@ import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.boot.autoconfigure.http.client.AbstractHttpClientProperties.Ssl;
import org.springframework.boot.autoconfigure.http.client.AbstractHttpRequestFactoryProperties.Factory;
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
@@ -37,25 +37,26 @@ import org.springframework.util.StringUtils;
* {@link ClientHttpRequestFactorySettings}.
*
* @author Phillip Webb
* @since 4.0.0
*/
class ClientHttpRequestFactories {
public final class ClientHttpRequestFactories {
private final ObjectProvider<SslBundles> sslBundles;
private final ObjectFactory<SslBundles> sslBundles;
private final AbstractHttpRequestFactoryProperties[] orderedProperties;
ClientHttpRequestFactories(ObjectProvider<SslBundles> sslBundles,
public ClientHttpRequestFactories(ObjectFactory<SslBundles> sslBundles,
AbstractHttpRequestFactoryProperties... orderedProperties) {
this.sslBundles = sslBundles;
this.orderedProperties = orderedProperties;
}
ClientHttpRequestFactoryBuilder<?> builder(ClassLoader classLoader) {
public ClientHttpRequestFactoryBuilder<?> builder(ClassLoader classLoader) {
Factory factory = getProperty(AbstractHttpRequestFactoryProperties::getFactory);
return (factory != null) ? factory.builder() : ClientHttpRequestFactoryBuilder.detect(classLoader);
}
ClientHttpRequestFactorySettings settings() {
public ClientHttpRequestFactorySettings settings() {
HttpRedirects redirects = getProperty(AbstractHttpRequestFactoryProperties::getRedirects);
Duration connectTimeout = getProperty(AbstractHttpRequestFactoryProperties::getConnectTimeout);
Duration readTimeout = getProperty(AbstractHttpRequestFactoryProperties::getReadTimeout);

View File

@@ -21,7 +21,7 @@ import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.boot.autoconfigure.http.client.AbstractHttpClientProperties.Ssl;
import org.springframework.boot.autoconfigure.http.client.reactive.AbstractClientHttpConnectorProperties.Connector;
import org.springframework.boot.http.client.HttpRedirects;
@@ -36,25 +36,26 @@ import org.springframework.util.StringUtils;
* {@link ClientHttpConnectorSettings}.
*
* @author Phillip Webb
* @since 4.0.0
*/
class ClientHttpConnectors {
public final class ClientHttpConnectors {
private final ObjectProvider<SslBundles> sslBundles;
private final ObjectFactory<SslBundles> sslBundles;
private final AbstractClientHttpConnectorProperties[] orderedProperties;
ClientHttpConnectors(ObjectProvider<SslBundles> sslBundles,
public ClientHttpConnectors(ObjectFactory<SslBundles> sslBundles,
AbstractClientHttpConnectorProperties... orderedProperties) {
this.sslBundles = sslBundles;
this.orderedProperties = orderedProperties;
}
ClientHttpConnectorBuilder<?> builder(ClassLoader classLoader) {
public ClientHttpConnectorBuilder<?> builder(ClassLoader classLoader) {
Connector connector = getProperty(AbstractClientHttpConnectorProperties::getConnector);
return (connector != null) ? connector.builder() : ClientHttpConnectorBuilder.detect(classLoader);
}
ClientHttpConnectorSettings settings() {
public ClientHttpConnectorSettings settings() {
HttpRedirects redirects = getProperty(AbstractClientHttpConnectorProperties::getRedirects);
Duration connectTimeout = getProperty(AbstractClientHttpConnectorProperties::getConnectTimeout);
Duration readTimeout = getProperty(AbstractClientHttpConnectorProperties::getReadTimeout);

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.autoconfigure.http.client.reactive.service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.http.client.reactive.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.autoconfigure.http.client.reactive.service;
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.autoconfigure.http.client.reactive.service;
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.autoconfigure.http.client.reactive.ClientHttpConnectorAutoConfiguration;
import org.springframework.boot.autoconfigure.http.client.reactive.HttpReactiveClientProperties;
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
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.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,61 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.http.client.reactive.service;
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;
/**
* 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.configureClient(this::configureClient);
}
private void configureClient(WebClient.Builder builder) {
this.customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
}
}

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.autoconfigure.http.client.reactive.service;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.http.client.HttpClientProperties;
import org.springframework.boot.autoconfigure.http.client.reactive.ClientHttpConnectors;
import org.springframework.boot.autoconfigure.http.client.reactive.HttpReactiveClientProperties;
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.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 HttpClientProperties}.
*
* @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.configureClient(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.autoconfigure.http.client.reactive.service;

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.autoconfigure.http.client.service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.autoconfigure.http.client.AbstractHttpRequestFactoryProperties;
/**
* {@link AbstractHttpRequestFactoryProperties} for HTTP Service clients.
*
* @author Olga Maciaszek-Sharma
* @author Rossen Stoyanchev
* @author Phillip Webb
* @since 4.0.0
*/
public abstract class AbstractHttpClientServiceProperties extends AbstractHttpRequestFactoryProperties {
/**
* 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.autoconfigure.http.client.service;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Properties for HTTP Service clients.
*
* @author Olga Maciaszek-Sharma
* @author Rossen Stoyanchev
* @author Phillip Webb
* @since 4.0.0
*/
@ConfigurationProperties("spring.http.client.service")
public class HttpClientServiceProperties extends AbstractHttpClientServiceProperties {
/**
* 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 AbstractHttpClientServiceProperties {
}
}

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.autoconfigure.http.client.service;
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.autoconfigure.http.client.HttpClientAutoConfiguration;
import org.springframework.boot.autoconfigure.http.client.HttpClientProperties;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.ClientHttpRequestFactorySettings;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.registry.HttpServiceProxyRegistry;
import org.springframework.web.service.registry.ImportHttpServices;
/**
* AutoConfiguration for Spring HTTP Service clients.
* <p>
* This will result in the creation of blocking 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 = { HttpClientAutoConfiguration.class, RestClientAutoConfiguration.class })
@ConditionalOnClass(RestClientAdapter.class)
@ConditionalOnBean(HttpServiceProxyRegistry.class)
@EnableConfigurationProperties(HttpClientServiceProperties.class)
public class HttpServiceClientAutoConfiguration implements BeanClassLoaderAware {
private ClassLoader beanClassLoader;
HttpServiceClientAutoConfiguration() {
}
@Override
public void setBeanClassLoader(ClassLoader classLoader) {
this.beanClassLoader = classLoader;
}
@Bean
RestClientPropertiesHttpServiceGroupConfigurer restClientPropertiesHttpServiceGroupConfigurer(
ObjectProvider<SslBundles> sslBundles, HttpClientProperties httpClientProperties,
HttpClientServiceProperties serviceProperties,
ObjectProvider<ClientHttpRequestFactoryBuilder<?>> clientFactoryBuilder,
ObjectProvider<ClientHttpRequestFactorySettings> clientHttpRequestFactorySettings) {
return new RestClientPropertiesHttpServiceGroupConfigurer(this.beanClassLoader, sslBundles,
httpClientProperties, serviceProperties, clientFactoryBuilder, clientHttpRequestFactorySettings);
}
@Bean
RestClientCustomizerHttpServiceGroupConfigurer restClientCustomizerHttpServiceGroupConfigurer(
ObjectProvider<RestClientCustomizer> customizers) {
return new RestClientCustomizerHttpServiceGroupConfigurer(customizers);
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.http.client.service;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer;
/**
* A {@link RestClientHttpServiceGroupConfigurer} to apply auto-configured
* {@link RestClientCustomizer} beans to the group's {@link RestClient}.
*
* @author Olga Maciaszek-Sharma
* @author Phillip Webb
*/
class RestClientCustomizerHttpServiceGroupConfigurer implements RestClientHttpServiceGroupConfigurer {
/**
* Allow user defined configurers to apply before / after ours.
*/
private static final int ORDER = 0;
private final ObjectProvider<RestClientCustomizer> customizers;
RestClientCustomizerHttpServiceGroupConfigurer(ObjectProvider<RestClientCustomizer> customizers) {
this.customizers = customizers;
}
@Override
public int getOrder() {
return ORDER;
}
@Override
public void configureGroups(Groups<RestClient.Builder> groups) {
groups.configureClient(this::configureClient);
}
private void configureClient(RestClient.Builder builder) {
this.customizers.orderedStream().forEach((customizer) -> customizer.customize(builder));
}
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.http.client.service;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.http.client.ClientHttpRequestFactories;
import org.springframework.boot.autoconfigure.http.client.HttpClientProperties;
import org.springframework.boot.context.properties.PropertyMapper;
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.ClientHttpRequestFactorySettings;
import org.springframework.boot.ssl.SslBundles;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer;
import org.springframework.web.service.registry.HttpServiceGroup;
/**
* A {@link RestClientHttpServiceGroupConfigurer} that configures the group and its
* underlying {@link RestClient} using {@link HttpClientProperties}.
*
* @author Olga Maciaszek-Sharma
* @author Phillip Webb
*/
class RestClientPropertiesHttpServiceGroupConfigurer implements RestClientHttpServiceGroupConfigurer {
private final ClassLoader classLoader;
private final ObjectProvider<SslBundles> sslBundles;
private final HttpClientProperties clientProperties;
private final HttpClientServiceProperties serviceProperties;
private final ObjectProvider<ClientHttpRequestFactoryBuilder<?>> requestFactoryBuilder;
private final ObjectProvider<ClientHttpRequestFactorySettings> requestFactorySettings;
RestClientPropertiesHttpServiceGroupConfigurer(ClassLoader classLoader, ObjectProvider<SslBundles> sslBundles,
HttpClientProperties clientProperties, HttpClientServiceProperties serviceProperties,
ObjectProvider<ClientHttpRequestFactoryBuilder<?>> requestFactoryBuilder,
ObjectProvider<ClientHttpRequestFactorySettings> requestFactorySettings) {
this.classLoader = classLoader;
this.sslBundles = sslBundles;
this.clientProperties = clientProperties;
this.serviceProperties = serviceProperties;
this.requestFactoryBuilder = requestFactoryBuilder;
this.requestFactorySettings = requestFactorySettings;
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
@Override
public void configureGroups(Groups<RestClient.Builder> groups) {
groups.configureClient(this::configureClient);
}
private void configureClient(HttpServiceGroup group, RestClient.Builder builder) {
HttpClientServiceProperties.Group groupProperties = this.serviceProperties.getGroup().get(group.name());
builder.requestFactory(getRequestFactory(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 ClientHttpRequestFactory getRequestFactory(HttpClientServiceProperties.Group groupProperties) {
ClientHttpRequestFactories factories = new ClientHttpRequestFactories(this.sslBundles, groupProperties,
this.serviceProperties, this.clientProperties);
ClientHttpRequestFactoryBuilder<?> builder = this.requestFactoryBuilder
.getIfAvailable(() -> factories.builder(this.classLoader));
ClientHttpRequestFactorySettings settings = this.requestFactorySettings.getIfAvailable(factories::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 Blocking HTTP Service Interface Clients.
*/
package org.springframework.boot.autoconfigure.http.client.service;

View File

@@ -65,6 +65,8 @@ org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfigurati
org.springframework.boot.autoconfigure.http.client.HttpClientAutoConfiguration
org.springframework.boot.autoconfigure.http.client.reactive.ClientHttpConnectorAutoConfiguration
org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration
org.springframework.boot.autoconfigure.http.client.service.HttpServiceClientAutoConfiguration
org.springframework.boot.autoconfigure.http.client.reactive.service.ReactiveHttpServiceClientAutoConfiguration
org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration
org.springframework.boot.autoconfigure.integration.IntegrationAutoConfiguration
org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.http.client;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.boot.autoconfigure.http.client.AbstractHttpRequestFactoryProperties.Factory;
import org.springframework.boot.http.client.ClientHttpRequestFactorySettings;
import org.springframework.boot.http.client.ClientHttpRequestFactorySettings.Redirects;
import org.springframework.boot.http.client.HttpComponentsClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.HttpRedirects;
import org.springframework.boot.http.client.JettyClientHttpRequestFactoryBuilder;
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 ClientHttpRequestFactories}
*
* @author Phillip Webb
*/
class ClientHttpRequestFactoriesTests {
private final DefaultSslBundleRegistry bundleRegistry = new DefaultSslBundleRegistry();
private ObjectFactory<SslBundles> sslBundles = () -> this.bundleRegistry;
@Test
void builderWhenHasFactoryPropertyReturnsFirst() {
TestProperties p1 = new TestProperties();
TestProperties p2 = new TestProperties();
p2.setFactory(Factory.JETTY);
TestProperties p3 = new TestProperties();
p3.setFactory(Factory.REACTOR);
ClientHttpRequestFactories factories = new ClientHttpRequestFactories(this.sslBundles, p1, p2, p3);
assertThat(factories.builder(null)).isInstanceOf(JettyClientHttpRequestFactoryBuilder.class);
}
@Test
void buildWhenHasNoFactoryPropertyReturnsDetected() {
TestProperties properties = new TestProperties();
ClientHttpRequestFactories factories = new ClientHttpRequestFactories(this.sslBundles, properties);
assertThat(factories.builder(null)).isInstanceOf(HttpComponentsClientHttpRequestFactoryBuilder.class);
}
@Test
void settingsWhenHasNoSettingProperties() {
TestProperties properties = new TestProperties();
ClientHttpRequestFactories factories = new ClientHttpRequestFactories(this.sslBundles, properties);
ClientHttpRequestFactorySettings settings = factories.settings();
assertThat(settings).isEqualTo(new ClientHttpRequestFactorySettings(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");
ClientHttpRequestFactories factories = new ClientHttpRequestFactories(this.sslBundles, p1, p2, p3);
ClientHttpRequestFactorySettings settings = factories.settings();
assertThat(settings).isEqualTo(new ClientHttpRequestFactorySettings(Redirects.DONT_FOLLOW,
Duration.ofSeconds(1), Duration.ofSeconds(2), this.bundleRegistry.getBundle("p2")));
}
static class TestProperties extends AbstractHttpRequestFactoryProperties {
}
}

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.autoconfigure.http.client.reactive;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.boot.autoconfigure.http.client.reactive.AbstractClientHttpConnectorProperties.Connector;
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.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,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.autoconfigure.http.client.reactive.service;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.http.client.reactive.AbstractClientHttpConnectorProperties.Connector;
import org.springframework.boot.autoconfigure.http.client.reactive.service.ReactiveHttpClientServiceProperties.Group;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.http.client.HttpRedirects;
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.autoconfigure.http.client.reactive.service;
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.autoconfigure.http.client.reactive.ClientHttpConnectorAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
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.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")
.configureClient((builder) -> builder.defaultHeader("customizedgroup", "true"));
}
}
interface TestClientOne {
@GetExchange("/hello")
String hello();
}
interface TestClientTwo {
@GetExchange("/there")
String there();
}
}

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.autoconfigure.http.client.service;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.http.client.AbstractHttpRequestFactoryProperties.Factory;
import org.springframework.boot.autoconfigure.http.client.service.HttpClientServiceProperties.Group;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.http.client.HttpRedirects;
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 HttpClientServiceProperties}.
*
* @author Phillip Webb
*/
class HttpClientServicePropertiesTests {
@Test
void bindProperties() {
MockEnvironment environment = new MockEnvironment();
environment.setProperty("spring.http.client.service.base-url", "https://example.com");
environment.setProperty("spring.http.client.service.default-header.secure", "very,somewhat");
environment.setProperty("spring.http.client.service.default-header.test", "true");
environment.setProperty("spring.http.client.service.factory", "jetty");
environment.setProperty("spring.http.client.service.redirects", "dont-follow");
environment.setProperty("spring.http.client.service.connect-timeout", "1s");
environment.setProperty("spring.http.client.service.read-timeout", "2s");
environment.setProperty("spring.http.client.service.ssl.bundle", "usual");
environment.setProperty("spring.http.client.service.group.olga.base-url", "https://example.com/olga");
environment.setProperty("spring.http.client.service.group.olga.default-header.secure", "nope");
environment.setProperty("spring.http.client.service.group.olga.factory", "reactor");
environment.setProperty("spring.http.client.service.group.olga.redirects", "follow");
environment.setProperty("spring.http.client.service.group.olga.connect-timeout", "10s");
environment.setProperty("spring.http.client.service.group.olga.read-timeout", "20s");
environment.setProperty("spring.http.client.service.group.olga.ssl.bundle", "unusual");
environment.setProperty("spring.http.client.service.group.rossen.base-url", "https://example.com/rossen");
environment.setProperty("spring.http.client.service.group.phil.base-url", "https://example.com/phil");
try (AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext()) {
applicationContext.setEnvironment(environment);
applicationContext.register(PropertiesConfiguration.class);
applicationContext.refresh();
HttpClientServiceProperties properties = applicationContext.getBean(HttpClientServiceProperties.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.getFactory()).isEqualTo(Factory.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.getFactory()).isEqualTo(Factory.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(HttpClientServiceProperties.class)
static class PropertiesConfiguration {
}
}

View File

@@ -0,0 +1,255 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.autoconfigure.http.client.service;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Proxy;
import java.net.http.HttpClient;
import java.net.http.HttpClient.Redirect;
import java.util.HashMap;
import java.util.Map;
import java.util.function.BiConsumer;
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.autoconfigure.http.client.HttpClientAutoConfiguration;
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder;
import org.springframework.boot.http.client.ClientHttpRequestFactorySettings;
import org.springframework.boot.http.client.ClientHttpRequestFactorySettings.Redirects;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClient.Builder;
import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.registry.HttpServiceGroup;
import org.springframework.web.service.registry.HttpServiceProxyRegistry;
import org.springframework.web.service.registry.ImportHttpServices;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/**
* Tests for {@link HttpServiceClientAutoConfiguration},
* {@link RestClientPropertiesHttpServiceGroupConfigurer} and
* {@link RestClientCustomizerHttpServiceGroupConfigurer}.
*
* @author Phillip Webb
*/
class HttpServiceClientAutoConfigurationTests {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(HttpServiceClientAutoConfiguration.class,
HttpClientAutoConfiguration.class, RestClientAutoConfiguration.class));
@Test
void configuresClientFromProperties() {
this.contextRunner
.withPropertyValues("spring.http.client.service.base-url=https://example.com",
"spring.http.client.service.default-header.test=true",
"spring.http.client.service.group.one.base-url=https://example.com/one",
"spring.http.client.service.group.two.default-header.two=iam2")
.withUserConfiguration(HttpClientConfiguration.class, MockRestServiceServerConfiguration.class)
.run((context) -> {
HttpServiceProxyRegistry serviceProxyRegistry = context.getBean(HttpServiceProxyRegistry.class);
assertThat(serviceProxyRegistry.getGroupNames()).containsOnly("one", "two");
MockRestServiceServerConfiguration mockServers = context
.getBean(MockRestServiceServerConfiguration.class);
MockRestServiceServer serverOne = mockServers.getMock("one");
serverOne.expect(requestTo("https://example.com/one/hello"))
.andExpect(header("test", "true"))
.andRespond(withSuccess().body("world!"));
TestClientOne clientOne = context.getBean(TestClientOne.class);
assertThat(clientOne.hello()).isEqualTo("world!");
MockRestServiceServer serverTwo = mockServers.getMock("two");
serverTwo.expect((request) -> request.getURI().toString().equals("https://example.com/"))
.andExpect(header("test", "true"))
.andExpect(header("two", "iam2"))
.andRespond(withSuccess().body("boot!"));
TestClientTwo clientTwo = context.getBean(TestClientTwo.class);
assertThat(clientTwo.there()).isEqualTo("boot!");
});
}
@Test
void whenHasUserDefinedRequestFactoryBuilder() {
this.contextRunner.withPropertyValues("spring.http.client.service.base-url=https://example.com")
.withUserConfiguration(HttpClientConfiguration.class, RequestFactoryBuilderConfiguration.class)
.run((context) -> {
TestClientOne clientOne = context.getBean(TestClientOne.class);
assertThat(getJdkHttpClient(clientOne).followRedirects()).isEqualTo(Redirect.NEVER);
});
}
@Test
void whenHasUserDefinedRequestFactorySettings() {
this.contextRunner
.withPropertyValues("spring.http.client.service.base-url=https://example.com",
"spring.http.client.factory=jdk")
.withUserConfiguration(HttpClientConfiguration.class, RequestFactorySettingsConfiguration.class)
.run((context) -> {
TestClientOne clientOne = context.getBean(TestClientOne.class);
assertThat(getJdkHttpClient(clientOne).followRedirects()).isEqualTo(Redirect.NEVER);
});
}
@Test
void whenHasUserDefinedRestClientCustomizer() {
this.contextRunner.withPropertyValues("spring.http.client.service.base-url=https://example.com")
.withUserConfiguration(HttpClientConfiguration.class, MockRestServiceServerConfiguration.class,
RestClientCustomizerConfiguration.class)
.run((context) -> {
MockRestServiceServerConfiguration mockServers = context
.getBean(MockRestServiceServerConfiguration.class);
MockRestServiceServer serverOne = mockServers.getMock("one");
serverOne.expect(requestTo("https://example.com/hello"))
.andExpect(header("customized", "true"))
.andRespond(withSuccess().body("world!"));
TestClientOne clientOne = context.getBean(TestClientOne.class);
assertThat(clientOne.hello()).isEqualTo("world!");
});
}
@Test
void whenHasUserDefinedHttpServiceGroupConfigurer() {
this.contextRunner.withPropertyValues("spring.http.client.service.base-url=https://example.com")
.withUserConfiguration(HttpClientConfiguration.class, MockRestServiceServerConfiguration.class,
HttpServiceGroupConfigurerConfiguration.class)
.run((context) -> {
MockRestServiceServerConfiguration mockServers = context
.getBean(MockRestServiceServerConfiguration.class);
MockRestServiceServer serverOne = mockServers.getMock("one");
serverOne.expect(requestTo("https://example.com/hello"))
.andExpect(header("customizedgroup", "true"))
.andRespond(withSuccess().body("world!"));
TestClientOne clientOne = context.getBean(TestClientOne.class);
assertThat(clientOne.hello()).isEqualTo("world!");
});
}
@Test
void whenHasNoHttpServiceProxyRegistryBean() {
this.contextRunner.withPropertyValues("spring.http.client.service.base-url=https://example.com")
.run((context) -> assertThat(context).doesNotHaveBean(HttpServiceProxyRegistry.class));
}
private HttpClient getJdkHttpClient(Object proxy) {
return (HttpClient) Extractors.byName("clientRequestFactory.httpClient").apply(getRestClient(proxy));
}
private RestClient getRestClient(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 (RestClient) Extractors.byName("responseFunction.responseFunction.arg$1.restClient")
.apply(serviceMethod);
}
@Configuration(proxyBeanMethods = false)
static class MockRestServiceServerConfiguration {
private Map<String, MockRestServiceServer> mocks = new HashMap<>();
@Bean
RestClientHttpServiceGroupConfigurer mockServerConfigurer() {
return (groups) -> groups.configureClient((BiConsumer<HttpServiceGroup, Builder>) this::addMock);
}
private MockRestServiceServer addMock(HttpServiceGroup group, Builder client) {
return this.mocks.put(group.name(), MockRestServiceServer.bindTo(client).build());
}
MockRestServiceServer getMock(String name) {
return this.mocks.get(name);
}
}
@Configuration(proxyBeanMethods = false)
@ImportHttpServices(group = "one", types = TestClientOne.class)
@ImportHttpServices(group = "two", types = TestClientTwo.class)
static class HttpClientConfiguration {
}
@Configuration(proxyBeanMethods = false)
static class RequestFactoryBuilderConfiguration {
@Bean
ClientHttpRequestFactoryBuilder<?> requestFactoryBuilder() {
return ClientHttpRequestFactoryBuilder.jdk()
.withHttpClientCustomizer((httpClient) -> httpClient.followRedirects(Redirect.NEVER));
}
}
@Configuration(proxyBeanMethods = false)
static class RequestFactorySettingsConfiguration {
@Bean
ClientHttpRequestFactorySettings requestFactorySettings() {
return ClientHttpRequestFactorySettings.defaults().withRedirects(Redirects.DONT_FOLLOW);
}
}
@Configuration(proxyBeanMethods = false)
static class RestClientCustomizerConfiguration {
@Bean
RestClientCustomizer restClientCustomizer() {
return (builder) -> builder.defaultHeader("customized", "true");
}
}
@Configuration(proxyBeanMethods = false)
static class HttpServiceGroupConfigurerConfiguration {
@Bean
RestClientHttpServiceGroupConfigurer restClientHttpServiceGroupConfigurer() {
return (groups) -> groups.filterByName("one")
.configureClient((builder) -> builder.defaultHeader("customizedgroup", "true"));
}
}
interface TestClientOne {
@GetExchange("/hello")
String hello();
}
interface TestClientTwo {
@GetExchange("/there")
String there();
}
}