Skip to content

Commit ad87582

Browse files
Draft interface clients autoconfiguration
1 parent 25954d2 commit ad87582

30 files changed

+1306
-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
@@ -65,6 +65,7 @@ include "spring-boot-project:spring-boot-actuator-autoconfigure"
6565
include "spring-boot-project:spring-boot-docker-compose"
6666
include "spring-boot-project:spring-boot-devtools"
6767
include "spring-boot-project:spring-boot-docs"
68+
include "spring-boot-project:spring-boot-interface-clients"
6869
include "spring-boot-project:spring-boot-test"
6970
include "spring-boot-project:spring-boot-testcontainers"
7071
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("org.assertj:assertj-core")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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.BeansException;
20+
import org.springframework.beans.factory.FactoryBean;
21+
import org.springframework.context.ApplicationContext;
22+
import org.springframework.context.ApplicationContextAware;
23+
import org.springframework.context.ConfigurableApplicationContext;
24+
import org.springframework.util.Assert;
25+
26+
/**
27+
* @author Olga Maciaszek-Sharma
28+
*/
29+
public abstract class AbstractInterfaceClientsFactoryBean implements FactoryBean<Object>, ApplicationContextAware {
30+
31+
protected Class<?> type;
32+
33+
protected String beanName;
34+
35+
protected String clientId;
36+
37+
protected ConfigurableApplicationContext applicationContext;
38+
39+
@Override
40+
public Class<?> getObjectType() {
41+
return this.type;
42+
}
43+
44+
@Override
45+
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
46+
Assert.isInstanceOf(ConfigurableApplicationContext.class, applicationContext,
47+
"ApplicationContext must be an instance of " + ConfigurableApplicationContext.class.getSimpleName());
48+
this.applicationContext = (ConfigurableApplicationContext) applicationContext;
49+
50+
}
51+
52+
public Class<?> getType() {
53+
return this.type;
54+
}
55+
56+
public void setType(Class<?> type) {
57+
this.type = type;
58+
}
59+
60+
public String getBeanName() {
61+
return this.beanName;
62+
}
63+
64+
public void setBeanName(String beanName) {
65+
this.beanName = beanName;
66+
}
67+
68+
public String getClientId() {
69+
return this.clientId;
70+
}
71+
72+
public void setClientId(String clientId) {
73+
this.clientId = clientId;
74+
}
75+
76+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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.text.CaseUtils;
26+
27+
import org.springframework.beans.factory.ListableBeanFactory;
28+
import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition;
29+
import org.springframework.beans.factory.config.BeanDefinition;
30+
import org.springframework.beans.factory.config.BeanDefinitionHolder;
31+
import org.springframework.beans.factory.support.AbstractBeanDefinition;
32+
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
33+
import org.springframework.beans.factory.support.BeanDefinitionReaderUtils;
34+
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
35+
import org.springframework.beans.factory.support.BeanNameGenerator;
36+
import org.springframework.boot.autoconfigure.AutoConfigurationPackages;
37+
import org.springframework.context.EnvironmentAware;
38+
import org.springframework.context.ResourceLoaderAware;
39+
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
40+
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
41+
import org.springframework.core.annotation.MergedAnnotation;
42+
import org.springframework.core.env.Environment;
43+
import org.springframework.core.io.ResourceLoader;
44+
import org.springframework.core.type.AnnotationMetadata;
45+
import org.springframework.core.type.filter.AnnotationTypeFilter;
46+
import org.springframework.util.Assert;
47+
import org.springframework.util.ObjectUtils;
48+
49+
/**
50+
* @author Josh Long
51+
* @author Olga Maciaszek-Sharma
52+
*/
53+
// TODO: Handle AOT
54+
public abstract class AbstractInterfaceClientsImportRegistrar
55+
implements ImportBeanDefinitionRegistrar, EnvironmentAware, ResourceLoaderAware {
56+
57+
private static final String INTERFACE_CLIENT_SUFFIX = "InterfaceClient";
58+
59+
private static final String BEAN_NAME_ATTRIBUTE_NAME = "beanName";
60+
61+
private static final String CLIENT_ID_ATTRIBUTE_NAME = "clientId";
62+
63+
private static final String BEAN_CLASS_ATTRIBUTE_NAME = "type";
64+
65+
private Environment environment;
66+
67+
private ResourceLoader resourceLoader;
68+
69+
@Override
70+
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry,
71+
BeanNameGenerator importBeanNameGenerator) {
72+
Assert.isInstanceOf(ListableBeanFactory.class, registry,
73+
"Registry must be an instance of " + ListableBeanFactory.class.getSimpleName());
74+
ListableBeanFactory beanFactory = (ListableBeanFactory) registry;
75+
Set<BeanDefinition> candidateComponents = discoverCandidateComponents(beanFactory);
76+
for (BeanDefinition candidateComponent : candidateComponents) {
77+
if (candidateComponent instanceof AnnotatedBeanDefinition beanDefinition) {
78+
registerInterfaceClient(registry, beanDefinition);
79+
}
80+
}
81+
}
82+
83+
private void registerInterfaceClient(BeanDefinitionRegistry registry, AnnotatedBeanDefinition beanDefinition) {
84+
AnnotationMetadata annotatedBeanMetadata = beanDefinition.getMetadata();
85+
Assert.isTrue(annotatedBeanMetadata.isInterface(),
86+
getAnnotation().getSimpleName() + "can only be placed on an interface.");
87+
MergedAnnotation<? extends Annotation> annotation = annotatedBeanMetadata.getAnnotations().get(getAnnotation());
88+
String beanClassName = annotatedBeanMetadata.getClassName();
89+
// The value of the annotation is the qualifier to look for related beans
90+
// while the default beanName corresponds to the simple class name suffixed with
91+
// `InterfaceClient`
92+
String clientId = annotation.getString(MergedAnnotation.VALUE);
93+
String beanName = !ObjectUtils.isEmpty(annotation.getString(BEAN_NAME_ATTRIBUTE_NAME))
94+
? annotation.getString(BEAN_NAME_ATTRIBUTE_NAME) : buildBeanName(clientId);
95+
BeanDefinitionBuilder definitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(getFactoryBeanClass());
96+
definitionBuilder.addPropertyValue(BEAN_NAME_ATTRIBUTE_NAME, beanName);
97+
definitionBuilder.addPropertyValue(CLIENT_ID_ATTRIBUTE_NAME, clientId);
98+
definitionBuilder.addPropertyValue(BEAN_CLASS_ATTRIBUTE_NAME, beanClassName);
99+
definitionBuilder.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE);
100+
AbstractBeanDefinition definition = definitionBuilder.getBeanDefinition();
101+
BeanDefinitionHolder holder = new BeanDefinitionHolder(definition, beanName, new String[] { clientId });
102+
BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);
103+
}
104+
105+
protected Set<BeanDefinition> discoverCandidateComponents(ListableBeanFactory beanFactory) {
106+
Set<BeanDefinition> candidateComponents = new HashSet<>();
107+
ClassPathScanningCandidateComponentProvider scanner = getScanner();
108+
scanner.setResourceLoader(this.resourceLoader);
109+
scanner.addIncludeFilter(new AnnotationTypeFilter(getAnnotation()));
110+
List<String> basePackages = AutoConfigurationPackages.get(beanFactory);
111+
for (String basePackage : basePackages) {
112+
candidateComponents.addAll(scanner.findCandidateComponents(basePackage));
113+
}
114+
return candidateComponents;
115+
}
116+
117+
private ClassPathScanningCandidateComponentProvider getScanner() {
118+
return new ClassPathScanningCandidateComponentProvider(false, this.environment) {
119+
@Override
120+
protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
121+
boolean isCandidate = false;
122+
if (beanDefinition.getMetadata().isIndependent()) {
123+
if (!beanDefinition.getMetadata().isAnnotation()) {
124+
isCandidate = true;
125+
}
126+
}
127+
return isCandidate;
128+
}
129+
};
130+
}
131+
132+
@Override
133+
public void setEnvironment(Environment environment) {
134+
this.environment = environment;
135+
}
136+
137+
@Override
138+
public void setResourceLoader(ResourceLoader resourceLoader) {
139+
this.resourceLoader = resourceLoader;
140+
}
141+
142+
protected abstract Class<? extends Annotation> getAnnotation();
143+
144+
protected abstract Class<?> getFactoryBeanClass();
145+
146+
private String buildBeanName(String clientId) {
147+
String normalised = Normalizer.normalize(clientId, Normalizer.Form.NFD);
148+
String camelCased = CaseUtils.toCamelCase(normalised, false, '-', '_');
149+
return camelCased + INTERFACE_CLIENT_SUFFIX;
150+
}
151+
152+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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.NoUniqueBeanDefinitionException;
27+
import org.springframework.beans.factory.annotation.Qualifier;
28+
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
29+
30+
/**
31+
* @author Josh Long
32+
* @author Olga Maciaszek-Sharma
33+
*/
34+
public final class QualifiedBeanProvider {
35+
36+
private static final Log logger = LogFactory.getLog(QualifiedBeanProvider.class);
37+
38+
public static <T> T qualifiedBean(ConfigurableListableBeanFactory beanFactory, Class<T> type, String clientId) {
39+
Map<String, T> matchingClientBeans = getQualifiedBeansOfType(beanFactory, type, clientId);
40+
if (matchingClientBeans.size() > 1) {
41+
throw new NoUniqueBeanDefinitionException(type, matchingClientBeans.keySet());
42+
}
43+
if (matchingClientBeans.isEmpty()) {
44+
if (logger.isDebugEnabled()) {
45+
logger.debug("No qualified bean of type " + type + " found for " + clientId);
46+
}
47+
Map<String, T> matchingDefaultBeans = getQualifiedBeansOfType(beanFactory, type, clientId);
48+
if (matchingDefaultBeans.size() > 1) {
49+
throw new NoUniqueBeanDefinitionException(type, matchingDefaultBeans.keySet());
50+
}
51+
if (matchingDefaultBeans.isEmpty()) {
52+
if (logger.isDebugEnabled()) {
53+
logger.debug("No qualified bean of type " + type + " found for default id");
54+
}
55+
return null;
56+
}
57+
}
58+
return matchingClientBeans.values().iterator().next();
59+
}
60+
61+
private static <T> Map<String, T> getQualifiedBeansOfType(ConfigurableListableBeanFactory beanFactory,
62+
Class<T> type, String clientId) {
63+
Map<String, T> beansOfType = BeanFactoryUtils.beansOfTypeIncludingAncestors(beanFactory, type);
64+
Map<String, T> matchingClientBeans = new HashMap<>();
65+
for (String beanName : beansOfType.keySet()) {
66+
Qualifier qualifier = (beanFactory.findAnnotationOnBean(beanName, Qualifier.class));
67+
if (qualifier != null && clientId.equals(qualifier.value())) {
68+
matchingClientBeans.put(beanName, beanFactory.getBean(beanName, type));
69+
}
70+
}
71+
return matchingClientBeans;
72+
}
73+
74+
}

0 commit comments

Comments
 (0)