From 3df66580f397eb8bef4ea51bff95cc41ba11fbe9 Mon Sep 17 00:00:00 2001 From: Juhwan Lee Date: Sat, 10 Jan 2026 17:32:53 +0900 Subject: [PATCH] Support property placeholders in HTTP service registry Implement EmbeddedValueResolverAware to resolve ${...} placeholders in @HttpExchange URL attributes. See gh-36126 Signed-off-by: Juhwan Lee --- .../HttpServiceProxyRegistryFactoryBean.java | 22 ++++- ...pServiceProxyRegistryFactoryBeanTests.java | 93 +++++++++++++++++++ 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyRegistryFactoryBean.java b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyRegistryFactoryBean.java index fb534d63155..af46d9829af 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyRegistryFactoryBean.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyRegistryFactoryBean.java @@ -39,10 +39,12 @@ import org.springframework.beans.factory.FactoryBean; import org.springframework.beans.factory.InitializingBean; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; +import org.springframework.context.EmbeddedValueResolverAware; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.StringValueResolver; import org.springframework.web.service.invoker.HttpExchangeAdapter; import org.springframework.web.service.invoker.HttpServiceProxyFactory; @@ -61,8 +63,8 @@ import org.springframework.web.service.invoker.HttpServiceProxyFactory; * @see AbstractHttpServiceRegistrar */ public final class HttpServiceProxyRegistryFactoryBean - implements ApplicationContextAware, BeanClassLoaderAware, InitializingBean, - FactoryBean { + implements ApplicationContextAware, BeanClassLoaderAware, EmbeddedValueResolverAware, + InitializingBean, FactoryBean { private static final Map> groupAdapters = GroupAdapterInitializer.initGroupAdapters(); @@ -76,6 +78,7 @@ public final class HttpServiceProxyRegistryFactoryBean private @Nullable HttpServiceProxyRegistry proxyRegistry; + private @Nullable StringValueResolver embeddedValueResolver; HttpServiceProxyRegistryFactoryBean(GroupsMetadata groupsMetadata) { this.groupsMetadata = groupsMetadata; @@ -92,6 +95,11 @@ public final class HttpServiceProxyRegistryFactoryBean this.beanClassLoader = beanClassLoader; } + @Override + public void setEmbeddedValueResolver(StringValueResolver resolver) { + this.embeddedValueResolver = resolver; + } + @Override public Class getObjectType() { return HttpServiceProxyRegistry.class; @@ -105,7 +113,7 @@ public final class HttpServiceProxyRegistryFactoryBean // Create the groups from the metadata Set groups = this.groupsMetadata.groups(this.beanClassLoader).stream() - .map(ConfigurableGroup::new) + .map(group -> new ConfigurableGroup(group, this.embeddedValueResolver)) .collect(Collectors.toSet()); // Apply group configurers @@ -169,11 +177,14 @@ public final class HttpServiceProxyRegistryFactoryBean private @Nullable Object clientBuilder; + private final @Nullable StringValueResolver embeddedValueResolver; + private final HttpServiceProxyFactory.Builder proxyFactoryBuilder = HttpServiceProxyFactory.builder(); - ConfigurableGroup(HttpServiceGroup group) { + ConfigurableGroup(HttpServiceGroup group, @Nullable StringValueResolver embeddedValueResolver) { this.group = group; this.groupAdapter = getGroupAdapter(group.clientType()); + this.embeddedValueResolver = embeddedValueResolver; } private static HttpServiceGroupAdapter getGroupAdapter(HttpServiceGroup.ClientType clientType) { @@ -218,6 +229,9 @@ public final class HttpServiceProxyRegistryFactoryBean public Map, Object> createProxies() { Map, Object> map = new LinkedHashMap<>(this.group.httpServiceTypes().size()); HttpExchangeAdapter adapter = this.groupAdapter.createExchangeAdapter(getClientBuilder()); + if (this.embeddedValueResolver != null) { + this.proxyFactoryBuilder.embeddedValueResolver(this.embeddedValueResolver); + } HttpServiceProxyFactory factory = this.proxyFactoryBuilder.exchangeAdapter(adapter).build(); this.group.httpServiceTypes().forEach(type -> map.put(type, factory.createClient(type))); return map; diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceProxyRegistryFactoryBeanTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceProxyRegistryFactoryBeanTests.java index 0a625774eac..4e6d371934e 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceProxyRegistryFactoryBeanTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceProxyRegistryFactoryBeanTests.java @@ -16,18 +16,23 @@ package org.springframework.web.service.registry; +import java.net.URI; import java.util.List; import java.util.function.Predicate; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.http.client.ClientHttpRequestFactory; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.StringValueResolver; import org.springframework.web.client.RestClient; import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; import org.springframework.web.service.invoker.HttpServiceProxyFactory; import org.springframework.web.service.registry.echo.EchoA; import org.springframework.web.service.registry.echo.EchoB; @@ -37,6 +42,7 @@ import org.springframework.web.testfixture.http.client.MockClientHttpRequest; import org.springframework.web.testfixture.http.client.MockClientHttpResponse; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.atLeastOnce; @@ -95,6 +101,87 @@ public class HttpServiceProxyRegistryFactoryBeanTests { verify(requestFactory, atLeastOnce()).createRequest(any(), any()); } + @Test + void propertyPlaceholderInHttpExchangeUrlIsResolved() throws Exception { + GroupsMetadata groupsMetadata = new GroupsMetadata(); + groupsMetadata.getOrCreateGroup("testGroup", REST_CLIENT) + .httpServiceTypeNames() + .add(PlaceholderService.class.getName()); + + ClientHttpRequestFactory requestFactory = Mockito.mock(ClientHttpRequestFactory.class); + MockClientHttpRequest mockRequest = new MockClientHttpRequest(); + mockRequest.setResponse(new MockClientHttpResponse()); + + ArgumentCaptor uriCaptor = ArgumentCaptor.forClass(URI.class); + given(requestFactory.createRequest(uriCaptor.capture(), any())).willReturn(mockRequest); + + StringValueResolver resolver = value -> { + if (value.contains("${test.base.url}")) { + return value.replace("${test.base.url}", "https://api.example.com"); + } + return value; + }; + + RestClient.Builder clientBuilder = RestClient.builder().requestFactory(requestFactory); + + RestClientHttpServiceGroupConfigurer configurer = groups -> groups.forEachClient(group -> clientBuilder); + + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.registerBean(RestClientHttpServiceGroupConfigurer.class, () -> configurer); + context.refresh(); + + HttpServiceProxyRegistryFactoryBean factoryBean = new HttpServiceProxyRegistryFactoryBean(groupsMetadata); + + factoryBean.setApplicationContext(context); + factoryBean.setBeanClassLoader(getClass().getClassLoader()); + factoryBean.setEmbeddedValueResolver(resolver); + factoryBean.afterPropertiesSet(); + + HttpServiceProxyRegistry registry = factoryBean.getObject(); + PlaceholderService service = registry.getClient(PlaceholderService.class); + service.callEndpoint(); + + URI requestedUri = uriCaptor.getValue(); + + assertThat(requestedUri.toString()) + .startsWith("https://api.example.com") + .doesNotContain("${") + .contains("/endpoint"); + } + + @Test + void withoutResolverPlaceholderRemainsUnresolved() throws Exception { + GroupsMetadata groupsMetadata = new GroupsMetadata(); + groupsMetadata.getOrCreateGroup("testGroup", REST_CLIENT) + .httpServiceTypeNames() + .add(PlaceholderService.class.getName()); + + ClientHttpRequestFactory requestFactory = Mockito.mock(ClientHttpRequestFactory.class); + MockClientHttpRequest capturedRequest = new MockClientHttpRequest(); + capturedRequest.setResponse(new MockClientHttpResponse()); + given(requestFactory.createRequest(any(), any())).willReturn(capturedRequest); + + RestClient.Builder clientBuilder = RestClient.builder().requestFactory(requestFactory); + RestClientHttpServiceGroupConfigurer configurer = groups -> + groups.forEachClient(group -> clientBuilder); + + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.registerBean(RestClientHttpServiceGroupConfigurer.class, () -> configurer); + context.refresh(); + + HttpServiceProxyRegistryFactoryBean factoryBean = new HttpServiceProxyRegistryFactoryBean(groupsMetadata); + factoryBean.setApplicationContext(context); + factoryBean.setBeanClassLoader(getClass().getClassLoader()); + factoryBean.afterPropertiesSet(); + + HttpServiceProxyRegistry registry = factoryBean.getObject(); + PlaceholderService service = registry.getClient(PlaceholderService.class); + + assertThatThrownBy(service::callEndpoint) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("test.base.url"); + } + private HttpServiceProxyRegistry initProxyRegistry( RestClientHttpServiceGroupConfigurer groupConfigurer, GroupsMetadata groupsMetadata) { @@ -136,4 +223,10 @@ public class HttpServiceProxyRegistryFactoryBeanTests { } } + @HttpExchange(url = "${test.base.url}") + interface PlaceholderService { + + @GetExchange("/endpoint") + String callEndpoint(); + } }