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:
Claude
2026-09-24 06:53:07 +00:00
parent d6a2e1a5f2
commit c418589251
57 changed files with 1590 additions and 0 deletions
@@ -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 {
}
@@ -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);
}
@@ -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());
}
}
@@ -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);
}
}
@@ -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()));
}
});
}
}
@@ -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) {
}
@@ -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) {
}
@@ -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);
}
}
@@ -0,0 +1,12 @@
{
"hints": [
{
"name": "masker.replacement",
"values": [
{ "value": "*", "description": "Asterisk (the default)." },
{ "value": "#", "description": "Hash sign." },
{ "value": "•", "description": "Bullet." }
]
}
]
}
@@ -0,0 +1,2 @@
com.ankurm.masker.MaskerAutoConfiguration
com.ankurm.masker.MaskerJacksonAutoConfiguration
@@ -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 {
}
@@ -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);
});
}
}
}
@@ -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());
}
}
}
@@ -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");
});
}
}
}
@@ -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)));
});
}
}
}
@@ -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()));
});
}
}
@@ -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);
}
}
@@ -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";
}
}
@@ -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";
}
}
@@ -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 {
}
@@ -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");
}
}
@@ -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");
}
}
@@ -0,0 +1,5 @@
package com.ankurm.traps;
/** A bean that only says which auto-configuration created it. */
public record Marker(String createdBy) {
}
@@ -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);
}
}
@@ -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());
}
}
}
@@ -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);
}
}
}
@@ -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");
}
}
@@ -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