Add LoadBalancer integration for Spring Interface Clients AutoConfiguration (#1491)

Add LoadBalancer integration for Spring Interface Clients AutoConfiguration. 
---------

Signed-off-by: Olga Maciaszek-Sharma <olga.maciaszek-sharma@broadcom.com>
This commit is contained in:
Olga Maciaszek-Sharma
2025-05-15 15:58:53 +02:00
committed by GitHub
parent 22bcf3759b
commit a2b379752a
13 changed files with 649 additions and 11 deletions

View File

@@ -549,8 +549,40 @@ NOTE: For the properties where maps where already used, where you can specify a
NOTE: Starting with `4.1.0`, we have introduced the `callGetWithRequestOnDelegates` flag in `LoadBalancerProperties`. If this flag is set to `true`, `ServiceInstanceListSupplier#get(Request request)` method will be implemented to call `delegate.get(request)` in classes assignable from `DelegatingServiceInstanceListSupplier` that don't already implement that method, with the exclusion of `CachingServiceInstanceListSupplier` and `HealthCheckServiceInstanceListSupplier`, which should be placed in the instance supplier hierarchy directly after the supplier performing instance retrieval over the network, before any request-based filtering is done. It is set to `true` by default.
[[-aot-and-native-image-support]]
[[aot-and-native-image-support]]
== AOT and Native Image Support
Since `4.0.0`, Spring Cloud LoadBalancer supports Spring AOT transformations and native images. However, to use this feature, you need to explicitly define your `LoadBalancerClient` service IDs. You can do so by using the `value` or `name` attributes of the `@LoadBalancerClient` annotation or as values of the `spring.cloud.loadbalancer.eager-load.clients` property.
== LoadBalancer Integration for Spring Interface Clients AutoConfiguration
Since `5.0.0`, Spring Cloud LoadBalancer supports https://docs.spring.io/spring-framework/reference/7.0-SNAPSHOT/integration/rest-clients.html#rest-http-interface[Spring Interface Clients] AutoConfiguration through the `LoadBalancerRestClientHttpServiceGroupConfigurer` and `LoadBalancerWebClientHttpServiceGroupConfigurer`.
For each Interface Client group, if the group `baseUrl` (defined under the
`spring.http.client.service.group.[groupName].base-url` property) is `null`, a `serviceId`-based URL for load-balancing is set up as the `baseUrl`, with `serviceId` resolved from the Interface Client `groupName`.
If the group `baseUrl` is `null` or it is already a `serviceId`-based URL, a
`DeferringLoadBalancerInterceptor` instance is picked from the application context for blocking scenarios, and a `DeferringLoadBalancerExchangeFilterFunction` instance for reactive scenarios, and is added to the group's `RestClient.Builder` or `WebClient.Builder` if available, allowing for the requests to be load-balanced.
For example, in an app with the following Interface Clients configuration:
[source,java,indent=0]
----
@SpringBootApplication
@ImportHttpServices(group = "verificationClient", types = {VerificationService.class,
PersonService.class})
public class HttpVerificationClientApplication {
public static void main(String[] args) {
SpringApplication.run(HttpVerificationClientApplication.class, args);
}
}
----
If the `spring.http.client.service.group.verificationClient.base-url` property is not set, it will be automatically set to `http://verificationClient`. The default scheme (`http`) is used initially; however, if a secure `ServiceInstance` is selected through load-balancing, it will be changed to `https`.
If the `spring.http.client.service.group.verificationClient.base-url` property is set to a URL that uses the `groupName` / `serviceId` as the host (for example, `https://verificationClient/path`), it will be left unchanged. In both of these cases, either a `DeferringLoadBalancerInterceptor` or `DeferringLoadBalancerExchangeFilterFunction` will be added to the group's client builder, enabling the requests to be load-balanced.
If the `spring.http.client.service.group.verificationClient.base-url` property is set to a URL that does not have `verificationClient` as the host (for example, `http://someOtherHost/path`), no load-balancer integration will be applied.
TIP: To use a different default scheme instead of `http` (for example, `ws`, which would be converted to `wss` if a secure `ServiceInstance` is selected), set the `spring.http.client.service.group.verificationClient.base-url` property with the desired scheme.

View File

@@ -1,7 +1,7 @@
|===
|Name | Default | Description
|spring.cloud.compatibility-verifier.compatible-boot-versions | `+++3.5.x+++` | Default accepted versions for the Spring Boot dependency. You can set {@code x} for the patch version if you don't want to specify a concrete value. Example: {@code 3.5.x}
|spring.cloud.compatibility-verifier.compatible-boot-versions | `+++4.0.x+++` | Default accepted versions for the Spring Boot dependency. You can set {@code x} for the patch version if you don't want to specify a concrete value. Example: {@code 3.5.x}
|spring.cloud.compatibility-verifier.enabled | `+++false+++` | Enables creation of Spring Cloud compatibility verification.
|spring.cloud.config.allow-override | `+++true+++` | Flag to indicate that {@link #isOverrideSystemProperties() systemPropertiesOverride} can be used. Set to false to prevent users from changing the default accidentally. Default true.
|spring.cloud.config.initialize-on-context-refresh | `+++false+++` | Flag to initialize bootstrap configuration on context refresh event. Default false.

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2024 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -30,6 +30,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.http.client.service.HttpClientServiceProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer;
@@ -110,6 +111,15 @@ public class LoadBalancerAutoConfiguration {
return new LoadBalancerRestTemplateBuilderBeanPostProcessor<>(loadBalancerInterceptorProvider, context);
}
@Bean
@ConditionalOnBean({ HttpClientServiceProperties.class, ReactiveLoadBalancer.Factory.class })
@ConditionalOnMissingBean(LoadBalancerRestClientHttpServiceGroupConfigurer.class)
LoadBalancerRestClientHttpServiceGroupConfigurer loadBalancerRestClientHttpServiceGroupConfigurer(
ObjectProvider<DeferringLoadBalancerInterceptor> loadBalancerInterceptorProvider,
HttpClientServiceProperties properties) {
return new LoadBalancerRestClientHttpServiceGroupConfigurer(loadBalancerInterceptorProvider, properties);
}
}
@AutoConfiguration

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2024 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -145,8 +145,7 @@ public class LoadBalancerProperties {
this.hintHeaderName = hintHeaderName;
}
// TODO: fix spelling in a major release
public void setxForwarded(XForwarded xForwarded) {
public void setXForwarded(XForwarded xForwarded) {
this.xForwarded = xForwarded;
}

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.cloud.client.loadbalancer;
import java.net.URI;
import org.jspecify.annotations.NonNull;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.http.client.service.HttpClientServiceProperties;
import org.springframework.util.function.SingletonSupplier;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer;
import static org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools.constructInterfaceClientsBaseUrl;
import static org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools.isServiceIdUrl;
/**
* Load-balancer-specific {@link RestClientHttpServiceGroupConfigurer} implementation. If
* the group {@code baseUrl} is {@code null}, sets up a {@code baseUrl} with LoadBalancer
* {@code serviceId} -resolved from Interface Client {@code groupName} set as
* {@code host}. If the group {@code baseUrl} is {@code null} or
* {@link LoadBalancerUriTools#isServiceIdUrl(String, String)}, a
* {@link DeferringLoadBalancerInterceptor} instance picked from application context is
* added to the group's {@link RestClient.Builder} if available, allowing for the requests
* to be load-balanced.
*
* @author Olga Maciaszek-Sharma
* @since 5.0.0
* @see RestClientHttpServiceGroupConfigurer
* @see HttpClientServiceProperties
*/
public class LoadBalancerRestClientHttpServiceGroupConfigurer implements RestClientHttpServiceGroupConfigurer {
// Make sure Boot's configurers run before
private static final int ORDER = 10;
private final SingletonSupplier<DeferringLoadBalancerInterceptor> loadBalancerInterceptorSupplier;
private final HttpClientServiceProperties clientServiceProperties;
public LoadBalancerRestClientHttpServiceGroupConfigurer(
ObjectProvider<DeferringLoadBalancerInterceptor> loadBalancerInterceptorProvider,
HttpClientServiceProperties clientServiceProperties) {
this.loadBalancerInterceptorSupplier = SingletonSupplier
.ofNullable(loadBalancerInterceptorProvider::getIfAvailable);
this.clientServiceProperties = clientServiceProperties;
}
@Override
public void configureGroups(@NonNull Groups<RestClient.Builder> groups) {
DeferringLoadBalancerInterceptor loadBalancerInterceptor = loadBalancerInterceptorSupplier.get();
if (loadBalancerInterceptor == null) {
throw new IllegalStateException(
DeferringLoadBalancerInterceptor.class.getSimpleName() + " bean not available.");
}
groups.configureClient((group, builder) -> {
String groupName = group.name();
HttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup().get(groupName);
if (groupProperties == null || groupProperties.getBaseUrl() == null) {
URI baseUrl = constructBaseUrl(groupName);
builder.baseUrl(baseUrl);
builder.requestInterceptor(loadBalancerInterceptor);
}
else if (isServiceIdUrl(groupProperties.getBaseUrl(), groupName)) {
builder.requestInterceptor(loadBalancerInterceptor);
}
});
}
@Override
public int getOrder() {
return ORDER;
}
private URI constructBaseUrl(String groupName) {
return constructInterfaceClientsBaseUrl(groupName);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2020 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -17,11 +17,15 @@
package org.springframework.cloud.client.loadbalancer;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.web.util.UriComponentsBuilder;
@@ -35,6 +39,8 @@ public final class LoadBalancerUriTools {
throw new IllegalStateException("Can't instantiate a utility class");
}
private static final Log LOG = LogFactory.getLog(LoadBalancerUriTools.class);
private static final String PERCENTAGE_SIGN = "%";
private static final String DEFAULT_SCHEME = "http";
@@ -115,4 +121,28 @@ public final class LoadBalancerUriTools {
return originalOrDefault;
}
public static URI constructInterfaceClientsBaseUrl(String groupName) {
return UriComponentsBuilder.newInstance().scheme(DEFAULT_SCHEME).host(groupName).encode().build().toUri();
}
public static boolean isServiceIdUrl(String baseUrlString, String serviceId) {
if (serviceId == null) {
return false;
}
if (baseUrlString == null) {
return false;
}
URI baseUrl;
try {
baseUrl = new URI(baseUrlString);
}
catch (URISyntaxException e) {
if (LOG.isErrorEnabled()) {
LOG.error("Incorrect baseUrl String syntax", e);
}
return false;
}
return serviceId.equals(baseUrl.getHost());
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2024 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -21,6 +21,8 @@ import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.boot.autoconfigure.condition.AnyNestedCondition;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.http.client.reactive.service.ReactiveHttpClientServiceProperties;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.client.loadbalancer.LoadBalancerClient;
import org.springframework.context.ApplicationContext;
@@ -66,6 +68,15 @@ public class LoadBalancerBeanPostProcessorAutoConfiguration {
return new DeferringLoadBalancerExchangeFilterFunction<>(exchangeFilterFunctionProvider);
}
@Bean
@ConditionalOnBean({ ReactiveHttpClientServiceProperties.class, ReactiveLoadBalancer.Factory.class })
@ConditionalOnMissingBean(LoadBalancerWebClientHttpServiceGroupConfigurer.class)
LoadBalancerWebClientHttpServiceGroupConfigurer loadBalancerWebClientHttpServiceGroupConfigurer(
ObjectProvider<DeferringLoadBalancerExchangeFilterFunction<LoadBalancedExchangeFilterFunction>> deferringExchangeFilterFunction,
ReactiveHttpClientServiceProperties properties) {
return new LoadBalancerWebClientHttpServiceGroupConfigurer(deferringExchangeFilterFunction, properties);
}
}
static final class OnAnyLoadBalancerImplementationPresentCondition extends AnyNestedCondition {

View File

@@ -0,0 +1,96 @@
/*
* 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.cloud.client.loadbalancer.reactive;
import java.net.URI;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.http.client.reactive.service.ReactiveHttpClientServiceProperties;
import org.springframework.boot.autoconfigure.http.client.service.HttpClientServiceProperties;
import org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools;
import org.springframework.util.function.SingletonSupplier;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientHttpServiceGroupConfigurer;
import static org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools.constructInterfaceClientsBaseUrl;
import static org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools.isServiceIdUrl;
/**
* Load-balancer-specific {@link WebClientHttpServiceGroupConfigurer} implementation. If
* the group {@code baseUrl} is {@code null}, sets up a {@code baseUrl} with LoadBalancer
* {@code serviceId} -resolved from Interface Client {@code groupName} set as
* {@code host}. If the group {@code baseUrl} is {@code null} or
* {@link LoadBalancerUriTools#isServiceIdUrl(String, String)}, a
* {@link DeferringLoadBalancerExchangeFilterFunction} instance picked from application
* context is added to the group's {@link WebClient.Builder} if available, allowing for
* the requests to be load-balanced.
*
* @author Olga Maciaszek-Sharma
* @since 5.0.0
* @see WebClient.Builder
* @see HttpClientServiceProperties
*/
public class LoadBalancerWebClientHttpServiceGroupConfigurer implements WebClientHttpServiceGroupConfigurer {
// Make sure Boot's configurers run before
private static final int ORDER = 10;
private final SingletonSupplier<DeferringLoadBalancerExchangeFilterFunction<LoadBalancedExchangeFilterFunction>> loadBalancerFilterFunctionSupplier;
private final ReactiveHttpClientServiceProperties clientServiceProperties;
public LoadBalancerWebClientHttpServiceGroupConfigurer(
ObjectProvider<DeferringLoadBalancerExchangeFilterFunction<LoadBalancedExchangeFilterFunction>> exchangeFilterFunctionProvider,
ReactiveHttpClientServiceProperties clientServiceProperties) {
this.loadBalancerFilterFunctionSupplier = SingletonSupplier
.ofNullable(exchangeFilterFunctionProvider::getIfAvailable);
this.clientServiceProperties = clientServiceProperties;
}
@Override
public void configureGroups(Groups<WebClient.Builder> groups) {
DeferringLoadBalancerExchangeFilterFunction<LoadBalancedExchangeFilterFunction> loadBalancerFilterFunction = loadBalancerFilterFunctionSupplier
.get();
if (loadBalancerFilterFunction == null) {
throw new IllegalStateException(
DeferringLoadBalancerExchangeFilterFunction.class.getSimpleName() + " bean not available.");
}
groups.configureClient((group, builder) -> {
String groupName = group.name();
ReactiveHttpClientServiceProperties.Group groupProperties = clientServiceProperties.getGroup()
.get(groupName);
if (groupProperties == null || groupProperties.getBaseUrl() == null) {
URI baseUrl = constructBaseUrl(groupName);
builder.baseUrl(String.valueOf(baseUrl));
builder.filter(loadBalancerFilterFunction);
}
else if (isServiceIdUrl(groupProperties.getBaseUrl(), groupName)) {
builder.filter(loadBalancerFilterFunction);
}
});
}
@Override
public int getOrder() {
return ORDER;
}
private URI constructBaseUrl(String groupName) {
return constructInterfaceClientsBaseUrl(groupName);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2024 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -25,9 +25,11 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.http.client.service.HttpClientServiceProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.cloud.client.DefaultServiceInstance;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.reactive.LoadBalancerWebClientHttpServiceGroupConfigurer;
import org.springframework.cloud.client.loadbalancer.reactive.ReactiveLoadBalancer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -154,6 +156,14 @@ public abstract class AbstractLoadBalancerAutoConfigurationTests {
});
}
@Test
void loadBalancerRestClientHttpServiceGroupConfigurerPresent() {
applicationContextRunner.withUserConfiguration(OneRestClientBuilder.class).run(context -> {
assertThat(context.getBeansOfType(LoadBalancerRestClientHttpServiceGroupConfigurer.class)).hasSize(1);
assertThat(context.getBeansOfType(LoadBalancerWebClientHttpServiceGroupConfigurer.class)).hasSize(0);
});
}
protected abstract void assertLoadBalanced(RestClient.Builder restClientBuilder);
protected abstract void assertLoadBalanced(RestTemplate restTemplate);
@@ -180,6 +190,11 @@ public abstract class AbstractLoadBalancerAutoConfigurationTests {
return RestClient.builder();
}
@Bean
HttpClientServiceProperties httpClientServiceProperties() {
return new HttpClientServiceProperties();
}
}
@Configuration(proxyBeanMethods = false)

View File

@@ -0,0 +1,149 @@
/*
* 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.cloud.client.loadbalancer;
import java.util.HashSet;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Predicate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.http.client.service.HttpClientServiceProperties;
import org.springframework.boot.autoconfigure.http.client.service.HttpClientServiceProperties.Group;
import org.springframework.web.client.RestClient;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import org.springframework.web.service.registry.HttpServiceGroup;
import org.springframework.web.service.registry.HttpServiceGroupConfigurer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link LoadBalancerRestClientHttpServiceGroupConfigurer}
*
* @author Olga Maciaszek-Sharma
*/
@SuppressWarnings({ "removal" })
class LoadBalancerRestClientHttpServiceGroupConfigurerTests {
private static final String GROUP_NAME = "testService";
private HttpClientServiceProperties clientServiceProperties;
private ObjectProvider<DeferringLoadBalancerInterceptor> interceptorProvider;
@BeforeEach
void setup() {
DeferringLoadBalancerInterceptor interceptor = mock(DeferringLoadBalancerInterceptor.class);
interceptorProvider = new SimpleObjectProvider<>(interceptor);
clientServiceProperties = new HttpClientServiceProperties();
}
@Test
void shouldAddInterceptorWhenBaseUrlIsNotSet() {
LoadBalancerRestClientHttpServiceGroupConfigurer configurer = new LoadBalancerRestClientHttpServiceGroupConfigurer(
interceptorProvider, clientServiceProperties);
TestGroups groups = new TestGroups();
configurer.configureGroups(groups);
groups.builder.requestInterceptors(interceptors -> {
assertThat(interceptors).hasSize(1);
assertThat(interceptors.get(0).getClass()).isEqualTo(DeferringLoadBalancerInterceptor.class);
});
}
@Test
void shouldAddInterceptorWhenBaseUrlIsServiceIdUrl() {
Group group = new Group();
group.setBaseUrl("https://" + GROUP_NAME + "/path");
clientServiceProperties.getGroup().put(GROUP_NAME, group);
LoadBalancerRestClientHttpServiceGroupConfigurer configurer = new LoadBalancerRestClientHttpServiceGroupConfigurer(
interceptorProvider, clientServiceProperties);
TestGroups groups = new TestGroups();
configurer.configureGroups(groups);
groups.builder.requestInterceptors(interceptors -> {
assertThat(interceptors).hasSize(1);
assertThat(interceptors.get(0).getClass()).isEqualTo(DeferringLoadBalancerInterceptor.class);
});
}
@Test
void shouldNotAddInterceptorWhenBaseUrlIsNotServiceIdUrl() {
Group group = new Group();
group.setBaseUrl("https://some-other-service/path");
clientServiceProperties.getGroup().put(GROUP_NAME, group);
LoadBalancerRestClientHttpServiceGroupConfigurer configurer = new LoadBalancerRestClientHttpServiceGroupConfigurer(
interceptorProvider, clientServiceProperties);
TestGroups groups = new TestGroups();
configurer.configureGroups(groups);
groups.builder.requestInterceptors(interceptors -> assertThat(interceptors).hasSize(0));
}
private static class TestGroups implements HttpServiceGroupConfigurer.Groups<RestClient.Builder> {
RestClient.Builder builder = RestClient.builder();
@Override
public HttpServiceGroupConfigurer.Groups<RestClient.Builder> filterByName(String... groupNames) {
throw new UnsupportedOperationException("Please, implement me.");
}
@Override
public HttpServiceGroupConfigurer.Groups<RestClient.Builder> filter(Predicate<HttpServiceGroup> predicate) {
throw new UnsupportedOperationException("Please, implement me.");
}
@Override
public void configureClient(Consumer<RestClient.Builder> clientConfigurer) {
}
@Override
public void configureClient(BiConsumer<HttpServiceGroup, RestClient.Builder> clientConfigurer) {
clientConfigurer.accept(new TestGroup(GROUP_NAME, HttpServiceGroup.ClientType.REST_CLIENT, new HashSet<>()),
builder);
}
@Override
public void configureProxyFactory(
BiConsumer<HttpServiceGroup, HttpServiceProxyFactory.Builder> proxyFactoryConfigurer) {
}
@Override
public void configure(BiConsumer<HttpServiceGroup, RestClient.Builder> clientConfigurer,
BiConsumer<HttpServiceGroup, HttpServiceProxyFactory.Builder> proxyFactoryConfigurer) {
}
}
private record TestGroup(String name, ClientType clientType,
Set<Class<?>> httpServiceTypes) implements HttpServiceGroup {
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2020 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -19,13 +19,19 @@ package org.springframework.cloud.client.loadbalancer;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.web.util.InvalidUrlException;
import org.springframework.web.util.UriComponentsBuilder;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Tests for {@link LoadBalancerUriTools}.
@@ -183,6 +189,29 @@ class LoadBalancerUriToolsTests {
assertThat(reconstructed.getPort()).isEqualTo(serviceInstance.getPort());
}
@ParameterizedTest(name = "{index} => url={0}, serviceId={1}, expected={2}")
@MethodSource("provideUrlAndServiceIdForIsServiceIdUrl")
void verifyServiceIdUrl(String url, String serviceId, boolean expected) {
assertThat(LoadBalancerUriTools.isServiceIdUrl(url, serviceId)).isEqualTo(expected);
}
@Test
void verifyServiceIdIncorrectUrl() {
assertThatExceptionOfType(InvalidUrlException.class).isThrownBy(() -> {
URI baseUrl = UriComponentsBuilder.fromUriString("https://:testService/xxx").build().toUri();
LoadBalancerUriTools.isServiceIdUrl(String.valueOf(baseUrl), null);
});
}
private static Stream<Arguments> provideUrlAndServiceIdForIsServiceIdUrl() {
return Stream.of(org.junit.jupiter.params.provider.Arguments.of("https://testService/xxx", "testService", true),
org.junit.jupiter.params.provider.Arguments.of("https://test/xxx", "testService", false),
org.junit.jupiter.params.provider.Arguments.of("https://testService/xxx", null, false),
org.junit.jupiter.params.provider.Arguments.of(null, "testService", false));
}
}
class TestServiceInstance implements ServiceInstance {

View File

@@ -0,0 +1,150 @@
/*
* 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.cloud.client.loadbalancer.reactive;
import java.util.HashSet;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Predicate;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.http.client.reactive.service.ReactiveHttpClientServiceProperties;
import org.springframework.cloud.client.loadbalancer.SimpleObjectProvider;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import org.springframework.web.service.registry.HttpServiceGroup;
import org.springframework.web.service.registry.HttpServiceGroupConfigurer;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link LoadBalancerWebClientHttpServiceGroupConfigurer}
*
* @author Olga Maciaszek-Sharma
*/
@SuppressWarnings({ "unchecked", "removal" })
class LoadBalancerWebClientHttpServiceGroupConfigurerTests {
private static final String GROUP_NAME = "testService";
private ReactiveHttpClientServiceProperties clientServiceProperties;
private ObjectProvider<DeferringLoadBalancerExchangeFilterFunction<LoadBalancedExchangeFilterFunction>> exchangeFilterFunctionProvider;
@BeforeEach
void setup() {
DeferringLoadBalancerExchangeFilterFunction<LoadBalancedExchangeFilterFunction> exchangeFilterFunction = mock(
DeferringLoadBalancerExchangeFilterFunction.class);
exchangeFilterFunctionProvider = new SimpleObjectProvider<>(exchangeFilterFunction);
clientServiceProperties = new ReactiveHttpClientServiceProperties();
}
@Test
void shouldAddInterceptorWhenBaseUrlIsNotSet() {
LoadBalancerWebClientHttpServiceGroupConfigurer configurer = new LoadBalancerWebClientHttpServiceGroupConfigurer(
exchangeFilterFunctionProvider, clientServiceProperties);
TestGroups groups = new TestGroups();
configurer.configureGroups(groups);
groups.builder.filters(filterFunctions -> {
assertThat(filterFunctions).hasSize(1);
assertThat(filterFunctions.get(0).getClass()).isEqualTo(DeferringLoadBalancerExchangeFilterFunction.class);
});
}
@Test
void shouldAddInterceptorWhenBaseUrlIsServiceIdUrl() {
ReactiveHttpClientServiceProperties.Group group = new ReactiveHttpClientServiceProperties.Group();
group.setBaseUrl("https://" + GROUP_NAME + "/path");
clientServiceProperties.getGroup().put(GROUP_NAME, group);
LoadBalancerWebClientHttpServiceGroupConfigurer configurer = new LoadBalancerWebClientHttpServiceGroupConfigurer(
exchangeFilterFunctionProvider, clientServiceProperties);
TestGroups groups = new TestGroups();
configurer.configureGroups(groups);
groups.builder.filters(filterFunctions -> {
assertThat(filterFunctions).hasSize(1);
assertThat(filterFunctions.get(0).getClass()).isEqualTo(DeferringLoadBalancerExchangeFilterFunction.class);
});
}
@Test
void shouldNotAddInterceptorWhenBaseUrlIsNotServiceIdUrl() {
ReactiveHttpClientServiceProperties.Group group = new ReactiveHttpClientServiceProperties.Group();
group.setBaseUrl("https://some-other-service/path");
clientServiceProperties.getGroup().put(GROUP_NAME, group);
LoadBalancerWebClientHttpServiceGroupConfigurer configurer = new LoadBalancerWebClientHttpServiceGroupConfigurer(
exchangeFilterFunctionProvider, clientServiceProperties);
TestGroups groups = new TestGroups();
configurer.configureGroups(groups);
groups.builder.filters(filterFunctions -> assertThat(filterFunctions).hasSize(0));
}
private static class TestGroups implements HttpServiceGroupConfigurer.Groups<WebClient.Builder> {
WebClient.Builder builder = WebClient.builder();
@Override
public HttpServiceGroupConfigurer.Groups<WebClient.Builder> filterByName(String... groupNames) {
throw new UnsupportedOperationException("Please, implement me.");
}
@Override
public HttpServiceGroupConfigurer.Groups<WebClient.Builder> filter(Predicate<HttpServiceGroup> predicate) {
throw new UnsupportedOperationException("Please, implement me.");
}
@Override
public void configureClient(Consumer<WebClient.Builder> clientConfigurer) {
}
@Override
public void configureClient(BiConsumer<HttpServiceGroup, WebClient.Builder> clientConfigurer) {
clientConfigurer.accept(new TestGroup(GROUP_NAME, HttpServiceGroup.ClientType.WEB_CLIENT, new HashSet<>()),
builder);
}
@Override
public void configureProxyFactory(
BiConsumer<HttpServiceGroup, HttpServiceProxyFactory.Builder> proxyFactoryConfigurer) {
}
@Override
public void configure(BiConsumer<HttpServiceGroup, WebClient.Builder> clientConfigurer,
BiConsumer<HttpServiceGroup, HttpServiceProxyFactory.Builder> proxyFactoryConfigurer) {
}
}
private record TestGroup(String name, ClientType clientType,
Set<Class<?>> httpServiceTypes) implements HttpServiceGroup {
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2020 the original author or authors.
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -23,19 +23,24 @@ import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.http.client.reactive.service.ReactiveHttpClientServiceProperties;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.LoadBalanced;
import org.springframework.cloud.client.loadbalancer.LoadBalancedRetryFactory;
import org.springframework.cloud.client.loadbalancer.LoadBalancerClientsProperties;
import org.springframework.cloud.client.loadbalancer.LoadBalancerProperties;
import org.springframework.cloud.client.loadbalancer.LoadBalancerRestClientHttpServiceGroupConfigurer;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.web.reactive.function.client.WebClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.BDDAssertions.then;
import static org.springframework.cloud.client.loadbalancer.reactive.LoadBalancerTestUtils.assertLoadBalanced;
import static org.springframework.cloud.client.loadbalancer.reactive.LoadBalancerTestUtils.getFilters;
@@ -150,6 +155,18 @@ public class ReactorLoadBalancerClientAutoConfigurationTests {
then(clientProperties.getHealthCheck().getInterval()).isEqualTo(Duration.ofSeconds(30));
}
@Test
void loadBalancerRestClientHttpServiceGroupConfigurerPresent() {
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(ReactorLoadBalancerClientAutoConfiguration.class,
LoadBalancerBeanPostProcessorAutoConfiguration.class))
.withUserConfiguration(OneWebClientBuilder.class)
.run(context -> {
assertThat(context.getBeansOfType(LoadBalancerWebClientHttpServiceGroupConfigurer.class)).hasSize(1);
assertThat(context.getBeansOfType(LoadBalancerRestClientHttpServiceGroupConfigurer.class)).hasSize(0);
});
}
private ConfigurableApplicationContext init(Class<?> config) {
return LoadBalancerTestUtils.init(config, ReactorLoadBalancerClientAutoConfiguration.class,
LoadBalancerBeanPostProcessorAutoConfiguration.class);
@@ -211,6 +228,11 @@ public class ReactorLoadBalancerClientAutoConfigurationTests {
return new TestService(loadBalancedWebClientBuilder());
}
@Bean
ReactiveHttpClientServiceProperties reactiveHttpClientServiceProperties() {
return new ReactiveHttpClientServiceProperties();
}
private static final class TestService {
public final WebClient webClient;