diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/ClientHttpRequestFactories.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/ClientHttpRequestFactories.java index 340f6ff131..d1327e5fe2 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/ClientHttpRequestFactories.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/ClientHttpRequestFactories.java @@ -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; + private final ObjectFactory sslBundles; private final AbstractHttpRequestFactoryProperties[] orderedProperties; - ClientHttpRequestFactories(ObjectProvider sslBundles, + public ClientHttpRequestFactories(ObjectFactory 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); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectors.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectors.java index 6eaeffe9ab..ff962c30ee 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectors.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectors.java @@ -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; + private final ObjectFactory sslBundles; private final AbstractClientHttpConnectorProperties[] orderedProperties; - ClientHttpConnectors(ObjectProvider sslBundles, + public ClientHttpConnectors(ObjectFactory 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); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/AbstractHttpReactiveClientServiceProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/AbstractHttpReactiveClientServiceProperties.java new file mode 100644 index 0000000000..6977c34cd7 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/AbstractHttpReactiveClientServiceProperties.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.http.client.reactive.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> defaultHeader = new LinkedHashMap<>(); + + public String getBaseUrl() { + return this.baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public Map> getDefaultHeader() { + return this.defaultHeader; + } + + public void setDefaultHeader(Map> defaultHeaders) { + this.defaultHeader = defaultHeaders; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/ReactiveHttpClientServiceProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/ReactiveHttpClientServiceProperties.java new file mode 100644 index 0000000000..d76fb035b9 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/ReactiveHttpClientServiceProperties.java @@ -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 group = new LinkedHashMap<>(); + + public Map getGroup() { + return this.group; + } + + public void setGroup(Map group) { + this.group = group; + } + + /** + * Properties for a single HTTP Service client group. + */ + public static class Group extends AbstractHttpReactiveClientServiceProperties { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/ReactiveHttpServiceClientAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/ReactiveHttpServiceClientAutoConfiguration.java new file mode 100644 index 0000000000..5b60fcae6b --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/ReactiveHttpServiceClientAutoConfiguration.java @@ -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. + *

+ * 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, HttpReactiveClientProperties httpReactiveClientProperties, + ReactiveHttpClientServiceProperties serviceProperties, + ObjectProvider> clientConnectorBuilder, + ObjectProvider clientConnectorSettings) { + return new WebClientPropertiesHttpServiceGroupConfigurer(this.beanClassLoader, sslBundles, + httpReactiveClientProperties, serviceProperties, clientConnectorBuilder, clientConnectorSettings); + } + + @Bean + WebClientCustomizerHttpServiceGroupConfigurer webClientCustomizerHttpServiceGroupConfigurer( + ObjectProvider customizers) { + return new WebClientCustomizerHttpServiceGroupConfigurer(customizers); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/WebClientCustomizerHttpServiceGroupConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/WebClientCustomizerHttpServiceGroupConfigurer.java new file mode 100644 index 0000000000..377107b4eb --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/WebClientCustomizerHttpServiceGroupConfigurer.java @@ -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 customizers; + + WebClientCustomizerHttpServiceGroupConfigurer(ObjectProvider customizers) { + this.customizers = customizers; + } + + @Override + public int getOrder() { + return ORDER; + } + + @Override + public void configureGroups(Groups groups) { + groups.configureClient(this::configureClient); + } + + private void configureClient(WebClient.Builder builder) { + this.customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/WebClientPropertiesHttpServiceGroupConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/WebClientPropertiesHttpServiceGroupConfigurer.java new file mode 100644 index 0000000000..bf16ea1643 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/WebClientPropertiesHttpServiceGroupConfigurer.java @@ -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; + + private final HttpReactiveClientProperties clientProperties; + + private final ReactiveHttpClientServiceProperties serviceProperties; + + private final ObjectProvider> clientConnectorBuilder; + + private final ObjectProvider clientConnectorSettings; + + WebClientPropertiesHttpServiceGroupConfigurer(ClassLoader classLoader, ObjectProvider sslBundles, + HttpReactiveClientProperties clientProperties, ReactiveHttpClientServiceProperties serviceProperties, + ObjectProvider> clientConnectorBuilder, + ObjectProvider 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 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 putAllHeaders(Map> 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); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/package-info.java new file mode 100644 index 0000000000..84f39a9248 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/reactive/service/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-Configuration for Spring's Reactive HTTP Service Interface Clients. + */ +package org.springframework.boot.autoconfigure.http.client.reactive.service; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/AbstractHttpClientServiceProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/AbstractHttpClientServiceProperties.java new file mode 100644 index 0000000000..9a537ee38c --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/AbstractHttpClientServiceProperties.java @@ -0,0 +1,63 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.autoconfigure.http.client.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> defaultHeader = new LinkedHashMap<>(); + + public String getBaseUrl() { + return this.baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public Map> getDefaultHeader() { + return this.defaultHeader; + } + + public void setDefaultHeader(Map> defaultHeaders) { + this.defaultHeader = defaultHeaders; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/HttpClientServiceProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/HttpClientServiceProperties.java new file mode 100644 index 0000000000..86cc29d726 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/HttpClientServiceProperties.java @@ -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 group = new LinkedHashMap<>(); + + public Map getGroup() { + return this.group; + } + + public void setGroup(Map group) { + this.group = group; + } + + /** + * Properties for a single HTTP Service client group. + */ + public static class Group extends AbstractHttpClientServiceProperties { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/HttpServiceClientAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/HttpServiceClientAutoConfiguration.java new file mode 100644 index 0000000000..431e48f5eb --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/HttpServiceClientAutoConfiguration.java @@ -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. + *

+ * 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, HttpClientProperties httpClientProperties, + HttpClientServiceProperties serviceProperties, + ObjectProvider> clientFactoryBuilder, + ObjectProvider clientHttpRequestFactorySettings) { + return new RestClientPropertiesHttpServiceGroupConfigurer(this.beanClassLoader, sslBundles, + httpClientProperties, serviceProperties, clientFactoryBuilder, clientHttpRequestFactorySettings); + } + + @Bean + RestClientCustomizerHttpServiceGroupConfigurer restClientCustomizerHttpServiceGroupConfigurer( + ObjectProvider customizers) { + return new RestClientCustomizerHttpServiceGroupConfigurer(customizers); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/RestClientCustomizerHttpServiceGroupConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/RestClientCustomizerHttpServiceGroupConfigurer.java new file mode 100644 index 0000000000..16f77e1eaf --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/RestClientCustomizerHttpServiceGroupConfigurer.java @@ -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 customizers; + + RestClientCustomizerHttpServiceGroupConfigurer(ObjectProvider customizers) { + this.customizers = customizers; + } + + @Override + public int getOrder() { + return ORDER; + } + + @Override + public void configureGroups(Groups groups) { + groups.configureClient(this::configureClient); + } + + private void configureClient(RestClient.Builder builder) { + this.customizers.orderedStream().forEach((customizer) -> customizer.customize(builder)); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/RestClientPropertiesHttpServiceGroupConfigurer.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/RestClientPropertiesHttpServiceGroupConfigurer.java new file mode 100644 index 0000000000..2480b292b0 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/RestClientPropertiesHttpServiceGroupConfigurer.java @@ -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; + + private final HttpClientProperties clientProperties; + + private final HttpClientServiceProperties serviceProperties; + + private final ObjectProvider> requestFactoryBuilder; + + private final ObjectProvider requestFactorySettings; + + RestClientPropertiesHttpServiceGroupConfigurer(ClassLoader classLoader, ObjectProvider sslBundles, + HttpClientProperties clientProperties, HttpClientServiceProperties serviceProperties, + ObjectProvider> requestFactoryBuilder, + ObjectProvider 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 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 putAllHeaders(Map> 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); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/package-info.java new file mode 100644 index 0000000000..442610dfb9 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/http/client/service/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Auto-Configuration for Spring's Blocking HTTP Service Interface Clients. + */ +package org.springframework.boot.autoconfigure.http.client.service; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index cc4e6fafdc..ae29ede31f 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -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 diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/ClientHttpRequestFactoriesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/ClientHttpRequestFactoriesTests.java new file mode 100644 index 0000000000..6e1942f2d4 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/ClientHttpRequestFactoriesTests.java @@ -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 = () -> 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 { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorsTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorsTests.java new file mode 100644 index 0000000000..a683ca9a17 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/ClientHttpConnectorsTests.java @@ -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 = () -> 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 { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/service/ReactiveHttpClientServicePropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/service/ReactiveHttpClientServicePropertiesTests.java new file mode 100644 index 0000000000..c540f2436c --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/service/ReactiveHttpClientServicePropertiesTests.java @@ -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 { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/service/ReactiveHttpServiceClientAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/service/ReactiveHttpServiceClientAutoConfigurationTests.java new file mode 100644 index 0000000000..155eefaa51 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/reactive/service/ReactiveHttpServiceClientAutoConfigurationTests.java @@ -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(); + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/service/HttpClientServicePropertiesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/service/HttpClientServicePropertiesTests.java new file mode 100644 index 0000000000..41998ea11d --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/service/HttpClientServicePropertiesTests.java @@ -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 { + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/service/HttpServiceClientAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/service/HttpServiceClientAutoConfigurationTests.java new file mode 100644 index 0000000000..a1fba7e4ee --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/http/client/service/HttpServiceClientAutoConfigurationTests.java @@ -0,0 +1,255 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.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 mocks = new HashMap<>(); + + @Bean + RestClientHttpServiceGroupConfigurer mockServerConfigurer() { + return (groups) -> groups.configureClient((BiConsumer) 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(); + + } + +}