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
+33
View File
@@ -0,0 +1,33 @@
<?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-demo</artifactId>
<name>masker-demo</name>
<description>An ordinary application that adds the starter and nothing else</description>
<dependencies>
<dependency>
<groupId>com.ankurm.masker</groupId>
<artifactId>masker-spring-boot-starter</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,7 @@
package com.ankurm.demo;
import com.ankurm.masker.Sensitive;
/** An application type that carries a value which must not reach a log or a response in clear. */
public record Customer(String name, Sensitive card) {
}
@@ -0,0 +1,16 @@
package com.ankurm.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* An ordinary application. Its only knowledge of the masker is the one dependency in its pom:
* no {@code @Import}, no {@code @Bean}, no {@code @ComponentScan} mentioning the starter.
*/
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
@@ -0,0 +1,68 @@
package com.ankurm.demo;
import java.util.Arrays;
import com.ankurm.masker.Masker;
import com.ankurm.masker.MaskerJacksonModule;
import com.ankurm.masker.MaskerProperties;
import com.ankurm.masker.Sensitive;
import org.junit.jupiter.api.Test;
import org.springframework.boot.Banner;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;
import tools.jackson.databind.json.JsonMapper;
import static org.assertj.core.api.Assertions.assertThat;
/** The consumer's view: a real application started with SpringApplication, the starter reached only through its pom. */
class DemoApplicationTests {
private static ConfigurableApplicationContext start(String... args) {
return new SpringApplicationBuilder(DemoApplication.class).web(WebApplicationType.NONE)
.bannerMode(Banner.Mode.OFF).properties("logging.level.root=WARN").run(args);
}
@Test
void addingTheDependencyIsEnough() {
try (Transcript t = new Transcript("10-demo-app-with-only-the-starter.txt",
"An application that adds masker-spring-boot-starter and nothing else");
ConfigurableApplicationContext context = start()) {
assertThat(context).isNotNull();
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
t.line("MaskerProperties beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerProperties.class)));
t.line("Jackson module beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerJacksonModule.class)));
String json = context.getBean(JsonMapper.class).writeValueAsString(
new Customer("Asha", new Sensitive("4111111111111111")));
t.line("JSON of a Customer : %s", json);
assertThat(json).isEqualTo("{\"name\":\"Asha\",\"card\":\"************1111\"}");
t.section("what Boot recorded (the same lines --debug prints)");
ReportSupport.print(t, context, "com.ankurm.masker.Masker");
}
}
@Test
void propertiesAndTheOffSwitchWorkFromTheApplication() {
try (Transcript t = new Transcript("11-demo-app-properties-and-off-switch.txt",
"The same application configured with masker.* properties, then switched off")) {
t.section("--masker.replacement=# --masker.visible-tail=6");
try (ConfigurableApplicationContext context = start("--masker.replacement=#", "--masker.visible-tail=6")) {
String json = context.getBean(JsonMapper.class).writeValueAsString(
new Customer("Asha", new Sensitive("4111111111111111")));
t.line("JSON of a Customer : %s", json);
assertThat(json).contains("##########111111");
}
t.section("--masker.enabled=false");
try (ConfigurableApplicationContext context = start("--masker.enabled=false")) {
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
t.line("Jackson module beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerJacksonModule.class)));
assertThat(context).isNotNull();
assertThat(context.getBeanNamesForType(Masker.class)).isEmpty();
t.section("what Boot recorded");
ReportSupport.print(t, context, "com.ankurm.masker.Masker");
}
}
}
}
@@ -0,0 +1,25 @@
package com.ankurm.demo;
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.demo;
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);
}
}