Add HTTP Service registry support

See gh-33992
This commit is contained in:
rstoyanchev
2025-03-25 11:09:06 +00:00
parent a63c5ad305
commit 92b0eb7f8b
25 changed files with 1924 additions and 1 deletions

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2002-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.web.client.support;
import org.springframework.web.client.RestClient;
import org.springframework.web.service.invoker.HttpExchangeAdapter;
import org.springframework.web.service.registry.HttpServiceGroupAdapter;
import org.springframework.web.service.registry.HttpServiceGroupConfigurer;
/**
* Adapter for groups backed by {@link RestClient}.
*
* @author Rossen Stoyanchev
* @since 7.0
*/
@SuppressWarnings("unused")
public class RestClientHttpServiceGroupAdapter implements HttpServiceGroupAdapter<RestClient.Builder> {
@Override
public RestClient.Builder createClientBuilder() {
return RestClient.builder();
}
@Override
public Class<? extends HttpServiceGroupConfigurer<RestClient.Builder>> getConfigurerType() {
return RestClientHttpServiceGroupConfigurer.class;
}
@Override
public HttpExchangeAdapter createExchangeAdapter(RestClient.Builder clientBuilder) {
return RestClientAdapter.create(clientBuilder.build());
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2002-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.web.client.support;
import org.springframework.web.client.RestClient;
import org.springframework.web.service.registry.HttpServiceGroupConfigurer;
/**
* Extension of {@link HttpServiceGroupConfigurer} to configure groups
* with a {@link RestClient}.
*
* @author Rossen Stoyanchev
* @since 7.0
*/
public interface RestClientHttpServiceGroupConfigurer extends HttpServiceGroupConfigurer<RestClient.Builder> {
}

View File

@@ -0,0 +1,393 @@
/*
* Copyright 2002-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.web.service.registry;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import org.jspecify.annotations.Nullable;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.ConstructorArgumentValues;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.BeanNameGenerator;
import org.springframework.beans.factory.support.GenericBeanDefinition;
import org.springframework.context.EnvironmentAware;
import org.springframework.context.ResourceLoaderAware;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
import org.springframework.core.env.Environment;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.core.type.MethodMetadata;
import org.springframework.core.type.classreading.MetadataReader;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.web.service.annotation.HttpExchange;
/**
* Abstract registrar class that imports:
* <ul>
* <li>Bean definitions for HTTP Service interface client proxies organized by
* {@link HttpServiceGroup}.
* <li>Bean definition for an {@link HttpServiceProxyRegistryFactoryBean} that
* initializes the infrastructure for each group, {@code RestClient} or
* {@code WebClient} and a proxy factory, necessary to create the proxies.
* </ul>
*
* <p>Subclasses determine the HTTP Service types (interfaces with
* {@link HttpExchange @HttpExchange} methods) to register by implementing
* {@link #registerHttpServices}.
*
* <p>There is built-in support for declaring HTTP Services through
* {@link ImportHttpServices} annotations. It is also possible to perform
* registrations directly, sourced in another way, by extending this class.
*
* <p>It is possible to import multiple instances of this registrar type.
* Subsequent imports update the existing registry {@code FactoryBean}
* definition, and likewise merge HTTP Service group definitions.
*
* <p>An application can autowire HTTP Service proxy beans, or autowire the
* {@link HttpServiceProxyRegistry} from which to obtain proxies.
*
* @author Rossen Stoyanchev
* @since 7.0
* @see ImportHttpServices
* @see HttpServiceProxyRegistryFactoryBean
*/
public abstract class AbstractHttpServiceRegistrar implements
ImportBeanDefinitionRegistrar, EnvironmentAware, ResourceLoaderAware, BeanFactoryAware {
private HttpServiceGroup.ClientType defaultClientType = HttpServiceGroup.ClientType.UNSPECIFIED;
private @Nullable Environment environment;
private @Nullable ResourceLoader resourceLoader;
private @Nullable BeanFactory beanFactory;
private final Map<String, HttpServiceGroup> groupMap = new LinkedHashMap<>();
private @Nullable ClassPathScanningCandidateComponentProvider scanner;
/**
* Set the client type to use when the client type for an HTTP Service group
* remains {@link HttpServiceGroup.ClientType#UNSPECIFIED}.
* <p>By default, when this property is not set, then {@code REST_CLIENT}
* is used for any HTTP Service group whose client type remains unspecified.
*/
public void setDefaultClientType(HttpServiceGroup.ClientType defaultClientType) {
this.defaultClientType = defaultClientType;
}
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
@Override
public void setResourceLoader(ResourceLoader resourceLoader) {
this.resourceLoader = resourceLoader;
}
@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
this.beanFactory = beanFactory;
}
@Override
public final void registerBeanDefinitions(
AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry beanRegistry,
BeanNameGenerator beanNameGenerator) {
registerHttpServices(new DefaultGroupRegistry(), importingClassMetadata);
String proxyRegistryBeanName = HttpServiceProxyRegistry.class.getName();
GenericBeanDefinition proxyRegistryBeanDef;
if (!beanRegistry.containsBeanDefinition(proxyRegistryBeanName)) {
proxyRegistryBeanDef = new GenericBeanDefinition();
proxyRegistryBeanDef.setBeanClass(HttpServiceProxyRegistryFactoryBean.class);
ConstructorArgumentValues args = proxyRegistryBeanDef.getConstructorArgumentValues();
args.addIndexedArgumentValue(0, new LinkedHashMap<String, HttpServiceGroup>());
beanRegistry.registerBeanDefinition(proxyRegistryBeanName, proxyRegistryBeanDef);
}
else {
proxyRegistryBeanDef = (GenericBeanDefinition) beanRegistry.getBeanDefinition(proxyRegistryBeanName);
}
mergeHttpServices(proxyRegistryBeanDef);
this.groupMap.forEach((groupName, group) -> group.httpServiceTypes().forEach(type -> {
GenericBeanDefinition proxyBeanDef = new GenericBeanDefinition();
proxyBeanDef.setBeanClass(type);
proxyBeanDef.setInstanceSupplier(() -> getProxyInstance(proxyRegistryBeanName, groupName, type));
String beanName = (groupName + "." + beanNameGenerator.generateBeanName(proxyBeanDef, beanRegistry));
if (!beanRegistry.containsBeanDefinition(beanName)) {
beanRegistry.registerBeanDefinition(beanName, proxyBeanDef);
}
}));
}
@Override
public final void registerBeanDefinitions(AnnotationMetadata metadata, BeanDefinitionRegistry registry) {
}
/**
* This method is called before any bean definition registrations are made.
* Subclasses must implement it to register the HTTP Services for which bean
* definitions for which proxies need to be created.
* @param registry to perform HTTP Service registrations with
* @param importingClassMetadata annotation metadata of the importing class
*/
protected abstract void registerHttpServices(
GroupRegistry registry, AnnotationMetadata importingClassMetadata);
private ClassPathScanningCandidateComponentProvider getScanner() {
if (this.scanner == null) {
Assert.state(environment != null, "Environment has not been set");
Assert.state(resourceLoader != null, "ResourceLoader has not been set");
this.scanner = new HttpExchangeClassPathScanningCandidateComponentProvider();
this.scanner.setEnvironment(this.environment);
this.scanner.setResourceLoader(this.resourceLoader);
}
return this.scanner;
}
@SuppressWarnings("unchecked")
private void mergeHttpServices(GenericBeanDefinition proxyRegistryBeanDef) {
ConstructorArgumentValues args = proxyRegistryBeanDef.getConstructorArgumentValues();
ConstructorArgumentValues.ValueHolder valueHolder = args.getArgumentValue(0, Map.class);
Assert.state(valueHolder != null, "Expected Map constructor argument at index 0");
Map<String, HttpServiceGroup> targetMap = (Map<String, HttpServiceGroup>) valueHolder.getValue();
Assert.state(targetMap != null, "No constructor argument value");
this.groupMap.forEach((name, group) -> {
HttpServiceGroup previousGroup = targetMap.putIfAbsent(name, group);
if (previousGroup != null) {
if (!compatibleClientTypes(group.clientType(), previousGroup.clientType())) {
throw new IllegalArgumentException("ClientType conflict for group '" + name + "'");
}
previousGroup.httpServiceTypes().addAll(group.httpServiceTypes());
}
});
}
private static boolean compatibleClientTypes(
HttpServiceGroup.ClientType clientTypeA, HttpServiceGroup.ClientType clientTypeB) {
return (clientTypeA == clientTypeB ||
clientTypeA == HttpServiceGroup.ClientType.UNSPECIFIED ||
clientTypeB == HttpServiceGroup.ClientType.UNSPECIFIED);
}
private Object getProxyInstance(String registryBeanName, String groupName, Class<?> type) {
Assert.state(this.beanFactory != null, "BeanFactory has not been set");
HttpServiceProxyRegistry registry = this.beanFactory.getBean(registryBeanName, HttpServiceProxyRegistry.class);
Object proxy = registry.getClient(groupName, type);
Assert.notNull(proxy, "No proxy for HTTP Service [" + type.getName() + "]");
return proxy;
}
/**
* Registry API to allow subclasses to register HTTP Services.
*/
protected interface GroupRegistry {
/**
* Perform HTTP Service registrations for the given group.
*/
GroupSpec forGroup(String name);
/**
* Variant of {@link #forGroup(String)} with a client type.
*/
GroupSpec forGroup(String name, HttpServiceGroup.ClientType clientType);
/**
* Perform HTTP Service registrations for the
* {@link HttpServiceGroup#DEFAULT_GROUP_NAME} group.
*/
default GroupSpec forDefaultGroup() {
return forGroup(HttpServiceGroup.DEFAULT_GROUP_NAME);
}
/**
* Spec to list or scan for HTTP Service types.
*/
interface GroupSpec {
/**
* List HTTP Service types to create proxies for.
*/
GroupSpec register(Class<?>... serviceTypes);
/**
* Detect HTTP Service types in the given packages, looking for
* interfaces with a type and/or method {@link HttpExchange} annotation.
*/
GroupSpec detectInBasePackages(Class<?>... packageClasses);
/**
* Variant of {@link #detectInBasePackages(Class[])} with a String package name.
*/
GroupSpec detectInBasePackages(String... packageNames);
}
}
/**
* Default implementation of {@link GroupRegistry}.
*/
private class DefaultGroupRegistry implements GroupRegistry {
@Override
public GroupSpec forGroup(String name) {
return forGroup(name, HttpServiceGroup.ClientType.UNSPECIFIED);
}
@Override
public GroupSpec forGroup(String name, HttpServiceGroup.ClientType clientType) {
return new DefaultGroupSpec(name, clientType);
}
private class DefaultGroupSpec implements GroupSpec {
private final String groupName;
private final HttpServiceGroup.ClientType clientType;
public DefaultGroupSpec(String groupName, HttpServiceGroup.ClientType clientType) {
this.groupName = groupName;
this.clientType = initClientType(clientType);
}
private HttpServiceGroup.ClientType initClientType(HttpServiceGroup.ClientType clientType) {
if (clientType != HttpServiceGroup.ClientType.UNSPECIFIED) {
return clientType;
}
else if (defaultClientType != HttpServiceGroup.ClientType.UNSPECIFIED) {
return defaultClientType;
}
else {
return HttpServiceGroup.ClientType.REST_CLIENT;
}
}
@Override
public GroupSpec register(Class<?>... serviceTypes) {
addHttpServiceTypes(groupName, clientType, serviceTypes);
return this;
}
@Override
public GroupSpec detectInBasePackages(Class<?>... packageClasses) {
for (Class<?> packageClass : packageClasses) {
detect(groupName, clientType, packageClass.getPackageName());
}
return this;
}
@Override
public GroupSpec detectInBasePackages(String... packageNames) {
for (String packageName : packageNames) {
detect(groupName, clientType, packageName);
}
return this;
}
private void detect(String groupName, HttpServiceGroup.ClientType clientType, String packageName) {
for (BeanDefinition definition : getScanner().findCandidateComponents(packageName)) {
String className = definition.getBeanClassName();
if (className != null) {
try {
Class<?> clazz = ClassUtils.forName(className, getClass().getClassLoader());
addHttpServiceTypes(groupName, clientType, clazz);
}
catch (ClassNotFoundException ex) {
throw new IllegalStateException("Failed to load '" + className + "'", ex);
}
}
}
}
private void addHttpServiceTypes(
String groupName, HttpServiceGroup.ClientType clientType, Class<?>... serviceTypes) {
groupMap.computeIfAbsent(groupName, name -> new RegisteredGroup(name, new LinkedHashSet<>(), clientType))
.httpServiceTypes().addAll(Arrays.asList(serviceTypes));
}
}
private record RegisteredGroup(
String name, Set<Class<?>> httpServiceTypes, ClientType clientType) implements HttpServiceGroup {
}
}
/**
* Extension of ClassPathScanningCandidateComponentProvider to look for HTTP Services.
*/
private static class HttpExchangeClassPathScanningCandidateComponentProvider
extends ClassPathScanningCandidateComponentProvider {
public HttpExchangeClassPathScanningCandidateComponentProvider() {
addIncludeFilter(new HttpExchangeFilter());
}
@Override
protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
AnnotationMetadata metadata = beanDefinition.getMetadata();
return (metadata.isIndependent() && !metadata.isAnnotation());
}
/**
* Find interfaces with type and/or method {@code @HttpExchange}.
*/
private static class HttpExchangeFilter extends AnnotationTypeFilter {
public HttpExchangeFilter() {
super(HttpExchange.class, true, true);
}
@Override
protected boolean matchSelf(MetadataReader metadataReader) {
if (metadataReader.getClassMetadata().isInterface()) {
for (MethodMetadata metadata : metadataReader.getAnnotationMetadata().getDeclaredMethods()) {
if (metadata.getAnnotations().isPresent(HttpExchange.class)) {
return true;
}
}
}
return false;
}
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2002-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.web.service.registry;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.type.AnnotationMetadata;
/**
* Built-in implementation {@link AbstractHttpServiceRegistrar} that uses
* {@link ImportHttpServices} annotations on the importing configuration class
* to determine the HTTP services and groups to register.
*
* @author Rossen Stoyanchev
* @since 7.0
*/
final class AnnotationHttpServiceRegistrar extends AbstractHttpServiceRegistrar {
@Override
protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata importMetadata) {
MergedAnnotation<?> groupsAnnot = importMetadata.getAnnotations().get(HttpServiceGroups.class);
if (groupsAnnot.isPresent()) {
HttpServiceGroup.ClientType clientType = groupsAnnot.getEnum("clientType", HttpServiceGroup.ClientType.class);
for (MergedAnnotation<?> annot : groupsAnnot.getAnnotationArray("value", ImportHttpServices.class)) {
processImportAnnotation(annot, registry, clientType);
}
}
importMetadata.getAnnotations().stream(ImportHttpServices.class).forEach(annot ->
processImportAnnotation(annot, registry, HttpServiceGroup.ClientType.UNSPECIFIED));
}
private void processImportAnnotation(
MergedAnnotation<?> annotation, GroupRegistry groupRegistry,
HttpServiceGroup.ClientType containerClientType) {
String groupName = annotation.getString("group");
HttpServiceGroup.ClientType clientType = annotation.getEnum("clientType", HttpServiceGroup.ClientType.class);
clientType = (clientType != HttpServiceGroup.ClientType.UNSPECIFIED ? clientType : containerClientType);
groupRegistry.forGroup(groupName, clientType)
.register(annotation.getClassArray("types"))
.detectInBasePackages(annotation.getStringArray("basePackages"))
.detectInBasePackages(annotation.getClassArray("basePackageClasses"));
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2002-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.web.service.registry;
import java.util.Set;
/**
* A group of HTTP Service interfaces that share the same
* {@link org.springframework.web.service.invoker.HttpServiceProxyFactory} and
* HTTP client setup.
*
* @author Rossen Stoyanchev
* @since 7.0
*/
public interface HttpServiceGroup {
/**
* The name of the group to add HTTP Services to when a group isn't specified.
*/
String DEFAULT_GROUP_NAME = "default";
/**
* The name of the HTTP Service group.
*/
String name();
/**
* The HTTP Services in the group.
*/
Set<Class<?>> httpServiceTypes();
/**
* The client type to use for the group.
* <p>By default, {@link ClientType#REST_CLIENT} remains unspecified.
*/
ClientType clientType();
/**
* Enum to specify the client type to use for an HTTP Service group.
*/
enum ClientType {
/**
* A group backed by {@link org.springframework.web.client.RestClient}.
*/
REST_CLIENT,
/**
* A group backed by {@link org.springframework.web.reactive.function.client.WebClient}.
*/
WEB_CLIENT,
/**
* Not specified, falling back on a default.
* @see ImportHttpServices#clientType()
* @see HttpServiceGroups#clientType()
* @see AbstractHttpServiceRegistrar#setDefaultClientType
*/
UNSPECIFIED;
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2002-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.web.service.registry;
import org.springframework.web.service.invoker.HttpExchangeAdapter;
/**
* Adapter that helps to configure a group independent of its client builder type.
*
* @author Rossen Stoyanchev
* @since 7.0
* @param <CB> the type of client builder, i.e. {@code RestClient} or {@code WebClient} builder.
*/
public interface HttpServiceGroupAdapter<CB> {
/**
* Create a client builder instance.
*/
CB createClientBuilder();
/**
* Return the type of configurer that is compatible with this group.
*/
Class<? extends HttpServiceGroupConfigurer<CB>> getConfigurerType();
/**
* Use the client builder to create an {@link HttpExchangeAdapter} to use to
* initialize the {@link org.springframework.web.service.invoker.HttpServiceProxyFactory}
* for the group.
*/
HttpExchangeAdapter createExchangeAdapter(CB clientBuilder);
}

View File

@@ -0,0 +1,83 @@
/*
* Copyright 2002-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.web.service.registry;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Predicate;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/**
* Callback to configure the set of declared {@link HttpServiceGroup}s.
*
* @author Rossen Stoyanchev
* @since 7.0
* @param <CB> the type of client builder, i.e. {@code RestClient} or {@code WebClient} builder.
*/
@FunctionalInterface
public interface HttpServiceGroupConfigurer<CB> {
/**
* Configure the underlying infrastructure for all group.
*/
void configureGroups(Groups<CB> groups);
/**
* Contract to help iterate and configure the set of groups.
* @param <CB> the type of client builder, i.e. {@code RestClient} or {@code WebClient} builder.
*/
interface Groups<CB> {
/**
* Select groups to configure by name.
*/
Groups<CB> filterByName(String... groupNames);
/**
* Select groups to configure through a {@link Predicate}.
*/
Groups<CB> filter(Predicate<HttpServiceGroup> predicate);
/**
* Configure the client for the selected groups.
* This is called once for each selected group.
*/
void configureClient(Consumer<CB> clientConfigurer);
/**
* Variant of {@link #configureClient(Consumer)} with access to the
* group being configured.
*/
void configureClient(BiConsumer<HttpServiceGroup, CB> clientConfigurer);
/**
* Configure the {@link HttpServiceProxyFactory} for the selected groups.
* This is called once for each selected group.
*/
void configureProxyFactory(BiConsumer<HttpServiceGroup, HttpServiceProxyFactory.Builder> proxyFactoryConfigurer);
/**
* Configure the client and {@link HttpServiceProxyFactory} for the selected groups.
* This is called once for each selected group.
*/
void configure(BiConsumer<HttpServiceGroup, CB> clientConfigurer,
BiConsumer<HttpServiceGroup, HttpServiceProxyFactory.Builder> proxyFactoryConfigurer);
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2002-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.web.service.registry;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Import;
import org.springframework.core.annotation.AliasFor;
/**
* Container annotation for the {@link ImportHttpServices} repeatable annotation.
* Typically not necessary to use as {@code @ImportHttpServices} annotations can
* be declared one after another without a wrapper, but the container annotation
* may be used to set the {@link #clientType()} and that would be inherited by
* all nested annotations.
*
* @author Rossen Stoyanchev
* @since 7.0
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(AnnotationHttpServiceRegistrar.class)
public @interface HttpServiceGroups {
/**
* Alias for {@link #groups()}.
*/
@AliasFor("groups")
ImportHttpServices[] value() default {};
/**
* Nested annotations that declare HTTP Services by group.
*/
@AliasFor("value")
ImportHttpServices[] groups() default {};
/**
* Specify the type of client to use for nested {@link ImportHttpServices}
* annotations that don't specify it.
* <p>By default, this is {@link HttpServiceGroup.ClientType#UNSPECIFIED}
* in which case {@code RestClient} is used, but this default can be reset
* via {@link AbstractHttpServiceRegistrar#setDefaultClientType}.
*/
HttpServiceGroup.ClientType clientType() default HttpServiceGroup.ClientType.UNSPECIFIED;
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2002-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.web.service.registry;
import org.jspecify.annotations.Nullable;
/**
* A registry that contains HTTP Service client proxies.
*
* @author Rossen Stoyanchev
* @since 7.0
* @see ImportHttpServices
* @see HttpServiceProxyRegistryFactoryBean
*/
public interface HttpServiceProxyRegistry {
/**
* Return an HTTP service client proxy from any group as long as there is
* only one client proxy of the given type across all groups.
* @param httpServiceType the type of client proxy
* @return the proxy, or {@code null} if not found
* @param <P> the type of HTTP Interface client proxy
* @throws IllegalArgumentException if more than one client proxy of the
* given type exists across groups
*/
<P> @Nullable P getClient(Class<P> httpServiceType);
/**
* Return an HTTP service client proxy from the given group.
* @param groupName the name of the group
* @param httpServiceType the type of client proxy
* @return the proxy, or {@code null} if not found
* @param <P> the type of HTTP Interface client proxy
*/
<P> @Nullable P getClient(String groupName, Class<P> httpServiceType);
}

View File

@@ -0,0 +1,306 @@
/*
* Copyright 2002-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.web.service.registry;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.BeansException;
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.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.service.invoker.HttpExchangeAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
/**
* {@link FactoryBean} for {@link HttpServiceProxyRegistry} responsible for
* initializing {@link HttpServiceGroup}s, and creating the HTTP Service client
* proxies for each group.
*
* <p>This class is imported as a bean definition through an
* {@link AbstractHttpServiceRegistrar}, and given .
*
* @author Rossen Stoyanchev
* @since 7.0
* @see AbstractHttpServiceRegistrar
*/
public final class HttpServiceProxyRegistryFactoryBean
implements ApplicationContextAware, InitializingBean, FactoryBean<HttpServiceProxyRegistry> {
private final Set<ProxyHttpServiceGroup> groupSet;
private final Map<HttpServiceGroup.ClientType, HttpServiceGroupAdapter<?>> groupAdapters;
private @Nullable ApplicationContext applicationContext;
private @Nullable HttpServiceProxyRegistry proxyRegistry;
HttpServiceProxyRegistryFactoryBean(Map<String, HttpServiceGroup> groupMap) {
this.groupSet = groupMap.values().stream().map(ProxyHttpServiceGroup::new).collect(Collectors.toSet());
this.groupAdapters = GroupAdapterInitializer.initGroupAdapters();
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Override
public Class<?> getObjectType() {
return HttpServiceProxyRegistry.class;
}
@Override
public void afterPropertiesSet() {
Assert.notNull(this.applicationContext, "ApplicationContext not initialized");
// Set client builders
groupAdapters.forEach((clientType, groupAdapter) -> {
this.groupSet.stream()
.filter(group -> group.clientType().equals(clientType))
.forEach(group -> group.initialize(groupAdapter));
});
// Apply group configurers
groupAdapters.forEach((clientType, groupAdapter) -> {
Collection<? extends HttpServiceGroupConfigurer<?>> configurers =
this.applicationContext.getBeansOfType(groupAdapter.getConfigurerType()).values();
configurers.stream()
.filter(configurer -> groupAdapter.getConfigurerType().isInstance(configurer))
.forEach(configurer -> configurer.configureGroups(new DefaultGroups<>(clientType)));
});
// Create proxies
Map<String, Map<Class<?>, Object>> groupProxyMap = this.groupSet.stream()
.collect(Collectors.toMap(ProxyHttpServiceGroup::name, ProxyHttpServiceGroup::createProxies));
this.proxyRegistry = new DefaultHttpServiceProxyRegistry(groupProxyMap);
}
@Override
public HttpServiceProxyRegistry getObject() {
Assert.state(this.proxyRegistry != null, "HttpServiceProxyRegistry not initialized");
return this.proxyRegistry;
}
private static class GroupAdapterInitializer {
static Map<HttpServiceGroup.ClientType, HttpServiceGroupAdapter<?>> initGroupAdapters() {
Map<HttpServiceGroup.ClientType, HttpServiceGroupAdapter<?>> map = new LinkedHashMap<>(2);
addGroupAdapter(map, HttpServiceGroup.ClientType.REST_CLIENT,
"org.springframework.web.client.support.RestClientHttpServiceGroupAdapter");
addGroupAdapter(map, HttpServiceGroup.ClientType.WEB_CLIENT,
"org.springframework.web.reactive.function.client.support.WebClientHttpServiceGroupAdapter");
return map;
}
private static void addGroupAdapter(
Map<HttpServiceGroup.ClientType, HttpServiceGroupAdapter<?>> groupAdapters,
HttpServiceGroup.ClientType clientType, String className) {
try {
Class<?> clazz = ClassUtils.forName(className, HttpServiceGroupAdapter.class.getClassLoader());
groupAdapters.put(clientType, (HttpServiceGroupAdapter<?>) BeanUtils.instantiateClass(clazz));
}
catch (ClassNotFoundException ex) {
// ignore
}
}
}
/**
* {@link HttpServiceGroup} that creates client proxies.
*/
private static final class ProxyHttpServiceGroup implements HttpServiceGroup {
private final HttpServiceGroup declaredGroup;
private @Nullable Object clientBuilder;
private @Nullable HttpServiceGroupAdapter<?> groupAdapter;
private BiConsumer<HttpServiceGroup, HttpServiceProxyFactory.Builder> proxyFactoryConfigurer = (group, builder) -> {};
ProxyHttpServiceGroup(HttpServiceGroup group) {
this.declaredGroup = group;
}
@Override
public String name() {
return this.declaredGroup.name();
}
@Override
public Set<Class<?>> httpServiceTypes() {
return this.declaredGroup.httpServiceTypes();
}
@Override
public ClientType clientType() {
return this.declaredGroup.clientType();
}
public <CB> void initialize(HttpServiceGroupAdapter<?> adapter) {
this.clientBuilder = adapter.createClientBuilder();
this.groupAdapter = adapter;
}
@SuppressWarnings("unchecked")
public <CB> void apply(
BiConsumer<HttpServiceGroup, CB> clientConfigurer,
BiConsumer<HttpServiceGroup, HttpServiceProxyFactory.Builder> proxyFactoryConfigurer) {
clientConfigurer.accept(this, (CB) this.clientBuilder);
this.proxyFactoryConfigurer = this.proxyFactoryConfigurer.andThen(proxyFactoryConfigurer);
}
public Map<Class<?>, Object> createProxies() {
Map<Class<?>, Object> proxyMap = new LinkedHashMap<>(httpServiceTypes().size());
HttpExchangeAdapter exchangeAdapter = initExchangeAdapter();
HttpServiceProxyFactory.Builder proxyFactoryBuilder = HttpServiceProxyFactory.builderFor(exchangeAdapter);
this.proxyFactoryConfigurer.accept(this, proxyFactoryBuilder);
HttpServiceProxyFactory proxyFactory = proxyFactoryBuilder.build();
httpServiceTypes().forEach(type -> proxyMap.put(type, proxyFactory.createClient(type)));
return proxyMap;
}
@SuppressWarnings("unchecked")
private <CB> HttpExchangeAdapter initExchangeAdapter() {
Assert.state(this.clientBuilder != null, "Client builder not set");
Assert.state(this.groupAdapter != null, "Group adapter not set");
return ((HttpServiceGroupAdapter<CB>) this.groupAdapter).createExchangeAdapter((CB) this.clientBuilder);
}
@Override
public String toString() {
return getClass().getSimpleName() + "[id=" + name() + "]";
}
}
/**
* Default implementation of Groups that helps to configure the set of declared groups.
*/
private final class DefaultGroups<CB> implements HttpServiceGroupConfigurer.Groups<CB> {
private final HttpServiceGroup.ClientType clientType;
private @Nullable Predicate<HttpServiceGroup> filter;
DefaultGroups(HttpServiceGroup.ClientType clientType) {
this.clientType = clientType;
}
@Override
public HttpServiceGroupConfigurer.Groups<CB> filterByName(String... groupNames) {
return filter(group -> Arrays.stream(groupNames).anyMatch(id -> id.equals(group.name())));
}
@Override
public HttpServiceGroupConfigurer.Groups<CB> filter(Predicate<HttpServiceGroup> predicate) {
this.filter = (this.filter != null ? this.filter.or(predicate) : predicate);
return this;
}
@Override
public void configureClient(Consumer<CB> clientConfigurer) {
configureClient((group, builder) -> clientConfigurer.accept(builder));
}
@Override
public void configureClient(BiConsumer<HttpServiceGroup, CB> clientConfigurer) {
configure(clientConfigurer, (group, builder) -> {});
}
@Override
public void configureProxyFactory(
BiConsumer<HttpServiceGroup, HttpServiceProxyFactory.Builder> proxyFactoryConfigurer) {
configure((group, builder) -> {}, proxyFactoryConfigurer);
}
@Override
public void configure(
BiConsumer<HttpServiceGroup, CB> clientConfigurer,
BiConsumer<HttpServiceGroup, HttpServiceProxyFactory.Builder> proxyFactoryConfigurer) {
groupSet.stream()
.filter(group -> group.clientType().equals(this.clientType))
.filter(groups -> this.filter == null || this.filter.test(groups))
.forEach(group -> group.apply(clientConfigurer, proxyFactoryConfigurer));
}
}
/**
* Default {@link HttpServiceProxyRegistry} with a map of proxies.
*/
private static final class DefaultHttpServiceProxyRegistry implements HttpServiceProxyRegistry {
private final Map<String, Map<Class<?>, Object>> groupProxyMap;
private final MultiValueMap<Class<?>, Object> directLookupMap;
DefaultHttpServiceProxyRegistry(Map<String, Map<Class<?>, Object>> groupProxyMap) {
this.groupProxyMap = groupProxyMap;
this.directLookupMap = new LinkedMultiValueMap<>();
groupProxyMap.values().forEach(map -> map.forEach(this.directLookupMap::add));
}
@SuppressWarnings("unchecked")
@Override
public <P> @Nullable P getClient(Class<P> type) {
List<Object> proxies = this.directLookupMap.getOrDefault(type, Collections.emptyList());
Assert.state(proxies.size() <= 1, "No unique client of type " + type.getName());
return (!proxies.isEmpty() ? (P) proxies.get(0) : null);
}
@SuppressWarnings("unchecked")
@Override
public <P> @Nullable P getClient(String groupName, Class<P> httpServiceType) {
return (P) this.groupProxyMap.getOrDefault(groupName, Collections.emptyMap()).get(httpServiceType);
}
}
}

View File

@@ -0,0 +1,98 @@
/*
* Copyright 2012-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.web.service.registry;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.context.annotation.Import;
import org.springframework.core.annotation.AliasFor;
import org.springframework.web.service.annotation.HttpExchange;
/**
* Annotation to identify HTTP Service types (interfaces with
* {@link HttpExchange @HttpExchange} methods) for which to create client proxies,
* and have those proxies registered as beans.
*
* <p>This is a repeatable annotation that is expected on
* {@link org.springframework.context.annotation.Configuration @Configuration}
* classes. Each annotation is associated with an {@link HttpServiceGroup}
* identified by name through the {@link #group()} attribute.
*
* <p>The HTTP Services for each group can be listed via {@link #types()}, or
* detected via {@link #basePackageClasses()} or {@link #basePackages()}.
*
* <p>An application can autowire HTTP Service proxy beans, or autowire the
* {@link HttpServiceProxyRegistry} from which to obtain proxies.
*
* @author Rossen Stoyanchev
* @since 7.0
* @see HttpServiceGroups
* @see AbstractHttpServiceRegistrar
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Repeatable(HttpServiceGroups.class)
@Import(AnnotationHttpServiceRegistrar.class)
@Documented
public @interface ImportHttpServices {
/**
* An alias for {@link #types()}.
*/
@AliasFor("types")
Class<?>[] value() default {};
/**
* A list of HTTP Service types to include in the group.
*/
@AliasFor("value")
Class<?>[] types() default {};
/**
* The name of the HTTP Service group.
* <p>If not specified, declared HTTP Services are grouped under the
* {@link HttpServiceGroup#DEFAULT_GROUP_NAME}.
*/
String group() default HttpServiceGroup.DEFAULT_GROUP_NAME;
/**
* Detect HTTP Services in the packages of the specified classes by looking
* for interfaces with type or method level
* {@link org.springframework.web.service.annotation.HttpExchange @HttpExchange}.
*/
Class<?>[] basePackageClasses() default {};
/**
* Variant of {@link #basePackageClasses()} with a list of packages
* specified by package name.
*/
String[] basePackages() default {};
/**
* Specify the type of client to use for the group.
* <p>By default, this is {@link HttpServiceGroup.ClientType#UNSPECIFIED}
* in which case {@code RestClient} is used, but this default can be reset
* via {@link AbstractHttpServiceRegistrar#setDefaultClientType}.
*/
HttpServiceGroup.ClientType clientType() default HttpServiceGroup.ClientType.UNSPECIFIED;
}

View File

@@ -0,0 +1,8 @@
/**
* Support for creating a registry of HTTP Service client proxies, and declaring
* the proxies as beans.
*/
@NullMarked
package org.springframework.web.service.registry;
import org.jspecify.annotations.NullMarked;

View File

@@ -0,0 +1,169 @@
/*
* Copyright 2002-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.web.client.support;
import java.io.IOException;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.web.client.support.echo.EchoA;
import org.springframework.web.client.support.echo.EchoB;
import org.springframework.web.client.support.greeting.GreetingA;
import org.springframework.web.client.support.greeting.GreetingB;
import org.springframework.web.service.registry.AbstractHttpServiceRegistrar;
import org.springframework.web.service.registry.HttpServiceProxyRegistry;
import org.springframework.web.service.registry.ImportHttpServices;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link HttpServiceProxyRegistry} with a
* {@link org.springframework.web.client.RestClient}.
*
* @author Rossen Stoyanchev
*/
public class RestClientProxyRegistryIntegrationTests {
private final MockWebServer server = new MockWebServer();
@BeforeEach
void setUp() throws Exception {
this.server.start(9090);
}
@AfterEach
void shutdown() throws IOException {
this.server.shutdown();
}
@ParameterizedTest
@ValueSource(classes = {
ListingConfig.class, DetectConfig.class, ManualListingConfig.class, ManualDetectionConfig.class
})
void basic(Class<?> configClass) throws InterruptedException {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(configClass);
EchoA echoA = context.getBean(EchoA.class);
EchoB echoB = context.getBean(EchoB.class);
GreetingA greetingA = context.getBean(GreetingA.class);
GreetingB greetingB = context.getBean(GreetingB.class);
HttpServiceProxyRegistry registry = context.getBean(HttpServiceProxyRegistry.class);
assertThat(registry.getClient(EchoA.class)).isSameAs(echoA);
assertThat(registry.getClient(EchoB.class)).isSameAs(echoB);
assertThat(registry.getClient(GreetingA.class)).isSameAs(greetingA);
assertThat(registry.getClient(GreetingB.class)).isSameAs(greetingB);
for (int i = 0; i < 4; i++) {
this.server.enqueue(new MockResponse().setBody("body"));
}
echoA.handle("a");
echoB.handle("b");
RecordedRequest request = this.server.takeRequest();
assertThat(request.getMethod()).isEqualTo("GET");
assertThat(request.getPath()).isEqualTo("/echoA?input=a");
request = this.server.takeRequest();
assertThat(request.getMethod()).isEqualTo("GET");
assertThat(request.getPath()).isEqualTo("/echoB?input=b");
greetingA.handle("a");
greetingB.handle("b");
request = this.server.takeRequest();
assertThat(request.getMethod()).isEqualTo("GET");
assertThat(request.getPath()).isEqualTo("/greetingA?input=a");
request = this.server.takeRequest();
assertThat(request.getMethod()).isEqualTo("GET");
assertThat(request.getPath()).isEqualTo("/greetingB?input=b");
}
private static class ClientConfig {
@Bean
public RestClientHttpServiceGroupConfigurer groupConfigurer() {
return groups -> groups.filterByName("echo", "greeting")
.configureClient((group, builder) -> builder.baseUrl("http://localhost:9090"));
}
}
@Configuration(proxyBeanMethods = false)
@ImportHttpServices(group = "echo", types = {EchoA.class, EchoB.class})
@ImportHttpServices(group = "greeting", types = {GreetingA.class, GreetingB.class})
private static class ListingConfig extends ClientConfig {
}
@Configuration(proxyBeanMethods = false)
@ImportHttpServices(group = "echo", basePackageClasses = EchoA.class)
@ImportHttpServices(group = "greeting", basePackageClasses = GreetingA.class)
private static class DetectConfig extends ClientConfig {
}
@Configuration(proxyBeanMethods = false)
@Import(ManualListingRegistrar.class)
private static class ManualListingConfig extends ClientConfig {
}
private static class ManualListingRegistrar extends AbstractHttpServiceRegistrar {
@Override
protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata metadata) {
registry.forGroup("echo").register(EchoA.class, EchoB.class);
registry.forGroup("greeting").register(GreetingA.class, GreetingB.class);
}
}
@Configuration(proxyBeanMethods = false)
@Import(ManualDetectionRegistrar.class)
private static class ManualDetectionConfig extends ClientConfig {
}
private static class ManualDetectionRegistrar extends AbstractHttpServiceRegistrar {
@Override
protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata metadata) {
registry.forGroup("echo").detectInBasePackages(EchoA.class);
registry.forGroup("greeting").detectInBasePackages(GreetingA.class);
}
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2002-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.web.client.support.echo;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
public interface EchoA {
@GetExchange("/echoA")
String handle(@RequestParam String input);
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2002-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.web.client.support.echo;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
public interface EchoB {
@GetExchange("/echoB")
String handle(@RequestParam String input);
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2002-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.web.client.support.greeting;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
public interface GreetingA {
@GetExchange("/greetingA")
String handle(@RequestParam String input);
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2002-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.web.client.support.greeting;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
public interface GreetingB {
@GetExchange("/greetingB")
String handle(@RequestParam String input);
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2002-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.web.reactive.function.client.support;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.service.invoker.HttpExchangeAdapter;
import org.springframework.web.service.registry.HttpServiceGroupAdapter;
import org.springframework.web.service.registry.HttpServiceGroupConfigurer;
/**
* Adapter for groups backed by {@link WebClient}.
*
* @author Rossen Stoyanchev
* @since 7.0
*/
@SuppressWarnings("unused")
public class WebClientHttpServiceGroupAdapter implements HttpServiceGroupAdapter<WebClient.Builder> {
@Override
public WebClient.Builder createClientBuilder() {
return WebClient.builder();
}
@Override
public Class<? extends HttpServiceGroupConfigurer<WebClient.Builder>> getConfigurerType() {
return WebClientHttpServiceGroupConfigurer.class;
}
@Override
public HttpExchangeAdapter createExchangeAdapter(WebClient.Builder clientBuilder) {
return WebClientAdapter.create(clientBuilder.build());
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2002-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.web.reactive.function.client.support;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.service.registry.HttpServiceGroupConfigurer;
/**
* Extension of {@link HttpServiceGroupConfigurer} to configure groups
* with a {@link WebClient}.
*
* @author Rossen Stoyanchev
* @since 7.0
*/
public interface WebClientHttpServiceGroupConfigurer extends HttpServiceGroupConfigurer<WebClient.Builder> {
}

View File

@@ -0,0 +1,181 @@
/*
* Copyright 2002-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.web.reactive.function.client.support;
import java.io.IOException;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.web.reactive.function.client.support.echo.EchoA;
import org.springframework.web.reactive.function.client.support.echo.EchoB;
import org.springframework.web.reactive.function.client.support.greeting.GreetingA;
import org.springframework.web.reactive.function.client.support.greeting.GreetingB;
import org.springframework.web.service.registry.AbstractHttpServiceRegistrar;
import org.springframework.web.service.registry.HttpServiceGroup.ClientType;
import org.springframework.web.service.registry.HttpServiceGroups;
import org.springframework.web.service.registry.HttpServiceProxyRegistry;
import org.springframework.web.service.registry.ImportHttpServices;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link HttpServiceProxyRegistry} with a
* {@link org.springframework.web.reactive.function.client.WebClient}.
*
* @author Rossen Stoyanchev
*/
public class WebClientProxyRegistryIntegrationTests {
private final MockWebServer server = new MockWebServer();
@BeforeEach
void setUp() throws Exception {
this.server.start(9090);
}
@AfterEach
void shutdown() throws IOException {
this.server.shutdown();
}
@ParameterizedTest
@ValueSource(classes = {
ListingConfig.class, DetectConfig.class, ManualListingConfig.class, ManualDetectionConfig.class
})
void basic(Class<?> configClass) throws InterruptedException {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(configClass);
EchoA echoA = context.getBean(EchoA.class);
EchoB echoB = context.getBean(EchoB.class);
GreetingA greetingA = context.getBean(GreetingA.class);
GreetingB greetingB = context.getBean(GreetingB.class);
HttpServiceProxyRegistry registry = context.getBean(HttpServiceProxyRegistry.class);
assertThat(registry.getClient(EchoA.class)).isSameAs(echoA);
assertThat(registry.getClient(EchoB.class)).isSameAs(echoB);
assertThat(registry.getClient(GreetingA.class)).isSameAs(greetingA);
assertThat(registry.getClient(GreetingB.class)).isSameAs(greetingB);
for (int i = 0; i < 4; i++) {
this.server.enqueue(new MockResponse().setBody("body"));
}
echoA.handle("a");
echoB.handle("b");
RecordedRequest request = this.server.takeRequest();
assertThat(request.getMethod()).isEqualTo("GET");
assertThat(request.getPath()).isEqualTo("/echoA?input=a");
request = this.server.takeRequest();
assertThat(request.getMethod()).isEqualTo("GET");
assertThat(request.getPath()).isEqualTo("/echoB?input=b");
greetingA.handle("a");
greetingB.handle("b");
request = this.server.takeRequest();
assertThat(request.getMethod()).isEqualTo("GET");
assertThat(request.getPath()).isEqualTo("/greetingA?input=a");
request = this.server.takeRequest();
assertThat(request.getMethod()).isEqualTo("GET");
assertThat(request.getPath()).isEqualTo("/greetingB?input=b");
}
private static class BaseEchoConfig {
@Bean
public WebClientHttpServiceGroupConfigurer groupConfigurer() {
return groups -> groups.filterByName("echo", "greeting")
.configureClient((group, builder) -> builder.baseUrl("http://localhost:9090"));
}
}
@Configuration(proxyBeanMethods = false)
@HttpServiceGroups(clientType = ClientType.WEB_CLIENT, groups = {
@ImportHttpServices(group = "echo", types = {EchoA.class, EchoB.class}),
@ImportHttpServices(group = "greeting", types = {GreetingA.class, GreetingB.class})
})
private static class ListingConfig extends BaseEchoConfig {
}
@Configuration(proxyBeanMethods = false)
@HttpServiceGroups(clientType = ClientType.WEB_CLIENT, groups = {
@ImportHttpServices(group = "echo", basePackageClasses = EchoA.class),
@ImportHttpServices(group = "greeting", basePackageClasses = GreetingA.class)
})
private static class DetectConfig extends BaseEchoConfig {
}
@Configuration(proxyBeanMethods = false)
@Import(ManualListingRegistrar.class)
private static class ManualListingConfig extends BaseEchoConfig {
}
private static class ManualListingRegistrar extends AbstractHttpServiceRegistrar {
public ManualListingRegistrar() {
setDefaultClientType(ClientType.WEB_CLIENT);
}
@Override
protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata metadata) {
setDefaultClientType(ClientType.WEB_CLIENT);
registry.forGroup("echo").register(EchoA.class, EchoB.class);
registry.forGroup("greeting").register(GreetingA.class, GreetingB.class);
}
}
@Configuration(proxyBeanMethods = false)
@Import(ManualDetectionRegistrar.class)
private static class ManualDetectionConfig extends BaseEchoConfig {
}
private static class ManualDetectionRegistrar extends AbstractHttpServiceRegistrar {
@Override
protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata metadata) {
setDefaultClientType(ClientType.WEB_CLIENT);
registry.forGroup("echo").detectInBasePackages(EchoA.class);
registry.forGroup("greeting").detectInBasePackages(GreetingA.class);
}
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2002-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.web.reactive.function.client.support.echo;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
public interface EchoA {
@GetExchange("/echoA")
String handle(@RequestParam String input);
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2002-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.web.reactive.function.client.support.echo;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
public interface EchoB {
@GetExchange("/echoB")
String handle(@RequestParam String input);
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2002-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.web.reactive.function.client.support.greeting;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
public interface GreetingA {
@GetExchange("/greetingA")
String handle(@RequestParam String input);
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2002-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.web.reactive.function.client.support.greeting;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
public interface GreetingB {
@GetExchange("/greetingB")
String handle(@RequestParam String input);
}

View File

@@ -49,7 +49,7 @@ import java.util.function.Consumer
* @author Sebastien Deleuze
* @author Olga Maciaszek-Sharma
*/
class KotlinWebClientHttpServiceProxyTests {
class KotlinWebClientHttpServiceGroupAdapterServiceProxyTests {
private lateinit var server: MockWebServer