Skip to content

Commit

Permalink
Auto-configure Spring Interface Clients beans.
Browse files Browse the repository at this point in the history
  • Loading branch information
OlgaMaciaszek committed Sep 20, 2024
1 parent 3952d63 commit 8326022
Show file tree
Hide file tree
Showing 23 changed files with 1,041 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ void documentConfigurationProperties() throws IOException {
snippets.add("application-properties.server", "Server Properties", this::serverPrefixes);
snippets.add("application-properties.security", "Security Properties", this::securityPrefixes);
snippets.add("application-properties.rsocket", "RSocket Properties", this::rsocketPrefixes);
snippets.add("application-properties.interfaceclients", "Interface Clients Properties",
this::interfaceClientsPrefixes);
snippets.add("application-properties.actuator", "Actuator Properties", this::actuatorPrefixes);
snippets.add("application-properties.devtools", "Devtools Properties", this::devtoolsPrefixes);
snippets.add("application-properties.docker-compose", "Docker Compose Properties", this::dockerComposePrefixes);
Expand Down Expand Up @@ -205,6 +207,10 @@ private void rsocketPrefixes(Config prefix) {
prefix.accept("spring.rsocket");
}

private void interfaceClientsPrefixes(Config prefix) {
prefix.accept("spring.interfaceclients");
}

private void actuatorPrefixes(Config prefix) {
prefix.accept("management");
prefix.accept("micrometer");
Expand Down
1 change: 1 addition & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ include "spring-boot-project:spring-boot-actuator-autoconfigure"
include "spring-boot-project:spring-boot-docker-compose"
include "spring-boot-project:spring-boot-devtools"
include "spring-boot-project:spring-boot-docs"
include "spring-boot-project:spring-boot-interface-clients"
include "spring-boot-project:spring-boot-test"
include "spring-boot-project:spring-boot-testcontainers"
include "spring-boot-project:spring-boot-test-autoconfigure"
Expand Down
4 changes: 4 additions & 0 deletions spring-boot-project/spring-boot-autoconfigure/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ configurations.all {
dependencies {
api(project(":spring-boot-project:spring-boot"))

// TODO: Have added it to be able to use CaseUtils and avoid rewriting the code;
// can remove it and duplicate the required method instead
implementation("org.apache.commons:commons-text")

dockerTestImplementation(project(":spring-boot-project:spring-boot-test"))
dockerTestImplementation(project(":spring-boot-project:spring-boot-tools:spring-boot-test-support-docker"))
dockerTestImplementation("com.redis:testcontainers-redis")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* Copyright 2012-2024 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.boot.autoconfigure.interfaceclients;

import java.lang.annotation.Annotation;
import java.text.Normalizer;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.commons.text.CaseUtils;

import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.BeanDefinitionHolder;
import org.springframework.beans.factory.support.AbstractBeanDefinition;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.beans.factory.support.BeanDefinitionReaderUtils;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.BeanNameGenerator;
import org.springframework.boot.autoconfigure.AutoConfigurationPackages;
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.ResolvableType;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.env.Environment;
import org.springframework.core.io.ResourceLoader;
import org.springframework.core.type.AnnotationMetadata;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.util.Assert;
import org.springframework.util.ObjectUtils;

/**
* Registers bean definitions for annotated Interface Clients in order to automatically
* instantiate client beans based on those interfaces.
*
* @author Josh Long
* @author Olga Maciaszek-Sharma
* @since 3.4.0
*/
// TODO: Handle AOT
public abstract class AbstractInterfaceClientsImportRegistrar
implements ImportBeanDefinitionRegistrar, EnvironmentAware, ResourceLoaderAware {

private static final String INTERFACE_CLIENT_SUFFIX = "InterfaceClient";

private static final String BEAN_NAME_ATTRIBUTE_NAME = "beanName";

private static final Log logger = LogFactory.getLog(AbstractInterfaceClientsImportRegistrar.class);

private Environment environment;

private ResourceLoader resourceLoader;

@Override
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry,
BeanNameGenerator importBeanNameGenerator) {
Assert.isInstanceOf(ListableBeanFactory.class, registry,
"Registry must be an instance of " + ListableBeanFactory.class.getSimpleName());
ListableBeanFactory beanFactory = (ListableBeanFactory) registry;
Set<BeanDefinition> candidateComponents = discoverCandidateComponents(beanFactory);
for (BeanDefinition candidateComponent : candidateComponents) {
if (candidateComponent instanceof AnnotatedBeanDefinition beanDefinition) {
registerInterfaceClient(registry, beanFactory, beanDefinition);
}
}
}

@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}

@Override
public void setResourceLoader(ResourceLoader resourceLoader) {
this.resourceLoader = resourceLoader;
}

protected abstract Class<? extends Annotation> getAnnotation();

protected Set<BeanDefinition> discoverCandidateComponents(ListableBeanFactory beanFactory) {
Set<BeanDefinition> candidateComponents = new HashSet<>();
ClassPathScanningCandidateComponentProvider scanner = getScanner();
scanner.setResourceLoader(this.resourceLoader);
scanner.addIncludeFilter(new AnnotationTypeFilter(getAnnotation()));
List<String> basePackages = AutoConfigurationPackages.get(beanFactory);
for (String basePackage : basePackages) {
candidateComponents.addAll(scanner.findCandidateComponents(basePackage));
}
return candidateComponents;
}

private ClassPathScanningCandidateComponentProvider getScanner() {
return new ClassPathScanningCandidateComponentProvider(false, this.environment) {
@Override
protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) {
boolean isCandidate = false;
if (beanDefinition.getMetadata().isIndependent()) {
if (!beanDefinition.getMetadata().isAnnotation()) {
isCandidate = true;
}
}
return isCandidate;
}
};
}

private void registerInterfaceClient(BeanDefinitionRegistry registry, ListableBeanFactory beanFactory,
AnnotatedBeanDefinition beanDefinition) {
AnnotationMetadata annotatedBeanMetadata = beanDefinition.getMetadata();
Assert.isTrue(annotatedBeanMetadata.isInterface(),
getAnnotation().getSimpleName() + "can only be placed on an interface.");
MergedAnnotation<? extends Annotation> annotation = annotatedBeanMetadata.getAnnotations().get(getAnnotation());
String beanClassName = annotatedBeanMetadata.getClassName();
// The value of the annotation is the qualifier to look for related beans
// while the default beanName corresponds to the simple class name suffixed with
// `InterfaceClient`
String clientId = annotation.getString(MergedAnnotation.VALUE);
String beanName = !ObjectUtils.isEmpty(annotation.getString(BEAN_NAME_ATTRIBUTE_NAME))
? annotation.getString(BEAN_NAME_ATTRIBUTE_NAME) : buildBeanName(clientId);
InterfaceClientsAdapter adapter = beanFactory.getBean(InterfaceClientsAdapter.class);
Class<?> beanClass = toClass(beanClassName);
BeanDefinition definition = BeanDefinitionBuilder
.rootBeanDefinition(ResolvableType.forClass(beanClass),
() -> adapter.createClient(beanFactory, clientId, beanClass))
.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE)
.getBeanDefinition();
BeanDefinitionHolder holder = new BeanDefinitionHolder(definition, beanName, new String[] { clientId });
BeanDefinitionReaderUtils.registerBeanDefinition(holder, registry);
}

private String buildBeanName(String clientId) {
String normalised = Normalizer.normalize(clientId, Normalizer.Form.NFD);
String camelCased = CaseUtils.toCamelCase(normalised, false, '-', '_');
return camelCased + INTERFACE_CLIENT_SUFFIX;
}

private static Class<?> toClass(String beanClassName) {
Class<?> beanClass;
try {
beanClass = Class.forName(beanClassName);
}
catch (ClassNotFoundException ex) {
if (logger.isDebugEnabled()) {
logger.debug("Class not found for interface client " + beanClassName + ": " + ex.getMessage());
}
throw new RuntimeException(ex);
}
return beanClass;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2012-2024 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.boot.autoconfigure.interfaceclients;

import org.springframework.beans.factory.ListableBeanFactory;

/**
* Creates an Interface Client bean for the specified {@code type} and {@code clientId}.
*
* @author Josh Long
* @author Olga Maciaszek-Sharma
* @since 3.4.0
*/
public interface InterfaceClientsAdapter {

/**
* Default qualifier for user-provided beans used for creating Interface Clients.
*/
String INTERFACE_CLIENTS_DEFAULT_QUALIFIER = "interfaceClients";

<T> T createClient(ListableBeanFactory beanFactory, String clientId, Class<T> type);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Copyright 2012-2024 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.boot.autoconfigure.interfaceclients;

import java.util.HashMap;
import java.util.Map;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.beans.factory.BeanFactoryUtils;
import org.springframework.beans.factory.ListableBeanFactory;
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
import org.springframework.beans.factory.annotation.Qualifier;

/**
* Utility class containing methods that allow searching for beans with a specific
* qualifier, falling back to the
* {@link InterfaceClientsAdapter#INTERFACE_CLIENTS_DEFAULT_QUALIFIER} qualifier.
*
* @author Josh Long
* @author Olga Maciaszek-Sharma
* @since 3.4.0
*/
public final class QualifiedBeanProvider {

private QualifiedBeanProvider() {
throw new UnsupportedOperationException("Do not instantiate utility class");
}

private static final Log logger = LogFactory.getLog(QualifiedBeanProvider.class);

public static <T> T qualifiedBean(ListableBeanFactory beanFactory, Class<T> type, String clientId) {
Map<String, T> matchingClientBeans = getQualifiedBeansOfType(beanFactory, type, clientId);
if (matchingClientBeans.size() > 1) {
throw new NoUniqueBeanDefinitionException(type, matchingClientBeans.keySet());
}
if (matchingClientBeans.isEmpty()) {
if (logger.isDebugEnabled()) {
logger.debug("No qualified bean of type " + type + " found for " + clientId);
}
Map<String, T> matchingDefaultBeans = getQualifiedBeansOfType(beanFactory, type,
org.springframework.boot.autoconfigure.interfaceclients.InterfaceClientsAdapter.INTERFACE_CLIENTS_DEFAULT_QUALIFIER);
if (matchingDefaultBeans.size() > 1) {
throw new NoUniqueBeanDefinitionException(type, matchingDefaultBeans.keySet());
}
if (matchingDefaultBeans.isEmpty()) {
if (logger.isDebugEnabled()) {
logger.debug("No qualified bean of type " + type + " found for default id");
}
return null;
}
}
return matchingClientBeans.values().iterator().next();
}

private static <T> Map<String, T> getQualifiedBeansOfType(ListableBeanFactory beanFactory, Class<T> type,
String clientId) {
Map<String, T> beansOfType = BeanFactoryUtils.beansOfTypeIncludingAncestors(beanFactory, type);
Map<String, T> matchingClientBeans = new HashMap<>();
for (String beanName : beansOfType.keySet()) {
Qualifier qualifier = (beanFactory.findAnnotationOnBean(beanName, Qualifier.class));
if (qualifier != null && clientId.equals(qualifier.value())) {
matchingClientBeans.put(beanName, beanFactory.getBean(beanName, type));
}
}
return matchingClientBeans;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
* Copyright 2012-2024 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.boot.autoconfigure.interfaceclients.http;

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.web.service.annotation.HttpExchange;

/**
* Annotation to be placed on interfaces containing {@link HttpExchange}-annotated methods
* in order for a client based on that interface to be autoconfigured.
*
* @author Olga Maciaszek-Sharma
* @since 3.4.0
*/
// TODO: Consider moving over to Framework.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface HttpClient {

String value();

String beanName() default "";

}
Loading

0 comments on commit 8326022

Please sign in to comment.