Prevent early initialization of Container beans

Update testcontainers auto-configuration so that `Container` bean
instances are no longer needed when registering `ConnectionDetails`
beans. Registration now occurs based on the bean type and the `name`
attribute of `@ServiceConnection`.

Fixes gh-35168
pull/35165/head
Phillip Webb 2 years ago
parent c21cf31853
commit b4cd2572d5

@ -31,6 +31,7 @@ import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactoryNotFoundException;
import org.springframework.core.log.LogMessage; import org.springframework.core.log.LogMessage;
import org.springframework.util.ClassUtils; import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils; import org.springframework.util.ObjectUtils;
@ -61,11 +62,22 @@ class ConnectionDetailsRegistrar {
sources.forEach((source) -> registerBeanDefinitions(registry, source)); sources.forEach((source) -> registerBeanDefinitions(registry, source));
} }
private void registerBeanDefinitions(BeanDefinitionRegistry registry, ContainerConnectionSource<?> source) { void registerBeanDefinitions(BeanDefinitionRegistry registry, ContainerConnectionSource<?> source) {
try {
this.connectionDetailsFactories.getConnectionDetails(source, true) this.connectionDetailsFactories.getConnectionDetails(source, true)
.forEach((connectionDetailsType, connectionDetails) -> registerBeanDefinition(registry, source, .forEach((connectionDetailsType, connectionDetails) -> registerBeanDefinition(registry, source,
connectionDetailsType, connectionDetails)); connectionDetailsType, connectionDetails));
} }
catch (ConnectionDetailsFactoryNotFoundException ex) {
if (!StringUtils.hasText(source.getConnectionName())) {
StringBuilder message = new StringBuilder(ex.getMessage());
message.append((!message.toString().endsWith(".")) ? "." : "");
message.append(" You may need to add a 'name' to your @ServiceConnection annotation");
throw new ConnectionDetailsFactoryNotFoundException(message.toString(), ex.getCause());
}
throw ex;
}
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private <T> void registerBeanDefinition(BeanDefinitionRegistry registry, ContainerConnectionSource<?> source, private <T> void registerBeanDefinition(BeanDefinitionRegistry registry, ContainerConnectionSource<?> source,

@ -20,6 +20,7 @@ import java.util.Arrays;
import org.testcontainers.containers.Container; import org.testcontainers.containers.Container;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory;
import org.springframework.boot.origin.Origin; import org.springframework.boot.origin.Origin;
@ -108,16 +109,18 @@ public abstract class ContainerConnectionDetailsFactory<C extends Container<?>,
protected abstract D getContainerConnectionDetails(ContainerConnectionSource<C> source); protected abstract D getContainerConnectionDetails(ContainerConnectionSource<C> source);
/** /**
* Convenient base class for {@link ConnectionDetails} results that are backed by a * Base class for {@link ConnectionDetails} results that are backed by a
* {@link ContainerConnectionSource}. * {@link ContainerConnectionSource}.
* *
* @param <C> the container type * @param <C> the container type
*/ */
protected static class ContainerConnectionDetails<C extends Container<?>> protected static class ContainerConnectionDetails<C extends Container<?>>
implements ConnectionDetails, OriginProvider { implements ConnectionDetails, OriginProvider, InitializingBean {
private final ContainerConnectionSource<C> source; private final ContainerConnectionSource<C> source;
private volatile C container;
/** /**
* Create a new {@link ContainerConnectionDetails} instance. * Create a new {@link ContainerConnectionDetails} instance.
* @param source the source {@link ContainerConnectionSource} * @param source the source {@link ContainerConnectionSource}
@ -127,8 +130,20 @@ public abstract class ContainerConnectionDetailsFactory<C extends Container<?>,
this.source = source; this.source = source;
} }
@Override
public void afterPropertiesSet() throws Exception {
this.container = this.source.getContainerSupplier().get();
}
/**
* Return the container that back this connection details instance. This method
* can only be called once the connection details bean has been initialized.
* @return the container instance
*/
protected final C getContainer() { protected final C getContainer() {
return this.source.getContainer(); Assert.state(this.container != null,
"Container cannot be obtained before the connection details bean has been initialized");
return this.container;
} }
@Override @Override

@ -17,6 +17,7 @@
package org.springframework.boot.testcontainers.service.connection; package org.springframework.boot.testcontainers.service.connection;
import java.util.Set; import java.util.Set;
import java.util.function.Supplier;
import org.apache.commons.logging.Log; import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory; import org.apache.commons.logging.LogFactory;
@ -49,63 +50,72 @@ public final class ContainerConnectionSource<C extends Container<?>> implements
private final Origin origin; private final Origin origin;
private final C container; private final Class<C> containerType;
private final String acceptedConnectionName; private final String connectionName;
private final Set<Class<?>> acceptedConnectionDetailsTypes; private final Set<Class<?>> connectionDetailsTypes;
ContainerConnectionSource(String beanNameSuffix, Origin origin, C container, private Supplier<C> containerSupplier;
MergedAnnotation<ServiceConnection> annotation) {
ContainerConnectionSource(String beanNameSuffix, Origin origin, Class<C> containerType, String containerImageName,
MergedAnnotation<ServiceConnection> annotation, Supplier<C> containerSupplier) {
this.beanNameSuffix = beanNameSuffix; this.beanNameSuffix = beanNameSuffix;
this.origin = origin; this.origin = origin;
this.container = container; this.containerType = containerType;
this.acceptedConnectionName = getConnectionName(container, annotation.getString("name")); this.connectionName = getOrDeduceConnectionName(annotation.getString("name"), containerImageName);
this.acceptedConnectionDetailsTypes = Set.of(annotation.getClassArray("type")); this.connectionDetailsTypes = Set.of(annotation.getClassArray("type"));
this.containerSupplier = containerSupplier;
} }
ContainerConnectionSource(String beanNameSuffix, Origin origin, C container, ServiceConnection annotation) { ContainerConnectionSource(String beanNameSuffix, Origin origin, Class<C> containerType, String containerImageName,
ServiceConnection annotation, Supplier<C> containerSupplier) {
this.beanNameSuffix = beanNameSuffix; this.beanNameSuffix = beanNameSuffix;
this.origin = origin; this.origin = origin;
this.container = container; this.containerType = containerType;
this.acceptedConnectionName = getConnectionName(container, annotation.name()); this.connectionName = getOrDeduceConnectionName(annotation.name(), containerImageName);
this.acceptedConnectionDetailsTypes = Set.of(annotation.type()); this.connectionDetailsTypes = Set.of(annotation.type());
this.containerSupplier = containerSupplier;
} }
private static String getConnectionName(Container<?> container, String connectionName) { private static String getOrDeduceConnectionName(String connectionName, String containerImageName) {
if (StringUtils.hasLength(connectionName)) { if (StringUtils.hasText(connectionName)) {
return connectionName; return connectionName;
} }
try { if (StringUtils.hasText(containerImageName)) {
DockerImageName imageName = DockerImageName.parse(container.getDockerImageName()); DockerImageName imageName = DockerImageName.parse(containerImageName);
imageName.assertValid(); imageName.assertValid();
return imageName.getRepository(); return imageName.getRepository();
} }
catch (IllegalArgumentException ex) { return null;
return container.getDockerImageName();
}
} }
boolean accepts(String connectionName, Class<?> connectionDetailsType, Class<?> containerType) { boolean accepts(String requiredConnectionName, Class<?> requiredContainerType,
if (!containerType.isInstance(this.container)) { Class<?> requiredConnectionDetailsType) {
logger.trace(LogMessage.of(() -> "%s not accepted as %s is not an instance of %s".formatted(this, if (StringUtils.hasText(requiredConnectionName)
this.container.getClass().getName(), containerType.getName()))); && !requiredConnectionName.equalsIgnoreCase(this.connectionName)) {
logger.trace(LogMessage
.of(() -> "%s not accepted as source connection name '%s' does not match required connection name '%s'"
.formatted(this, this.connectionName, requiredConnectionName)));
return false; return false;
} }
if (StringUtils.hasLength(connectionName) && !connectionName.equalsIgnoreCase(this.acceptedConnectionName)) { if (!requiredContainerType.isAssignableFrom(this.containerType)) {
logger.trace(LogMessage.of(() -> "%s not accepted as connection names '%s' and '%s' do not match" logger.trace(LogMessage.of(() -> "%s not accepted as source container type %s is not assignable from %s"
.formatted(this, connectionName, this.acceptedConnectionName))); .formatted(this, this.containerType.getName(), requiredContainerType.getName())));
return false; return false;
} }
if (!this.acceptedConnectionDetailsTypes.isEmpty() && this.acceptedConnectionDetailsTypes.stream() if (!this.connectionDetailsTypes.isEmpty() && this.connectionDetailsTypes.stream()
.noneMatch((candidate) -> candidate.isAssignableFrom(connectionDetailsType))) { .noneMatch((candidate) -> candidate.isAssignableFrom(requiredConnectionDetailsType))) {
logger.trace(LogMessage.of(() -> "%s not accepted as connection details type %s not in %s".formatted(this, logger.trace(LogMessage
connectionDetailsType, this.acceptedConnectionDetailsTypes))); .of(() -> "%s not accepted as source connection details types %s has no element assignable from %s"
.formatted(this, this.connectionDetailsTypes.stream().map(Class::getName).toList(),
requiredConnectionDetailsType.getName())));
return false; return false;
} }
logger.trace(LogMessage logger.trace(
.of(() -> "%s accepted for connection name '%s', connection details type %s, container type %s" LogMessage.of(() -> "%s accepted for connection name '%s' container type %s, connection details type %s"
.formatted(this, connectionName, connectionDetailsType.getName(), containerType.getName()))); .formatted(this, requiredConnectionName, requiredContainerType.getName(),
requiredConnectionDetailsType.getName())));
return true; return true;
} }
@ -118,8 +128,12 @@ public final class ContainerConnectionSource<C extends Container<?>> implements
return this.origin; return this.origin;
} }
C getContainer() { String getConnectionName() {
return this.container; return this.connectionName;
}
Supplier<C> getContainerSupplier() {
return this.containerSupplier;
} }
@Override @Override

@ -22,8 +22,10 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; import java.lang.annotation.Target;
import org.testcontainers.containers.Container; import org.testcontainers.containers.Container;
import org.testcontainers.utility.DockerImageName;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
import org.springframework.context.annotation.Bean;
import org.springframework.core.annotation.AliasFor; import org.springframework.core.annotation.AliasFor;
/** /**
@ -40,9 +42,18 @@ import org.springframework.core.annotation.AliasFor;
public @interface ServiceConnection { public @interface ServiceConnection {
/** /**
* The name of the service being connected to. If not specified, the image name will * The name of the service being connected to. Container names are used to determine
* be used. Container names are used to determine the connection details that should * the connection details that should be created when a technology-specific
* be created when a technology-specific {@link Container} subclass is not available. * {@link Container} subclass is not available.
* <p>
* If not specified, and if the {@link Container} instance is available, the
* {@link DockerImageName#getRepository() repository} part of the
* {@link Container#getDockerImageName() docker image name} will be used. Note that
* {@link Container} instances are <em>not</em> available early enough when the
* container is defined as a {@link Bean @Bean} method. All
* {@link ServiceConnection @ServiceConnection} {@link Bean @Bean} methods that need
* to match on the connection name <em>must</em> declare this attribute.
* <p>
* This attribute is an alias for {@link #name()}. * This attribute is an alias for {@link #name()}.
* @return the name of the service * @return the name of the service
* @see #name() * @see #name()
@ -52,8 +63,19 @@ public @interface ServiceConnection {
/** /**
* The name of the service being connected to. If not specified, the image name will * The name of the service being connected to. If not specified, the image name will
* be used. Container names are used to determine the connection details that should * The name of the service being connected to. Container names are used to determine
* be created when a technology-specific {@link Container} subclass is not available. * the connection details that should be created when a technology-specific
* {@link Container} subclass is not available.
* <p>
* If not specified, and if the {@link Container} instance is available, the
* {@link DockerImageName#getRepository() repository} part of the
* {@link Container#getDockerImageName() docker image name} will be used. Note that
* {@link Container} instances are <em>not</em> available early enough when the
* container is defined as a {@link Bean @Bean} method. All
* {@link ServiceConnection @ServiceConnection} {@link Bean @Bean} methods that need
* to match on the connection name <em>must</em> declare this attribute.
* <p>
* This attribute is an alias for {@link #value()}.
* @return the name of the service * @return the name of the service
* @see #value() * @see #value()
*/ */

@ -16,13 +16,12 @@
package org.springframework.boot.testcontainers.service.connection; package org.springframework.boot.testcontainers.service.connection;
import java.util.ArrayList;
import java.util.List;
import java.util.Set; import java.util.Set;
import org.testcontainers.containers.Container; import org.testcontainers.containers.Container;
import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.BeanDefinitionRegistry;
@ -48,33 +47,43 @@ class ServiceConnectionAutoConfigurationRegistrar implements ImportBeanDefinitio
@Override @Override
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
if (this.beanFactory instanceof ConfigurableListableBeanFactory listableBeanFactory) { if (this.beanFactory instanceof ConfigurableListableBeanFactory listableBeanFactory) {
ConnectionDetailsFactories connectionDetailsFactories = new ConnectionDetailsFactories(); registerBeanDefinitions(listableBeanFactory, registry);
List<ContainerConnectionSource<?>> sources = getSources(listableBeanFactory);
new ConnectionDetailsRegistrar(listableBeanFactory, connectionDetailsFactories)
.registerBeanDefinitions(registry, sources);
} }
} }
private List<ContainerConnectionSource<?>> getSources(ConfigurableListableBeanFactory beanFactory) { private void registerBeanDefinitions(ConfigurableListableBeanFactory beanFactory, BeanDefinitionRegistry registry) {
List<ContainerConnectionSource<?>> sources = new ArrayList<>(); ConnectionDetailsRegistrar registrar = new ConnectionDetailsRegistrar(beanFactory,
for (String candidate : beanFactory.getBeanNamesForType(Container.class)) { new ConnectionDetailsFactories());
Set<ServiceConnection> annotations = beanFactory.findAllAnnotationsOnBean(candidate, for (String beanName : beanFactory.getBeanNamesForType(Container.class)) {
ServiceConnection.class, false); BeanDefinition beanDefinition = getBeanDefinition(beanFactory, beanName);
if (!annotations.isEmpty()) { for (ServiceConnection annotation : getAnnotations(beanFactory, beanName)) {
addSources(sources, beanFactory, candidate, annotations); ContainerConnectionSource<?> source = createSource(beanFactory, beanName, beanDefinition, annotation);
registrar.registerBeanDefinitions(registry, source);
} }
} }
return sources;
} }
private void addSources(List<ContainerConnectionSource<?>> sources, ConfigurableListableBeanFactory beanFactory, private Set<ServiceConnection> getAnnotations(ConfigurableListableBeanFactory beanFactory, String beanName) {
String beanName, Set<ServiceConnection> annotations) { return beanFactory.findAllAnnotationsOnBean(beanName, ServiceConnection.class, false);
BeanDefinition beanDefinition = beanFactory.getBeanDefinition(beanName); }
Origin origin = new BeanOrigin(beanName, beanDefinition);
Container<?> container = beanFactory.getBean(beanName, Container.class); private BeanDefinition getBeanDefinition(ConfigurableListableBeanFactory beanFactory, String beanName) {
for (ServiceConnection annotation : annotations) { try {
sources.add(new ContainerConnectionSource<>(beanName, origin, container, annotation)); return beanFactory.getBeanDefinition(beanName);
}
catch (NoSuchBeanDefinitionException ex) {
return null;
} }
} }
@SuppressWarnings("unchecked")
private <C extends Container<?>> ContainerConnectionSource<C> createSource(
ConfigurableListableBeanFactory beanFactory, String beanName, BeanDefinition beanDefinition,
ServiceConnection annotation) {
Origin origin = new BeanOrigin(beanName, beanDefinition);
Class<C> containerType = (Class<C>) beanFactory.getType(beanName, false);
return new ContainerConnectionSource<>(beanName, origin, containerType, null, annotation,
() -> beanFactory.getBean(beanName, containerType));
}
} }

@ -31,9 +31,7 @@ import org.springframework.test.context.ContextCustomizer;
import org.springframework.test.context.ContextCustomizerFactory; import org.springframework.test.context.ContextCustomizerFactory;
import org.springframework.test.context.TestContextAnnotationUtils; import org.springframework.test.context.TestContextAnnotationUtils;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils; import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
/** /**
* Spring Test {@link ContextCustomizerFactory} to support * Spring Test {@link ContextCustomizerFactory} to support
@ -65,17 +63,19 @@ class ServiceConnectionContextCustomizerFactory implements ContextCustomizerFact
} }
} }
private ContainerConnectionSource<?> createSource(Field field, MergedAnnotation<ServiceConnection> annotation) { @SuppressWarnings("unchecked")
private <C extends Container<?>> ContainerConnectionSource<?> createSource(Field field,
MergedAnnotation<ServiceConnection> annotation) {
Assert.state(Modifier.isStatic(field.getModifiers()), Assert.state(Modifier.isStatic(field.getModifiers()),
() -> "@ServiceConnection field '%s' must be static".formatted(field.getName())); () -> "@ServiceConnection field '%s' must be static".formatted(field.getName()));
String beanNameSuffix = StringUtils.capitalize(ClassUtils.getShortNameAsProperty(field.getDeclaringClass()))
+ StringUtils.capitalize(field.getName());
Origin origin = new FieldOrigin(field); Origin origin = new FieldOrigin(field);
Object fieldValue = getFieldValue(field); Object fieldValue = getFieldValue(field);
Assert.state(fieldValue instanceof Container, () -> "Field '%s' in %s must be a %s".formatted(field.getName(), Assert.state(fieldValue instanceof Container, () -> "Field '%s' in %s must be a %s".formatted(field.getName(),
field.getDeclaringClass().getName(), Container.class.getName())); field.getDeclaringClass().getName(), Container.class.getName()));
Container<?> container = (Container<?>) fieldValue; Class<C> containerType = (Class<C>) fieldValue.getClass();
return new ContainerConnectionSource<>(beanNameSuffix, origin, container, annotation); C container = (C) fieldValue;
return new ContainerConnectionSource<>("test", origin, containerType, container.getDockerImageName(),
annotation, () -> container);
} }
private Object getFieldValue(Field field) { private Object getFieldValue(Field field) {

@ -0,0 +1,105 @@
/*
* Copyright 2012-2023 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.testcontainers.service.connection;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactories;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactoryNotFoundException;
import org.springframework.boot.origin.Origin;
import org.springframework.core.annotation.MergedAnnotation;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link ConnectionDetailsRegistrar}.
*
* @author Phillip Webb
*/
class ConnectionDetailsRegistrarTests {
private Origin origin;
private PostgreSQLContainer<?> container;
private MergedAnnotation<ServiceConnection> annotation;
private ContainerConnectionSource<?> source;
private ConnectionDetailsFactories factories;
@BeforeEach
void setup() {
this.origin = mock(Origin.class);
this.container = mock(PostgreSQLContainer.class);
this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", "", "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class, null,
this.annotation, () -> this.container);
this.factories = mock(ConnectionDetailsFactories.class);
}
@Test
void registerBeanDefinitionsWhenConnectionDetailsFactoryNotFoundAndNoConnectionNameThrowsExceptionWithBetterMessage() {
DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
ConnectionDetailsRegistrar registrar = new ConnectionDetailsRegistrar(beanFactory, this.factories);
given(this.factories.getConnectionDetails(this.source, true))
.willThrow(new ConnectionDetailsFactoryNotFoundException("fail"));
assertThatExceptionOfType(ConnectionDetailsFactoryNotFoundException.class)
.isThrownBy(() -> registrar.registerBeanDefinitions(beanFactory, this.source))
.withMessage("fail. You may need to add a 'name' to your @ServiceConnection annotation");
}
@Test
void registerBeanDefinitionsWhenExistingBeanSkipsRegistration() {
DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
beanFactory.registerBeanDefinition("testbean", new RootBeanDefinition(CustomTestConnectionDetails.class));
ConnectionDetailsRegistrar registrar = new ConnectionDetailsRegistrar(beanFactory, this.factories);
given(this.factories.getConnectionDetails(this.source, true))
.willReturn(Map.of(TestConnectionDetails.class, new TestConnectionDetails()));
registrar.registerBeanDefinitions(beanFactory, this.source);
assertThat(beanFactory.getBean(TestConnectionDetails.class)).isInstanceOf(CustomTestConnectionDetails.class);
}
@Test
void registerBeanDefinitionsRegistersDefinition() {
DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
ConnectionDetailsRegistrar registrar = new ConnectionDetailsRegistrar(beanFactory, this.factories);
given(this.factories.getConnectionDetails(this.source, true))
.willReturn(Map.of(TestConnectionDetails.class, new TestConnectionDetails()));
registrar.registerBeanDefinitions(beanFactory, this.source);
assertThat(beanFactory.getBean(TestConnectionDetails.class)).isNotNull();
}
static class TestConnectionDetails implements ConnectionDetails {
}
static class CustomTestConnectionDetails extends TestConnectionDetails {
}
}

@ -28,9 +28,11 @@ import org.springframework.boot.autoconfigure.jdbc.JdbcConnectionDetails;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetails;
import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory; import org.springframework.boot.autoconfigure.service.connection.ConnectionDetailsFactory;
import org.springframework.boot.origin.Origin; import org.springframework.boot.origin.Origin;
import org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactoryTests.TestContainerConnectionDetailsFactory.TestContainerConnectionDetails;
import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.annotation.MergedAnnotation;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
/** /**
@ -59,8 +61,8 @@ class ContainerConnectionDetailsFactoryTests {
this.container = mock(PostgreSQLContainer.class); this.container = mock(PostgreSQLContainer.class);
this.annotation = MergedAnnotation.of(ServiceConnection.class, this.annotation = MergedAnnotation.of(ServiceConnection.class,
Map.of("name", "myname", "type", new Class<?>[0])); Map.of("name", "myname", "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container, this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class,
this.annotation); this.container.getDockerImageName(), this.annotation, () -> this.container);
} }
@Test @Test
@ -88,7 +90,7 @@ class ContainerConnectionDetailsFactoryTests {
void getConnectionDetailsWhenContainerTypeDoesNotMatchReturnsNull() { void getConnectionDetailsWhenContainerTypeDoesNotMatchReturnsNull() {
ElasticsearchContainer container = mock(ElasticsearchContainer.class); ElasticsearchContainer container = mock(ElasticsearchContainer.class);
ContainerConnectionSource<?> source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, ContainerConnectionSource<?> source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin,
container, this.annotation); ElasticsearchContainer.class, container.getDockerImageName(), this.annotation, () -> container);
TestContainerConnectionDetailsFactory factory = new TestContainerConnectionDetailsFactory(); TestContainerConnectionDetailsFactory factory = new TestContainerConnectionDetailsFactory();
ConnectionDetails connectionDetails = getConnectionDetails(factory, source); ConnectionDetails connectionDetails = getConnectionDetails(factory, source);
assertThat(connectionDetails).isNull(); assertThat(connectionDetails).isNull();
@ -101,10 +103,26 @@ class ContainerConnectionDetailsFactoryTests {
assertThat(Origin.from(connectionDetails)).isSameAs(this.origin); assertThat(Origin.from(connectionDetails)).isSameAs(this.origin);
} }
@Test
void getContainerWhenNotInitializedThrowsException() {
TestContainerConnectionDetailsFactory factory = new TestContainerConnectionDetailsFactory();
TestContainerConnectionDetails connectionDetails = getConnectionDetails(factory, this.source);
assertThatIllegalStateException().isThrownBy(() -> connectionDetails.callGetContainer())
.withMessage("Container cannot be obtained before the connection details bean has been initialized");
}
@Test
void getContainerWhenInitializedReturnsSuppliedContainer() throws Exception {
TestContainerConnectionDetailsFactory factory = new TestContainerConnectionDetailsFactory();
TestContainerConnectionDetails connectionDetails = getConnectionDetails(factory, this.source);
connectionDetails.afterPropertiesSet();
assertThat(connectionDetails.callGetContainer()).isSameAs(this.container);
}
@SuppressWarnings({ "rawtypes", "unchecked" }) @SuppressWarnings({ "rawtypes", "unchecked" })
private ConnectionDetails getConnectionDetails(ConnectionDetailsFactory<?, ?> factory, private TestContainerConnectionDetails getConnectionDetails(ConnectionDetailsFactory<?, ?> factory,
ContainerConnectionSource<?> source) { ContainerConnectionSource<?> source) {
return ((ConnectionDetailsFactory) factory).getConnectionDetails(source); return (TestContainerConnectionDetails) ((ConnectionDetailsFactory) factory).getConnectionDetails(source);
} }
/** /**
@ -127,8 +145,8 @@ class ContainerConnectionDetailsFactoryTests {
return new TestContainerConnectionDetails(source); return new TestContainerConnectionDetails(source);
} }
private static final class TestContainerConnectionDetails static final class TestContainerConnectionDetails extends ContainerConnectionDetails<JdbcDatabaseContainer<?>>
extends ContainerConnectionDetails<JdbcDatabaseContainer<?>> implements JdbcConnectionDetails { implements JdbcConnectionDetails {
private TestContainerConnectionDetails(ContainerConnectionSource<JdbcDatabaseContainer<?>> source) { private TestContainerConnectionDetails(ContainerConnectionSource<JdbcDatabaseContainer<?>> source) {
super(source); super(source);
@ -149,6 +167,10 @@ class ContainerConnectionDetailsFactoryTests {
return "jdbc:example"; return "jdbc:example";
} }
JdbcDatabaseContainer<?> callGetContainer() {
return super.getContainer();
}
} }
} }

@ -46,7 +46,7 @@ class ContainerConnectionSourceTests {
private Origin origin; private Origin origin;
private JdbcDatabaseContainer<?> container; private PostgreSQLContainer<?> container;
private MergedAnnotation<ServiceConnection> annotation; private MergedAnnotation<ServiceConnection> annotation;
@ -59,92 +59,102 @@ class ContainerConnectionSourceTests {
this.container = mock(PostgreSQLContainer.class); this.container = mock(PostgreSQLContainer.class);
given(this.container.getDockerImageName()).willReturn("postgres"); given(this.container.getDockerImageName()).willReturn("postgres");
this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", "", "type", new Class<?>[0])); this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", "", "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container, this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class,
this.annotation); this.container.getDockerImageName(), this.annotation, () -> this.container);
} }
@Test @Test
void acceptsWhenContainerIsNotInstanceOfContainerTypeReturnsFalse() { void acceptsWhenContainerIsNotInstanceOfRequiredContainerTypeReturnsFalse() {
String connectionName = null; String requiredConnectionName = null;
Class<?> connectionDetailsType = JdbcConnectionDetails.class; Class<?> requiredContainerType = ElasticsearchContainer.class;
Class<?> containerType = ElasticsearchContainer.class; Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isFalse(); assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isFalse();
} }
@Test @Test
void acceptsWhenContainerIsInstanceOfContainerTypeReturnsTrue() { void acceptsWhenContainerIsInstanceOfRequiredContainerTypeReturnsTrue() {
String connectionName = null; String requiredConnectionName = null;
Class<?> connectionDetailsType = JdbcConnectionDetails.class; Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> containerType = JdbcDatabaseContainer.class; Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue(); assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isTrue();
} }
@Test @Test
void acceptsWhenConnectionNameDoesNotMatchNameTakenFromAnnotationReturnsFalse() { void acceptsWhenRequiredConnectionNameDoesNotMatchNameTakenFromAnnotationReturnsFalse() {
setupSourceAnnotatedWithName("myname"); setupSourceAnnotatedWithName("myname");
String connectionName = "othername"; String requiredConnectionName = "othername";
Class<?> connectionDetailsType = JdbcConnectionDetails.class; Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> containerType = JdbcDatabaseContainer.class; Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isFalse(); assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isFalse();
} }
@Test @Test
void acceptsWhenConnectionNameDoesNotMatchNameTakenFromContainerReturnsFalse() { void acceptsWhenRequiredConnectionNameDoesNotMatchNameTakenFromContainerReturnsFalse() {
String connectionName = "othername"; String requiredConnectionName = "othername";
Class<?> connectionDetailsType = JdbcConnectionDetails.class; Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> containerType = JdbcDatabaseContainer.class; Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isFalse(); assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isFalse();
} }
@Test @Test
void acceptsWhenConnectionNameIsUnrestrictedReturnsTrue() { void acceptsWhenRequiredConnectionNameIsUnrestrictedReturnsTrue() {
String connectionName = null; String requiredConnectionName = null;
Class<?> connectionDetailsType = JdbcConnectionDetails.class; Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> containerType = JdbcDatabaseContainer.class; Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue(); assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isTrue();
} }
@Test @Test
void acceptsWhenConnectionNameMatchesNameTakenFromAnnotationReturnsTrue() { void acceptsWhenRequiredConnectionNameMatchesNameTakenFromAnnotationReturnsTrue() {
setupSourceAnnotatedWithName("myname"); setupSourceAnnotatedWithName("myname");
String connectionName = "myname"; String requiredConnectionName = "myname";
Class<?> connectionDetailsType = JdbcConnectionDetails.class; Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> containerType = JdbcDatabaseContainer.class; Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue(); assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isTrue();
} }
@Test @Test
void acceptsWhenConnectionNameMatchesNameTakenFromContainerReturnsTrue() { void acceptsWhenRequiredConnectionNameMatchesNameTakenFromContainerReturnsTrue() {
String connectionName = "postgres"; String requiredConnectionName = "postgres";
Class<?> connectionDetailsType = JdbcConnectionDetails.class; Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> containerType = JdbcDatabaseContainer.class; Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue(); assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isTrue();
} }
@Test @Test
void acceptsWhenConnectionDetailsTypeNotInAnnotationRestrictionReturnsFalse() { void acceptsWhenRequiredConnectionDetailsTypeNotInAnnotationRestrictionReturnsFalse() {
setupSourceAnnotatedWithType(ElasticsearchConnectionDetails.class); setupSourceAnnotatedWithType(ElasticsearchConnectionDetails.class);
String connectionName = null; String requiredConnectionName = null;
Class<?> connectionDetailsType = JdbcConnectionDetails.class; Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> containerType = JdbcDatabaseContainer.class; Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isFalse(); assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isFalse();
} }
@Test @Test
void acceptsWhenConnectionDetailsTypeInAnnotationRestrictionReturnsTrue() { void acceptsWhenRequiredConnectionDetailsTypeInAnnotationRestrictionReturnsTrue() {
setupSourceAnnotatedWithType(JdbcConnectionDetails.class); setupSourceAnnotatedWithType(JdbcConnectionDetails.class);
String connectionName = null; String requiredConnectionName = null;
Class<?> connectionDetailsType = JdbcConnectionDetails.class; Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> containerType = JdbcDatabaseContainer.class; Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue(); assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isTrue();
} }
@Test @Test
void acceptsWhenConnectionDetailsTypeIsNotRestrictedReturnsTrue() { void acceptsWhenRequiredConnectionDetailsTypeIsNotRestrictedReturnsTrue() {
String connectionName = null; String requiredConnectionName = null;
Class<?> connectionDetailsType = JdbcConnectionDetails.class; Class<?> requiredContainerType = JdbcDatabaseContainer.class;
Class<?> containerType = JdbcDatabaseContainer.class; Class<?> requiredConnectionDetailsType = JdbcConnectionDetails.class;
assertThat(this.source.accepts(connectionName, connectionDetailsType, containerType)).isTrue(); assertThat(this.source.accepts(requiredConnectionName, requiredContainerType, requiredConnectionDetailsType))
.isTrue();
} }
@Test @Test
@ -158,8 +168,8 @@ class ContainerConnectionSourceTests {
} }
@Test @Test
void getContainerReturnsContainer() { void getContainerSupplierReturnsSupplierSupplyingContainer() {
assertThat(this.source.getContainer()).isSameAs(this.container); assertThat(this.source.getContainerSupplier().get()).isSameAs(this.container);
} }
@Test @Test
@ -169,15 +179,15 @@ class ContainerConnectionSourceTests {
private void setupSourceAnnotatedWithName(String name) { private void setupSourceAnnotatedWithName(String name) {
this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", name, "type", new Class<?>[0])); this.annotation = MergedAnnotation.of(ServiceConnection.class, Map.of("name", name, "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container, this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class,
this.annotation); this.container.getDockerImageName(), this.annotation, () -> this.container);
} }
private void setupSourceAnnotatedWithType(Class<?> type) { private void setupSourceAnnotatedWithType(Class<?> type) {
this.annotation = MergedAnnotation.of(ServiceConnection.class, this.annotation = MergedAnnotation.of(ServiceConnection.class,
Map.of("name", "", "type", new Class<?>[] { type })); Map.of("name", "", "type", new Class<?>[] { type }));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container, this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, PostgreSQLContainer.class,
this.annotation); this.container.getDockerImageName(), this.annotation, () -> this.container);
} }
} }

@ -94,7 +94,7 @@ class ServiceConnectionAutoConfigurationTests {
static class ContainerConfiguration { static class ContainerConfiguration {
@Bean @Bean
@ServiceConnection @ServiceConnection("redis")
RedisContainer redisContainer() { RedisContainer redisContainer() {
return new RedisContainer(); return new RedisContainer();
} }

@ -80,7 +80,7 @@ class ServiceConnectionContextCustomizerFactoryTests {
ServiceConnectionContextCustomizer customizer = (ServiceConnectionContextCustomizer) this.factory ServiceConnectionContextCustomizer customizer = (ServiceConnectionContextCustomizer) this.factory
.createContextCustomizer(SingleServiceConnection.class, null); .createContextCustomizer(SingleServiceConnection.class, null);
ContainerConnectionSource<?> source = customizer.getSources().get(0); ContainerConnectionSource<?> source = customizer.getSources().get(0);
assertThat(source.getBeanNameSuffix()).isEqualTo("SingleServiceConnectionService1"); assertThat(source.getBeanNameSuffix()).isEqualTo("test");
} }
@Test @Test

@ -22,7 +22,6 @@ import java.util.Map;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.testcontainers.containers.JdbcDatabaseContainer;
import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.containers.PostgreSQLContainer;
import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.BeanDefinition;
@ -51,11 +50,9 @@ import static org.mockito.Mockito.spy;
*/ */
class ServiceConnectionContextCustomizerTests { class ServiceConnectionContextCustomizerTests {
private String beanNameSuffix;
private Origin origin; private Origin origin;
private JdbcDatabaseContainer<?> container; private PostgreSQLContainer<?> container;
private MergedAnnotation<ServiceConnection> annotation; private MergedAnnotation<ServiceConnection> annotation;
@ -65,13 +62,12 @@ class ServiceConnectionContextCustomizerTests {
@BeforeEach @BeforeEach
void setup() { void setup() {
this.beanNameSuffix = "MyBean";
this.origin = mock(Origin.class); this.origin = mock(Origin.class);
this.container = mock(PostgreSQLContainer.class); this.container = mock(PostgreSQLContainer.class);
this.annotation = MergedAnnotation.of(ServiceConnection.class, this.annotation = MergedAnnotation.of(ServiceConnection.class,
Map.of("name", "myname", "type", new Class<?>[0])); Map.of("name", "myname", "type", new Class<?>[0]));
this.source = new ContainerConnectionSource<>(this.beanNameSuffix, this.origin, this.container, this.source = new ContainerConnectionSource<>("test", this.origin, PostgreSQLContainer.class,
this.annotation); this.container.getDockerImageName(), this.annotation, () -> this.container);
this.factories = mock(ConnectionDetailsFactories.class); this.factories = mock(ConnectionDetailsFactories.class);
} }
@ -89,7 +85,7 @@ class ServiceConnectionContextCustomizerTests {
customizer.customizeContext(context, mergedConfig); customizer.customizeContext(context, mergedConfig);
ArgumentCaptor<BeanDefinition> beanDefinitionCaptor = ArgumentCaptor.forClass(BeanDefinition.class); ArgumentCaptor<BeanDefinition> beanDefinitionCaptor = ArgumentCaptor.forClass(BeanDefinition.class);
then(beanFactory).should() then(beanFactory).should()
.registerBeanDefinition(eq("testJdbcConnectionDetailsForMyBean"), beanDefinitionCaptor.capture()); .registerBeanDefinition(eq("testJdbcConnectionDetailsForTest"), beanDefinitionCaptor.capture());
RootBeanDefinition beanDefinition = (RootBeanDefinition) beanDefinitionCaptor.getValue(); RootBeanDefinition beanDefinition = (RootBeanDefinition) beanDefinitionCaptor.getValue();
assertThat(beanDefinition.getInstanceSupplier().get()).isSameAs(connectionDetails); assertThat(beanDefinition.getInstanceSupplier().get()).isSameAs(connectionDetails);
assertThat(beanDefinition.getBeanClass()).isEqualTo(TestJdbcConnectionDetails.class); assertThat(beanDefinition.getBeanClass()).isEqualTo(TestJdbcConnectionDetails.class);

Loading…
Cancel
Save