Skip to content

Commit 8326022

Browse files
committed
Auto-configure Spring Interface Clients beans.
1 parent 3952d63 commit 8326022

23 files changed

+1041
-1
lines changed

buildSrc/src/main/java/org/springframework/boot/build/context/properties/DocumentConfigurationProperties.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ void documentConfigurationProperties() throws IOException {
6969
snippets.add("application-properties.server", "Server Properties", this::serverPrefixes);
7070
snippets.add("application-properties.security", "Security Properties", this::securityPrefixes);
7171
snippets.add("application-properties.rsocket", "RSocket Properties", this::rsocketPrefixes);
72+
snippets.add("application-properties.interfaceclients", "Interface Clients Properties",
73+
this::interfaceClientsPrefixes);
7274
snippets.add("application-properties.actuator", "Actuator Properties", this::actuatorPrefixes);
7375
snippets.add("application-properties.devtools", "Devtools Properties", this::devtoolsPrefixes);
7476
snippets.add("application-properties.docker-compose", "Docker Compose Properties", this::dockerComposePrefixes);
@@ -205,6 +207,10 @@ private void rsocketPrefixes(Config prefix) {
205207
prefix.accept("spring.rsocket");
206208
}
207209

210+
private void interfaceClientsPrefixes(Config prefix) {
211+
prefix.accept("spring.interfaceclients");
212+
}
213+
208214
private void actuatorPrefixes(Config prefix) {
209215
prefix.accept("management");
210216
prefix.accept("micrometer");

settings.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ include "spring-boot-project:spring-boot-actuator-autoconfigure"
6464
include "spring-boot-project:spring-boot-docker-compose"
6565
include "spring-boot-project:spring-boot-devtools"
6666
include "spring-boot-project:spring-boot-docs"
67+
include "spring-boot-project:spring-boot-interface-clients"
6768
include "spring-boot-project:spring-boot-test"
6869
include "spring-boot-project:spring-boot-testcontainers"
6970
include "spring-boot-project:spring-boot-test-autoconfigure"

spring-boot-project/spring-boot-autoconfigure/build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ configurations.all {
2323
dependencies {
2424
api(project(":spring-boot-project:spring-boot"))
2525

26+
// TODO: Have added it to be able to use CaseUtils and avoid rewriting the code;
27+
// can remove it and duplicate the required method instead
28+
implementation("org.apache.commons:commons-text")
29+
2630
dockerTestImplementation(project(":spring-boot-project:spring-boot-test"))
2731
dockerTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support-docker"))
2832
dockerTestImplementation("com.redis:testcontainers-redis")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/*
2+
* Copyright 2012-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.autoconfigure.interfaceclients;
18+
19+
import java.lang.annotation.Annotation;
20+
import java.text.Normalizer;
21+
import java.util.HashSet;
22+
import java.util.List;
23+
import java.util.Set;
24+
25+
import org.apache.commons.logging.Log;
26+
import org.apache.commons.logging.LogFactory;
27+
import org.apache.commons.text.CaseUtils;
28+
29+
import org.springframework.beans.factory.ListableBeanFactory;
30+
import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition;
31+
import org.springframework.beans.factory.config.BeanDefinition;
32+
import org.springframework.beans.factory.config.BeanDefinitionHolder;
33+
import org.springframework.beans.factory.support.AbstractBeanDefinition;
34+
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
35+
import org.springframework.beans.factory.support.BeanDefinitionReaderUtils;
36+
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
37+
import org.springframework.beans.factory.support.BeanNameGenerator;
38+
import org.springframework.boot.autoconfigure.AutoConfigurationPackages;
39+
import org.springframework.context.EnvironmentAware;
40+
import org.springframework.context.ResourceLoaderAware;
41+
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
42+
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
43+
import org.springframework.core.ResolvableType;
44+
import org.springframework.core.annotation.MergedAnnotation;
45+
import org.springframework.core.env.Environment;
46+
import org.springframework.core.io.ResourceLoader;
47+
import org.springframework.core.type.AnnotationMetadata;
48+
import org.springframework.core.type.filter.AnnotationTypeFilter;
49+
import org.springframework.util.Assert;
50+
import org.springframework.util.ObjectUtils;
51+
52+
/**
53+
* Registers bean definitions for annotated Interface Clients in order to automatically
54+
* instantiate client beans based on those interfaces.
55+
*
56+
* @author Josh Long
57+
* @author Olga Maciaszek-Sharma
58+
* @since 3.4.0
59+
*/
60+
// TODO: Handle AOT
61+
public abstract class AbstractInterfaceClientsImportRegistrar
62+
implements ImportBeanDefinitionRegistrar, EnvironmentAware, ResourceLoaderAware {
63+
64+
private static final String INTERFACE_CLIENT_SUFFIX = "InterfaceClient";
65+
66+
private static final String BEAN_NAME_ATTRIBUTE_NAME = "beanName";
67+
68+
private static final Log logger = LogFactory.getLog(AbstractInterfaceClientsImportRegistrar.class);
69+
70+
private Environment environment;
71+
72+
private ResourceLoader resourceLoader;
73+
74+
@Override
75+
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry,
76+
BeanNameGenerator importBeanNameGenerator) {
77+
Assert.isInstanceOf(ListableBeanFactory.class, registry,
78+
"Registry must be an instance of " + ListableBeanFactory.class.getSimpleName());
79+
ListableBeanFactory beanFactory = (ListableBeanFactory) registry;
80+
Set<BeanDefinition> candidateComponents = discoverCandidateComponents(beanFactory);
81+
for (BeanDefinition candidateComponent : candidateComponents) {
82+
if (candidateComponent instanceof AnnotatedBeanDefinition beanDefinition) {
83+
registerInterfaceClient(registry, beanFactory, beanDefinition);
84+
}
85+
}
86+
}
87+
88+
@Override
89+
public void setEnvironment(Environment environment) {
90+
this.environment = environment;
91+
}
92+
93+
@Override
94+
public void setResourceLoader(ResourceLoader resourceLoader) {
95+
this.resourceLoader = resourceLoader;
96+
}
97+
98+
protected abstract Class<? extends Annotation> getAnnotation();
99+
100+
protected Set<BeanDefinition> discoverCandidateComponents(ListableBeanFactory beanFactory) {
101+
Set<BeanDefinition> candidateComponents = new HashSet<>();
102+
ClassPathScanningCandidateComponentProvider scanner = getScanner();
103+
scanner.setResourceLoader(this.resourceLoader);
104+
scanner.addIncludeFilter(new AnnotationTypeFilter(getAnnotation()));
105+
List<String> basePackages = AutoConfigurationPackages.get(beanFactory);
106+
for (String basePackage : basePackages) {
107+
candidateComponents.addAll(scanner.findCandidateComponents(basePackage));
108+
}
109+
return candidateComponents;
110+
}
111+
112+
private ClassPathScanningCandidateComponentProvider getScanner() {
113+
return new ClassPathScanningCandidateComponentProvider(false, this.environment) {
114+
@Override
115+
protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
116+
boolean isCandidate = false;
117+
if (beanDefinition.getMetadata().isIndependent()) {
118+
if (!beanDefinition.getMetadata().isAnnotation()) {
119+
isCandidate = true;
120+
}
121+
}
122+
return isCandidate;
123+
}
124+
};
125+
}
126+
127+
private void registerInterfaceClient(BeanDefinitionRegistry registry, ListableBeanFactory beanFactory,
128+
AnnotatedBeanDefinition beanDefinition) {
129+
AnnotationMetadata annotatedBeanMetadata = beanDefinition.getMetadata();
130+
Assert.isTrue(annotatedBeanMetadata.isInterface(),
131+
getAnnotation().getSimpleName() + "can only be placed on an interface.");
132+
MergedAnnotation<? extends Annotation> annotation = annotatedBeanMetadata.getAnnotations().get(getAnnotation());
133+
String beanClassName = annotatedBeanMetadata.getClassName();
134+
// The value of the annotation is the qualifier to look for related beans
135+
// while the default beanName corresponds to the simple class name suffixed with
136+
// `InterfaceClient`
137+
String clientId = annotation.getString(MergedAnnotation.VALUE);
138+
String beanName = !ObjectUtils.isEmpty(annotation.getString(BEAN_NAME_ATTRIBUTE_NAME))
139+
? annotation.getString(BEAN_NAME_ATTRIBUTE_NAME) : buildBeanName(clientId);
140+
InterfaceClientsAdapter adapter = beanFactory.getBean(InterfaceClientsAdapter.class);
141+
Class<?> beanClass = toClass(beanClassName);
142+
BeanDefinition definition = BeanDefinitionBuilder
143+
.rootBeanDefinition(ResolvableType.forClass(beanClass),
144+
() -> adapter.createClient(beanFactory, clientId, beanClass))
145+
.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE)
146+
.getBeanDefinition();
147+
BeanDefinitionHolder holder = new BeanDefinitionHolder(definition, beanName, new String[] { clientId });
148+
BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);
149+
}
150+
151+
private String buildBeanName(String clientId) {
152+
String normalised = Normalizer.normalize(clientId, Normalizer.Form.NFD);
153+
String camelCased = CaseUtils.toCamelCase(normalised, false, '-', '_');
154+
return camelCased + INTERFACE_CLIENT_SUFFIX;
155+
}
156+
157+
private static Class<?> toClass(String beanClassName) {
158+
Class<?> beanClass;
159+
try {
160+
beanClass = Class.forName(beanClassName);
161+
}
162+
catch (ClassNotFoundException ex) {
163+
if (logger.isDebugEnabled()) {
164+
logger.debug("Class not found for interface client " + beanClassName + ": " + ex.getMessage());
165+
}
166+
throw new RuntimeException(ex);
167+
}
168+
return beanClass;
169+
}
170+
171+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2012-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.autoconfigure.interfaceclients;
18+
19+
import org.springframework.beans.factory.ListableBeanFactory;
20+
21+
/**
22+
* Creates an Interface Client bean for the specified {@code type} and {@code clientId}.
23+
*
24+
* @author Josh Long
25+
* @author Olga Maciaszek-Sharma
26+
* @since 3.4.0
27+
*/
28+
public interface InterfaceClientsAdapter {
29+
30+
/**
31+
* Default qualifier for user-provided beans used for creating Interface Clients.
32+
*/
33+
String INTERFACE_CLIENTS_DEFAULT_QUALIFIER = "interfaceClients";
34+
35+
<T> T createClient(ListableBeanFactory beanFactory, String clientId, Class<T> type);
36+
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright 2012-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.autoconfigure.interfaceclients;
18+
19+
import java.util.HashMap;
20+
import java.util.Map;
21+
22+
import org.apache.commons.logging.Log;
23+
import org.apache.commons.logging.LogFactory;
24+
25+
import org.springframework.beans.factory.BeanFactoryUtils;
26+
import org.springframework.beans.factory.ListableBeanFactory;
27+
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
28+
import org.springframework.beans.factory.annotation.Qualifier;
29+
30+
/**
31+
* Utility class containing methods that allow searching for beans with a specific
32+
* qualifier, falling back to the
33+
* {@link InterfaceClientsAdapter#INTERFACE_CLIENTS_DEFAULT_QUALIFIER} qualifier.
34+
*
35+
* @author Josh Long
36+
* @author Olga Maciaszek-Sharma
37+
* @since 3.4.0
38+
*/
39+
public final class QualifiedBeanProvider {
40+
41+
private QualifiedBeanProvider() {
42+
throw new UnsupportedOperationException("Do not instantiate utility class");
43+
}
44+
45+
private static final Log logger = LogFactory.getLog(QualifiedBeanProvider.class);
46+
47+
public static <T> T qualifiedBean(ListableBeanFactory beanFactory, Class<T> type, String clientId) {
48+
Map<String, T> matchingClientBeans = getQualifiedBeansOfType(beanFactory, type, clientId);
49+
if (matchingClientBeans.size() > 1) {
50+
throw new NoUniqueBeanDefinitionException(type, matchingClientBeans.keySet());
51+
}
52+
if (matchingClientBeans.isEmpty()) {
53+
if (logger.isDebugEnabled()) {
54+
logger.debug("No qualified bean of type " + type + " found for " + clientId);
55+
}
56+
Map<String, T> matchingDefaultBeans = getQualifiedBeansOfType(beanFactory, type,
57+
org.springframework.boot.autoconfigure.interfaceclients.InterfaceClientsAdapter.INTERFACE_CLIENTS_DEFAULT_QUALIFIER);
58+
if (matchingDefaultBeans.size() > 1) {
59+
throw new NoUniqueBeanDefinitionException(type, matchingDefaultBeans.keySet());
60+
}
61+
if (matchingDefaultBeans.isEmpty()) {
62+
if (logger.isDebugEnabled()) {
63+
logger.debug("No qualified bean of type " + type + " found for default id");
64+
}
65+
return null;
66+
}
67+
}
68+
return matchingClientBeans.values().iterator().next();
69+
}
70+
71+
private static <T> Map<String, T> getQualifiedBeansOfType(ListableBeanFactory beanFactory, Class<T> type,
72+
String clientId) {
73+
Map<String, T> beansOfType = BeanFactoryUtils.beansOfTypeIncludingAncestors(beanFactory, type);
74+
Map<String, T> matchingClientBeans = new HashMap<>();
75+
for (String beanName : beansOfType.keySet()) {
76+
Qualifier qualifier = (beanFactory.findAnnotationOnBean(beanName, Qualifier.class));
77+
if (qualifier != null && clientId.equals(qualifier.value())) {
78+
matchingClientBeans.put(beanName, beanFactory.getBean(beanName, type));
79+
}
80+
}
81+
return matchingClientBeans;
82+
}
83+
84+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright 2012-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.autoconfigure.interfaceclients.http;
18+
19+
import java.lang.annotation.Documented;
20+
import java.lang.annotation.ElementType;
21+
import java.lang.annotation.Retention;
22+
import java.lang.annotation.RetentionPolicy;
23+
import java.lang.annotation.Target;
24+
25+
import org.springframework.web.service.annotation.HttpExchange;
26+
27+
/**
28+
* Annotation to be placed on interfaces containing {@link HttpExchange}-annotated methods
29+
* in order for a client based on that interface to be autoconfigured.
30+
*
31+
* @author Olga Maciaszek-Sharma
32+
* @since 3.4.0
33+
*/
34+
// TODO: Consider moving over to Framework.
35+
@Target(ElementType.TYPE)
36+
@Retention(RetentionPolicy.RUNTIME)
37+
@Documented
38+
public @interface HttpClient {
39+
40+
String value();
41+
42+
String beanName() default "";
43+
44+
}

0 commit comments

Comments
 (0)