Add custom-starter module: a Boot 4 auto-configuration starter with conditions, metadata and runner tests
Two-jar starter (autoconfigure + starter), a demo app, and 17 captured transcripts covering AutoConfiguration.imports, @ConditionalOn*, ordering, optional Jackson 3 integration, configuration metadata and the Boot 4 module split. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>com.ankurm.masker</groupId>
|
||||
<artifactId>masker-parent</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>masker-spring-boot-autoconfigure</artifactId>
|
||||
<name>masker-spring-boot-autoconfigure</name>
|
||||
<description>The auto-configuration, the properties and the two optional integrations</description>
|
||||
|
||||
<dependencies>
|
||||
<!-- the infrastructure every auto-configuration needs: @AutoConfiguration and the @Conditional* annotations -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- optional: the Jackson integration only activates when the application brings Jackson itself -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-jackson</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<annotationProcessorPaths>
|
||||
<path>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
</path>
|
||||
<path>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-autoconfigure-processor</artifactId>
|
||||
</path>
|
||||
</annotationProcessorPaths>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,9 @@
|
||||
package broken;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
|
||||
|
||||
/** How a Boot 3 starter orders itself after Jackson. Not part of the build: scripts/capture-jar-facts.sh compiles it on purpose. */
|
||||
@AutoConfiguration(after = JacksonAutoConfiguration.class)
|
||||
public class OldJacksonImport {
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.ankurm.masker;
|
||||
|
||||
/**
|
||||
* The one thing this starter provides: turn a sensitive string into one that is safe to log or return.
|
||||
*
|
||||
* <p>An application that wants different behaviour declares its own {@code Masker} bean and the
|
||||
* auto-configuration backs off (see {@link MaskerAutoConfiguration}).
|
||||
*/
|
||||
public interface Masker {
|
||||
|
||||
String mask(String value);
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.ankurm.masker;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
/**
|
||||
* The starter's main auto-configuration. It is listed in
|
||||
* {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}; that file, not this
|
||||
* annotation, is what makes Boot load it.
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@ConditionalOnProperty(prefix = "masker", name = "enabled", matchIfMissing = true)
|
||||
@EnableConfigurationProperties(MaskerProperties.class)
|
||||
public class MaskerAutoConfiguration {
|
||||
|
||||
/** Backs off when the application defines its own {@link Masker}. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public Masker masker(MaskerProperties properties) {
|
||||
return new TailMasker(properties.replacement(), properties.visibleTail());
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.masker;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Registers {@link MaskerJacksonModule} when Jackson is on the classpath and a {@link Masker} exists.
|
||||
*
|
||||
* <p>{@code @ConditionalOnClass} names {@link JsonMapper} in an annotation, which Spring reads from the class file
|
||||
* without loading it, so this class is safe to have on a classpath that has no Jackson. It must therefore stay a
|
||||
* separate auto-configuration: a nested class or a {@code @Bean} method signature that mentions Jackson would not be.
|
||||
*/
|
||||
@AutoConfiguration(after = MaskerAutoConfiguration.class)
|
||||
@ConditionalOnClass(JsonMapper.class)
|
||||
@ConditionalOnBean(Masker.class)
|
||||
public class MaskerJacksonAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public MaskerJacksonModule maskerJacksonModule(Masker masker) {
|
||||
return new MaskerJacksonModule(masker);
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.masker;
|
||||
|
||||
import tools.jackson.core.JsonGenerator;
|
||||
import tools.jackson.databind.SerializationContext;
|
||||
import tools.jackson.databind.module.SimpleModule;
|
||||
import tools.jackson.databind.ser.std.StdSerializer;
|
||||
|
||||
/** Writes {@link Sensitive} values through a {@link Masker}. Boot registers any {@code JacksonModule} bean itself. */
|
||||
public class MaskerJacksonModule extends SimpleModule {
|
||||
|
||||
public MaskerJacksonModule(Masker masker) {
|
||||
super("masker");
|
||||
addSerializer(Sensitive.class, new StdSerializer<>(Sensitive.class) {
|
||||
@Override
|
||||
public void serialize(Sensitive value, JsonGenerator generator, SerializationContext context) {
|
||||
generator.writeString(masker.mask(value.value()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.ankurm.masker;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
|
||||
/**
|
||||
* Settings for the auto-configured {@link Masker}.
|
||||
*
|
||||
* @param enabled whether the starter configures anything at all
|
||||
* @param replacement the text that stands in for each hidden character
|
||||
* @param visibleTail how many characters at the end of the value stay readable
|
||||
*/
|
||||
@ConfigurationProperties("masker")
|
||||
public record MaskerProperties(
|
||||
@DefaultValue("true") boolean enabled,
|
||||
@DefaultValue("*") String replacement,
|
||||
@DefaultValue("4") int visibleTail) {
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.ankurm.masker;
|
||||
|
||||
/** A value that must never be serialised in clear. With the Jackson integration active it is written masked. */
|
||||
public record Sensitive(String value) {
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.ankurm.masker;
|
||||
|
||||
/** Replaces everything except the last {@code visibleTail} characters. */
|
||||
public class TailMasker implements Masker {
|
||||
|
||||
private final String replacement;
|
||||
private final int visibleTail;
|
||||
|
||||
public TailMasker(String replacement, int visibleTail) {
|
||||
this.replacement = replacement;
|
||||
this.visibleTail = visibleTail;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String mask(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
int hidden = Math.max(0, value.length() - visibleTail);
|
||||
return replacement.repeat(hidden) + value.substring(hidden);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"hints": [
|
||||
{
|
||||
"name": "masker.replacement",
|
||||
"values": [
|
||||
{ "value": "*", "description": "Asterisk (the default)." },
|
||||
{ "value": "#", "description": "Hash sign." },
|
||||
{ "value": "•", "description": "Bullet." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
com.ankurm.masker.MaskerAutoConfiguration
|
||||
com.ankurm.masker.MaskerJacksonAutoConfiguration
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package com.ankurm.consumer;
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/** A consumer: its own package, the starter on the classpath, nothing else. */
|
||||
@SpringBootApplication
|
||||
public class ConsumerApplication {
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.ankurm.masker;
|
||||
|
||||
import com.ankurm.traps.DefaultOffAutoConfiguration;
|
||||
import com.ankurm.traps.EarlyModuleAutoConfiguration;
|
||||
import com.ankurm.traps.Marker;
|
||||
import com.ankurm.traps.OrderedModuleAutoConfiguration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener;
|
||||
import org.springframework.boot.context.annotation.Configurations;
|
||||
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** Correct-looking conditions that are wrong: one runs too early, one defaults to off. */
|
||||
class ConditionTrapTests {
|
||||
|
||||
@Test
|
||||
void aConditionalOnBeanNeedsAnOrderingOrItDependsOnTheClassName() {
|
||||
try (Transcript t = new Transcript("06-conditional-on-bean-needs-an-ordering.txt",
|
||||
"@ConditionalOnBean(JsonMapper) with and without an ordering constraint")) {
|
||||
AutoConfigurations configurations = AutoConfigurations.of(
|
||||
JacksonAutoConfiguration.class, OrderedModuleAutoConfiguration.class,
|
||||
EarlyModuleAutoConfiguration.class, MaskerAutoConfiguration.class);
|
||||
t.section("the order Boot evaluates them in");
|
||||
for (Class<?> c : Configurations.getClasses(configurations)) {
|
||||
t.line("%s", c.getName());
|
||||
}
|
||||
|
||||
new ApplicationContextRunner().withInitializer(new ConditionEvaluationReportLoggingListener())
|
||||
.withConfiguration(configurations).run(context -> {
|
||||
t.section("the beans each one produced");
|
||||
for (Marker marker : context.getBeansOfType(Marker.class).values()) {
|
||||
t.line("Marker bean created by %s", marker.createdBy());
|
||||
}
|
||||
assertThat(context.getBeansOfType(Marker.class).values())
|
||||
.extracting(Marker::createdBy).containsExactly("OrderedModuleAutoConfiguration");
|
||||
t.section("what Boot recorded");
|
||||
ReportSupport.print(t, (ConfigurableApplicationContext) context.getSourceApplicationContext(),
|
||||
"ModuleAutoConfiguration");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPropertyConditionWithoutMatchIfMissingIsOffUntilSomeoneSetsIt() {
|
||||
try (Transcript t = new Transcript("16-conditional-on-property-without-match-if-missing.txt",
|
||||
"@ConditionalOnProperty(prefix = \"traps\", name = \"enabled\") with no matchIfMissing")) {
|
||||
var runner = new ApplicationContextRunner().withInitializer(new ConditionEvaluationReportLoggingListener())
|
||||
.withConfiguration(AutoConfigurations.of(DefaultOffAutoConfiguration.class));
|
||||
t.section("no property set");
|
||||
runner.run(context -> {
|
||||
t.line("Marker beans : %d", context.getBeansOfType(Marker.class).size());
|
||||
assertThat(context).doesNotHaveBean(Marker.class);
|
||||
ReportSupport.print(t, (ConfigurableApplicationContext) context.getSourceApplicationContext(),
|
||||
"DefaultOffAutoConfiguration");
|
||||
});
|
||||
t.section("traps.enabled=true");
|
||||
runner.withPropertyValues("traps.enabled=true").run(context -> {
|
||||
t.line("Marker beans : %d", context.getBeansOfType(Marker.class).size());
|
||||
assertThat(context).hasSingleBean(Marker.class);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.ankurm.masker;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import com.ankurm.consumer.ConsumerApplication;
|
||||
import com.ankurm.masker.scanapp.ForgottenAutoConfiguration;
|
||||
import com.ankurm.masker.scanapp.OwnersApplication;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.Banner;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.context.annotation.ImportCandidates;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** An auto-configuration missing from the imports file passes its own tests and does nothing in any application. */
|
||||
class ForgottenImportsEntryTests {
|
||||
|
||||
private static ConfigurableApplicationContext start(Class<?> application) {
|
||||
return new SpringApplicationBuilder(application).web(WebApplicationType.NONE).bannerMode(Banner.Mode.OFF)
|
||||
.properties("logging.level.root=WARN").run();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRunnerTestNamesTheClassAndSoItPassesWhateverTheImportsFileSays() {
|
||||
try (Transcript t = new Transcript("08-auto-configuration-missing-from-the-imports-file.txt",
|
||||
"An auto-configuration that is not listed in AutoConfiguration.imports (it is listed in spring.factories, which Boot 4 ignores for this)")) {
|
||||
t.section("ApplicationContextRunner: the test names the class itself");
|
||||
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(ForgottenAutoConfiguration.class))
|
||||
.run(context -> {
|
||||
t.line("forgottenMarker bean : %s", context.containsBean("forgottenMarker"));
|
||||
assertThat(context).hasBean("forgottenMarker");
|
||||
});
|
||||
|
||||
t.section("the starter author's application: @SpringBootApplication scanning the package that holds both classes");
|
||||
try (ConfigurableApplicationContext context = start(OwnersApplication.class)) {
|
||||
t.line("forgottenMarker (@AutoConfiguration) : %s", context.containsBean("forgottenMarker"));
|
||||
t.line("forgottenPlainMarker (@Configuration) : %s", context.containsBean("forgottenPlainMarker"));
|
||||
assertThat(context.containsBean("forgottenMarker")).isFalse();
|
||||
assertThat(context.containsBean("forgottenPlainMarker")).isTrue();
|
||||
}
|
||||
|
||||
t.section("a consumer application: its own package, the starter on the classpath");
|
||||
try (ConfigurableApplicationContext context = start(ConsumerApplication.class)) {
|
||||
t.line("forgottenMarker (@AutoConfiguration) : %s", context.containsBean("forgottenMarker"));
|
||||
t.line("forgottenPlainMarker (@Configuration) : %s", context.containsBean("forgottenPlainMarker"));
|
||||
t.line("Masker beans : %s",
|
||||
Arrays.toString(context.getBeanNamesForType(Masker.class)));
|
||||
assertThat(context.containsBean("forgottenMarker")).isFalse();
|
||||
assertThat(context.containsBean("forgottenPlainMarker")).isFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTestThatReadsTheImportsFileCatchesTheOmission() {
|
||||
try (Transcript t = new Transcript("09-reading-the-imports-file-in-a-test.txt",
|
||||
"ImportCandidates: what Boot itself reads from META-INF/spring/...AutoConfiguration.imports")) {
|
||||
var candidates = ImportCandidates.load(AutoConfiguration.class, getClass().getClassLoader()).getCandidates();
|
||||
var ours = candidates.stream().filter(c -> c.startsWith("com.ankurm.masker.")).toList();
|
||||
t.line("entries under com.ankurm.masker: %d", ours.size());
|
||||
ours.forEach(c -> t.line(" %s", c));
|
||||
t.line("ForgottenAutoConfiguration listed: %s", candidates.contains(ForgottenAutoConfiguration.class.getName()));
|
||||
assertThat(ours).containsExactlyInAnyOrder(MaskerAutoConfiguration.class.getName(),
|
||||
MaskerJacksonAutoConfiguration.class.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.ankurm.masker;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
|
||||
import org.springframework.boot.test.context.FilteredClassLoader;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** The optional integration: present with Jackson, silently absent without it, and the order that matters. */
|
||||
class JacksonIntegrationTests {
|
||||
|
||||
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withInitializer(new ConditionEvaluationReportLoggingListener())
|
||||
.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class,
|
||||
MaskerAutoConfiguration.class, MaskerJacksonAutoConfiguration.class));
|
||||
|
||||
@Test
|
||||
void withJacksonASensitiveValueIsWrittenMasked() {
|
||||
try (Transcript t = new Transcript("04-jackson-integration-active.txt",
|
||||
"Jackson on the classpath: the module is registered and Sensitive is written masked")) {
|
||||
runner.run(context -> {
|
||||
assertThat(context).hasSingleBean(MaskerJacksonModule.class);
|
||||
String json = context.getBean(JsonMapper.class)
|
||||
.writeValueAsString(Map.of("card", new Sensitive("4111111111111111")));
|
||||
assertThat(json).isEqualTo("{\"card\":\"************1111\"}");
|
||||
t.line("module beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerJacksonModule.class)));
|
||||
t.line("JSON : %s", json);
|
||||
t.section("what Boot recorded for the Jackson auto-configuration");
|
||||
ReportSupport.print(t, (ConfigurableApplicationContext) context.getSourceApplicationContext(),
|
||||
"MaskerJacksonAutoConfiguration");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void withoutJacksonTheStarterStillStartsAndSaysWhy() {
|
||||
try (Transcript t = new Transcript("05-jackson-integration-absent-without-error.txt",
|
||||
"Jackson hidden with FilteredClassLoader: nothing fails, the integration just is not there")) {
|
||||
runner.withClassLoader(new FilteredClassLoader("tools.jackson")).run(context -> {
|
||||
assertThat(context).hasNotFailed().hasSingleBean(Masker.class).doesNotHaveBean(MaskerJacksonModule.class);
|
||||
t.line("context failed : %s", context.getStartupFailure() != null);
|
||||
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
|
||||
t.line("module beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerJacksonModule.class)));
|
||||
t.section("what Boot recorded for the Jackson auto-configuration");
|
||||
ReportSupport.print(t, (ConfigurableApplicationContext) context.getSourceApplicationContext(),
|
||||
"MaskerJacksonAutoConfiguration");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.ankurm.masker;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.catchThrowable;
|
||||
|
||||
/** The auto-configuration exercised with {@link ApplicationContextRunner}: no application, no server, a few milliseconds. */
|
||||
class MaskerAutoConfigurationTests {
|
||||
|
||||
private static final String CARD = "4111111111111111";
|
||||
|
||||
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(MaskerAutoConfiguration.class));
|
||||
|
||||
/** A user configuration. Deliberately not annotated: the runner registers whatever class it is given. */
|
||||
static class CustomMasker {
|
||||
@Bean
|
||||
Masker customMasker() {
|
||||
return value -> "<redacted>";
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaults() {
|
||||
try (Transcript t = new Transcript("01-defaults-without-any-configuration.txt",
|
||||
"The auto-configuration with no properties and no user beans")) {
|
||||
runner.run(context -> {
|
||||
assertThat(context).hasSingleBean(Masker.class).hasSingleBean(MaskerProperties.class);
|
||||
MaskerProperties properties = context.getBean(MaskerProperties.class);
|
||||
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
|
||||
t.line("Masker implementation : %s", context.getBean(Masker.class).getClass().getSimpleName());
|
||||
t.line("defaults bound : enabled=%s replacement='%s' visibleTail=%d",
|
||||
properties.enabled(), properties.replacement(), properties.visibleTail());
|
||||
t.line("mask(\"%s\") = %s", CARD, context.getBean(Masker.class).mask(CARD));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void propertiesChangeTheBehaviourAndEnabledFalseRemovesEverything() {
|
||||
try (Transcript t = new Transcript("02-properties-and-the-off-switch.txt",
|
||||
"masker.* properties, and masker.enabled=false")) {
|
||||
t.section("masker.replacement=# masker.visible-tail=2");
|
||||
runner.withPropertyValues("masker.replacement=#", "masker.visible-tail=2").run(context -> {
|
||||
assertThat(context.getBean(Masker.class).mask(CARD)).isEqualTo("##############11");
|
||||
t.line("mask(\"%s\") = %s", CARD, context.getBean(Masker.class).mask(CARD));
|
||||
});
|
||||
|
||||
t.section("masker.enabled=false");
|
||||
runner.withPropertyValues("masker.enabled=false").run(context -> {
|
||||
assertThat(context).doesNotHaveBean(Masker.class).doesNotHaveBean(MaskerProperties.class);
|
||||
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
|
||||
t.line("MaskerProperties beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerProperties.class)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aUserBeanMakesTheStarterBackOffOnlyWhenItIsRegisteredFirst() {
|
||||
try (Transcript t = new Transcript("03-conditional-on-missing-bean-and-registration-order.txt",
|
||||
"@ConditionalOnMissingBean: backing off, and the order that decides it")) {
|
||||
t.section("user configuration + auto-configuration (the way Boot loads them)");
|
||||
runner.withUserConfiguration(CustomMasker.class).run(context -> {
|
||||
assertThat(context).hasSingleBean(Masker.class);
|
||||
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
|
||||
t.line("getBean(Masker.class) : mask -> %s", context.getBean(Masker.class).mask(CARD));
|
||||
});
|
||||
|
||||
t.section("the same two classes registered as ordinary configuration, starter first");
|
||||
new ApplicationContextRunner().withUserConfiguration(MaskerAutoConfiguration.class, CustomMasker.class)
|
||||
.run(context -> {
|
||||
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
|
||||
Throwable failure = catchThrowable(() -> context.getBean(Masker.class));
|
||||
assertThat(failure).isInstanceOf(NoUniqueBeanDefinitionException.class);
|
||||
t.line("getBean(Masker.class) : %s", failure.getClass().getSimpleName());
|
||||
});
|
||||
|
||||
t.section("the same two classes, user configuration first");
|
||||
new ApplicationContextRunner().withUserConfiguration(CustomMasker.class, MaskerAutoConfiguration.class)
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(Masker.class);
|
||||
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.ankurm.masker;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
/** Prints the condition outcomes Boot recorded for the classes whose name contains a filter, as {@code --debug} would. */
|
||||
final class ReportSupport {
|
||||
|
||||
private ReportSupport() {
|
||||
}
|
||||
|
||||
static void print(Transcript t, ConfigurableApplicationContext context, String filter) {
|
||||
ConditionEvaluationReport report = ConditionEvaluationReport.get(context.getBeanFactory());
|
||||
report.getConditionAndOutcomesBySource().entrySet().stream()
|
||||
.filter(e -> e.getKey().contains(filter))
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.forEach(e -> {
|
||||
t.line("%s [%s]", e.getKey(), e.getValue().isFullMatch() ? "matched" : "not matched");
|
||||
e.getValue().forEach(o -> t.line(" %s %s",
|
||||
o.getOutcome().isMatch() ? "matched:" : "did not match:", o.getOutcome().getMessage()));
|
||||
});
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.ankurm.masker;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Writes a numbered transcript under {@code custom-starter/output/} and echoes it to the console.
|
||||
* Every console block quoted in the article comes out of one of these files verbatim.
|
||||
*/
|
||||
public final class Transcript implements AutoCloseable {
|
||||
|
||||
private static final Path ROOT =
|
||||
Path.of(System.getProperty("basedir", ".")).toAbsolutePath().normalize().getParent();
|
||||
|
||||
private final Path path;
|
||||
private final StringWriter buffer = new StringWriter();
|
||||
private final PrintWriter out = new PrintWriter(buffer);
|
||||
|
||||
public Transcript(String fileName, String title) {
|
||||
this.path = ROOT.resolve("output").resolve(fileName);
|
||||
out.println("# " + title);
|
||||
out.println();
|
||||
}
|
||||
|
||||
public Transcript line(String format, Object... args) {
|
||||
out.println(args.length == 0 ? format : String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript section(String heading) {
|
||||
out.println();
|
||||
out.println("--- " + heading + " ---");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
out.flush();
|
||||
// Absolute paths of whoever ran the build are environment noise, not a finding.
|
||||
String text = buffer.toString().replace(ROOT.toString(), "<custom-starter>");
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, text);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(text);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.masker.scanapp;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
/**
|
||||
* An auto-configuration whose author never added it to {@code AutoConfiguration.imports}.
|
||||
* It looks finished: annotated, conditional-free, and it compiles.
|
||||
*/
|
||||
@AutoConfiguration
|
||||
public class ForgottenAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
String forgottenMarker() {
|
||||
return "created by ForgottenAutoConfiguration";
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.ankurm.masker.scanapp;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/** The other way to forget: a plain {@code @Configuration}, also missing from the imports file. */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public class ForgottenPlainConfiguration {
|
||||
|
||||
@Bean
|
||||
String forgottenPlainMarker() {
|
||||
return "created by ForgottenPlainConfiguration";
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.ankurm.masker.scanapp;
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* The starter author's own test application. It scans {@code com.ankurm.masker}, which is the starter's package,
|
||||
* so it finds classes a real consumer's scan never will.
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = "com.ankurm.masker.scanapp")
|
||||
public class OwnersApplication {
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.traps;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
/** {@code @ConditionalOnProperty} without {@code matchIfMissing}: the property has to be set for anything to happen. */
|
||||
@AutoConfiguration
|
||||
@ConditionalOnProperty(prefix = "traps", name = "enabled")
|
||||
public class DefaultOffAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
Marker defaultOffMarker() {
|
||||
return new Marker("DefaultOffAutoConfiguration");
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.ankurm.traps;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* Conditional on a bean that another auto-configuration creates, and says nothing about ordering.
|
||||
* Its name sorts before Boot's, so it is evaluated first.
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@ConditionalOnBean(JsonMapper.class)
|
||||
public class EarlyModuleAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
Marker earlyMarker() {
|
||||
return new Marker("EarlyModuleAutoConfiguration");
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package com.ankurm.traps;
|
||||
|
||||
/** A bean that only says which auto-configuration created it. */
|
||||
public record Marker(String createdBy) {
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.traps;
|
||||
|
||||
import com.ankurm.masker.Masker;
|
||||
import com.ankurm.masker.MaskerJacksonModule;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* The condition is on the {@code @Bean} method, but the method's return type needs Jackson to be loaded at all.
|
||||
* Compare with {@code MaskerJacksonAutoConfiguration}, where the whole class carries the condition.
|
||||
*/
|
||||
@AutoConfiguration
|
||||
public class MethodLevelConditionAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnClass(JsonMapper.class)
|
||||
MaskerJacksonModule methodLevelModule(Masker masker) {
|
||||
return new MaskerJacksonModule(masker);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.ankurm.traps;
|
||||
|
||||
import com.ankurm.masker.MaskerAutoConfiguration;
|
||||
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
|
||||
/**
|
||||
* Started by {@code scripts/capture-jar-facts.sh}, on classpaths with and without Jackson. A test cannot show this:
|
||||
* the classes under test are already loaded by a loader that can see Jackson, whatever a filtered loader hides.
|
||||
*
|
||||
* <p>The argument picks the auto-configuration: {@code method} (condition on the {@code @Bean} method) or
|
||||
* {@code nested} (condition on a nested class).
|
||||
*/
|
||||
public final class MethodLevelMain {
|
||||
|
||||
public static void main(String[] args) {
|
||||
boolean nested = args.length > 0 && args[0].equals("nested");
|
||||
Class<?> configuration = nested ? NestedConditionAutoConfiguration.class : MethodLevelConditionAutoConfiguration.class;
|
||||
String beanName = nested ? "nestedModule" : "methodLevelModule";
|
||||
try (var context = new AnnotationConfigApplicationContext()) {
|
||||
context.register(MaskerAutoConfiguration.class, configuration);
|
||||
context.refresh();
|
||||
System.out.println("context started; " + beanName + " bean present: " + context.containsBean(beanName));
|
||||
} catch (Throwable failure) {
|
||||
StringBuilder chain = new StringBuilder();
|
||||
Throwable root = failure;
|
||||
for (Throwable t = failure; t != null; t = t.getCause()) {
|
||||
chain.append(chain.isEmpty() ? "" : " -> ").append(t.getClass().getSimpleName());
|
||||
root = t;
|
||||
}
|
||||
System.out.println("context failed");
|
||||
System.out.println("exception chain: " + chain);
|
||||
System.out.println("root cause : " + root.getClass().getName() + ": " + root.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.ankurm.traps;
|
||||
|
||||
import com.ankurm.masker.Masker;
|
||||
import com.ankurm.masker.MaskerJacksonModule;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/** The repair: the condition sits on a nested class, so the outer class never has a Jackson type in any signature. */
|
||||
@AutoConfiguration
|
||||
public class NestedConditionAutoConfiguration {
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnClass(JsonMapper.class)
|
||||
static class JacksonConfiguration {
|
||||
|
||||
@Bean
|
||||
MaskerJacksonModule nestedModule(Masker masker) {
|
||||
return new MaskerJacksonModule(masker);
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.traps;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/** The same condition, with the ordering stated. {@code afterName} needs no compile-time dependency on Boot's Jackson module. */
|
||||
@AutoConfiguration(afterName = "org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration")
|
||||
@ConditionalOnBean(JsonMapper.class)
|
||||
public class OrderedModuleAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
Marker orderedMarker() {
|
||||
return new Marker("OrderedModuleAutoConfiguration");
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
# The pre-Boot-2.7 way to register an auto-configuration. ForgottenImportsEntryTests shows that Boot 4 ignores it.
|
||||
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.ankurm.masker.scanapp.ForgottenAutoConfiguration
|
||||
Reference in New Issue
Block a user