diff --git a/spring-web/src/main/java/org/springframework/web/client/support/RestClientHttpServiceGroupAdapter.java b/spring-web/src/main/java/org/springframework/web/client/support/RestClientHttpServiceGroupAdapter.java new file mode 100644 index 0000000000..57d8d776d6 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/client/support/RestClientHttpServiceGroupAdapter.java @@ -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 { + + @Override + public RestClient.Builder createClientBuilder() { + return RestClient.builder(); + } + + @Override + public Class> getConfigurerType() { + return RestClientHttpServiceGroupConfigurer.class; + } + + @Override + public HttpExchangeAdapter createExchangeAdapter(RestClient.Builder clientBuilder) { + return RestClientAdapter.create(clientBuilder.build()); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/client/support/RestClientHttpServiceGroupConfigurer.java b/spring-web/src/main/java/org/springframework/web/client/support/RestClientHttpServiceGroupConfigurer.java new file mode 100644 index 0000000000..535dd6c3a3 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/client/support/RestClientHttpServiceGroupConfigurer.java @@ -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 { + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java new file mode 100644 index 0000000000..999afabe76 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java @@ -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: + * + * + *

Subclasses determine the HTTP Service types (interfaces with + * {@link HttpExchange @HttpExchange} methods) to register by implementing + * {@link #registerHttpServices}. + * + *

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. + * + *

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. + * + *

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 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}. + *

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()); + 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 targetMap = (Map) 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> 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; + } + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/AnnotationHttpServiceRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/AnnotationHttpServiceRegistrar.java new file mode 100644 index 0000000000..dfd7f38466 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/AnnotationHttpServiceRegistrar.java @@ -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")); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroup.java b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroup.java new file mode 100644 index 0000000000..a4ed906615 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroup.java @@ -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> httpServiceTypes(); + + /** + * The client type to use for the group. + *

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; + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroupAdapter.java b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroupAdapter.java new file mode 100644 index 0000000000..d696070613 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroupAdapter.java @@ -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 the type of client builder, i.e. {@code RestClient} or {@code WebClient} builder. + */ +public interface HttpServiceGroupAdapter { + + /** + * Create a client builder instance. + */ + CB createClientBuilder(); + + /** + * Return the type of configurer that is compatible with this group. + */ + Class> 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); + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroupConfigurer.java b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroupConfigurer.java new file mode 100644 index 0000000000..f2a9320ae4 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroupConfigurer.java @@ -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 the type of client builder, i.e. {@code RestClient} or {@code WebClient} builder. + */ +@FunctionalInterface +public interface HttpServiceGroupConfigurer { + + /** + * Configure the underlying infrastructure for all group. + */ + void configureGroups(Groups groups); + + + /** + * Contract to help iterate and configure the set of groups. + * @param the type of client builder, i.e. {@code RestClient} or {@code WebClient} builder. + */ + interface Groups { + + /** + * Select groups to configure by name. + */ + Groups filterByName(String... groupNames); + + /** + * Select groups to configure through a {@link Predicate}. + */ + Groups filter(Predicate predicate); + + /** + * Configure the client for the selected groups. + * This is called once for each selected group. + */ + void configureClient(Consumer clientConfigurer); + + /** + * Variant of {@link #configureClient(Consumer)} with access to the + * group being configured. + */ + void configureClient(BiConsumer clientConfigurer); + + /** + * Configure the {@link HttpServiceProxyFactory} for the selected groups. + * This is called once for each selected group. + */ + void configureProxyFactory(BiConsumer proxyFactoryConfigurer); + + /** + * Configure the client and {@link HttpServiceProxyFactory} for the selected groups. + * This is called once for each selected group. + */ + void configure(BiConsumer clientConfigurer, + BiConsumer proxyFactoryConfigurer); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroups.java b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroups.java new file mode 100644 index 0000000000..e1f2cce9f9 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceGroups.java @@ -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. + *

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; + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyRegistry.java b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyRegistry.java new file mode 100644 index 0000000000..eda52bf88f --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyRegistry.java @@ -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

the type of HTTP Interface client proxy + * @throws IllegalArgumentException if more than one client proxy of the + * given type exists across groups + */ +

@Nullable P getClient(Class

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

the type of HTTP Interface client proxy + */ +

@Nullable P getClient(String groupName, Class

httpServiceType); + +} 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 new file mode 100644 index 0000000000..4c9dc096c6 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyRegistryFactoryBean.java @@ -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. + * + *

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 { + + private final Set groupSet; + + private final Map> groupAdapters; + + private @Nullable ApplicationContext applicationContext; + + private @Nullable HttpServiceProxyRegistry proxyRegistry; + + + HttpServiceProxyRegistryFactoryBean(Map 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> 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, 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> initGroupAdapters() { + Map> 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> 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 proxyFactoryConfigurer = (group, builder) -> {}; + + ProxyHttpServiceGroup(HttpServiceGroup group) { + this.declaredGroup = group; + } + + @Override + public String name() { + return this.declaredGroup.name(); + } + + @Override + public Set> httpServiceTypes() { + return this.declaredGroup.httpServiceTypes(); + } + + @Override + public ClientType clientType() { + return this.declaredGroup.clientType(); + } + + public void initialize(HttpServiceGroupAdapter adapter) { + this.clientBuilder = adapter.createClientBuilder(); + this.groupAdapter = adapter; + } + + @SuppressWarnings("unchecked") + public void apply( + BiConsumer clientConfigurer, + BiConsumer proxyFactoryConfigurer) { + + clientConfigurer.accept(this, (CB) this.clientBuilder); + this.proxyFactoryConfigurer = this.proxyFactoryConfigurer.andThen(proxyFactoryConfigurer); + } + + public Map, Object> createProxies() { + Map, 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 HttpExchangeAdapter initExchangeAdapter() { + Assert.state(this.clientBuilder != null, "Client builder not set"); + Assert.state(this.groupAdapter != null, "Group adapter not set"); + return ((HttpServiceGroupAdapter) 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 implements HttpServiceGroupConfigurer.Groups { + + private final HttpServiceGroup.ClientType clientType; + + private @Nullable Predicate filter; + + DefaultGroups(HttpServiceGroup.ClientType clientType) { + this.clientType = clientType; + } + + @Override + public HttpServiceGroupConfigurer.Groups filterByName(String... groupNames) { + return filter(group -> Arrays.stream(groupNames).anyMatch(id -> id.equals(group.name()))); + } + + @Override + public HttpServiceGroupConfigurer.Groups filter(Predicate predicate) { + this.filter = (this.filter != null ? this.filter.or(predicate) : predicate); + return this; + } + + @Override + public void configureClient(Consumer clientConfigurer) { + configureClient((group, builder) -> clientConfigurer.accept(builder)); + } + + @Override + public void configureClient(BiConsumer clientConfigurer) { + configure(clientConfigurer, (group, builder) -> {}); + } + + @Override + public void configureProxyFactory( + BiConsumer proxyFactoryConfigurer) { + + configure((group, builder) -> {}, proxyFactoryConfigurer); + } + + @Override + public void configure( + BiConsumer clientConfigurer, + BiConsumer 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, Object>> groupProxyMap; + + private final MultiValueMap, Object> directLookupMap; + + DefaultHttpServiceProxyRegistry(Map, Object>> groupProxyMap) { + this.groupProxyMap = groupProxyMap; + this.directLookupMap = new LinkedMultiValueMap<>(); + groupProxyMap.values().forEach(map -> map.forEach(this.directLookupMap::add)); + } + + @SuppressWarnings("unchecked") + @Override + public

@Nullable P getClient(Class

type) { + List 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

@Nullable P getClient(String groupName, Class

httpServiceType) { + return (P) this.groupProxyMap.getOrDefault(groupName, Collections.emptyMap()).get(httpServiceType); + } + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java new file mode 100644 index 0000000000..65f96f609e --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java @@ -0,0 +1,98 @@ +/* + * Copyright 2012-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.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. + * + *

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. + * + *

The HTTP Services for each group can be listed via {@link #types()}, or + * detected via {@link #basePackageClasses()} or {@link #basePackages()}. + * + *

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. + *

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. + *

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; + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/package-info.java b/spring-web/src/main/java/org/springframework/web/service/registry/package-info.java new file mode 100644 index 0000000000..477a9cbeb6 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/package-info.java @@ -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; diff --git a/spring-web/src/test/java/org/springframework/web/client/support/RestClientProxyRegistryIntegrationTests.java b/spring-web/src/test/java/org/springframework/web/client/support/RestClientProxyRegistryIntegrationTests.java new file mode 100644 index 0000000000..45d2b088e1 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/client/support/RestClientProxyRegistryIntegrationTests.java @@ -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); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/client/support/echo/EchoA.java b/spring-web/src/test/java/org/springframework/web/client/support/echo/EchoA.java new file mode 100644 index 0000000000..8a11d1b27a --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/client/support/echo/EchoA.java @@ -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); + +} diff --git a/spring-web/src/test/java/org/springframework/web/client/support/echo/EchoB.java b/spring-web/src/test/java/org/springframework/web/client/support/echo/EchoB.java new file mode 100644 index 0000000000..882548458b --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/client/support/echo/EchoB.java @@ -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); + +} diff --git a/spring-web/src/test/java/org/springframework/web/client/support/greeting/GreetingA.java b/spring-web/src/test/java/org/springframework/web/client/support/greeting/GreetingA.java new file mode 100644 index 0000000000..7b4385e127 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/client/support/greeting/GreetingA.java @@ -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); + +} diff --git a/spring-web/src/test/java/org/springframework/web/client/support/greeting/GreetingB.java b/spring-web/src/test/java/org/springframework/web/client/support/greeting/GreetingB.java new file mode 100644 index 0000000000..c1dfcb53a9 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/client/support/greeting/GreetingB.java @@ -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); + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientHttpServiceGroupAdapter.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientHttpServiceGroupAdapter.java new file mode 100644 index 0000000000..f8dae4850c --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientHttpServiceGroupAdapter.java @@ -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 { + + @Override + public WebClient.Builder createClientBuilder() { + return WebClient.builder(); + } + + @Override + public Class> getConfigurerType() { + return WebClientHttpServiceGroupConfigurer.class; + } + + @Override + public HttpExchangeAdapter createExchangeAdapter(WebClient.Builder clientBuilder) { + return WebClientAdapter.create(clientBuilder.build()); + } + +} diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientHttpServiceGroupConfigurer.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientHttpServiceGroupConfigurer.java new file mode 100644 index 0000000000..e4d2790418 --- /dev/null +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/client/support/WebClientHttpServiceGroupConfigurer.java @@ -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 { + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientProxyRegistryIntegrationTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientProxyRegistryIntegrationTests.java new file mode 100644 index 0000000000..29d7f1f0ed --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/WebClientProxyRegistryIntegrationTests.java @@ -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); + } + } + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/echo/EchoA.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/echo/EchoA.java new file mode 100644 index 0000000000..1c2c71aa33 --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/echo/EchoA.java @@ -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); + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/echo/EchoB.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/echo/EchoB.java new file mode 100644 index 0000000000..8ef7a0b155 --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/echo/EchoB.java @@ -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); + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/greeting/GreetingA.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/greeting/GreetingA.java new file mode 100644 index 0000000000..e64436ed2f --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/greeting/GreetingA.java @@ -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); + +} diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/greeting/GreetingB.java b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/greeting/GreetingB.java new file mode 100644 index 0000000000..48f3e7c749 --- /dev/null +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/function/client/support/greeting/GreetingB.java @@ -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); + +} diff --git a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyKotlinTests.kt b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyKotlinTests.kt index 85de7f5a24..7aee77808f 100644 --- a/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyKotlinTests.kt +++ b/spring-webflux/src/test/kotlin/org/springframework/web/reactive/function/client/support/WebClientHttpServiceProxyKotlinTests.kt @@ -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