From c41858925175b56b531f8fbebc9c6b734208fc16 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 06:53:07 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1 --- custom-starter/README.md | 60 ++++++++ custom-starter/masker-demo/pom.xml | 33 +++++ .../main/java/com/ankurm/demo/Customer.java | 7 + .../java/com/ankurm/demo/DemoApplication.java | 16 +++ .../com/ankurm/demo/DemoApplicationTests.java | 68 +++++++++ .../java/com/ankurm/demo/ReportSupport.java | 25 ++++ .../test/java/com/ankurm/demo/Transcript.java | 52 +++++++ .../masker-spring-boot-autoconfigure/pom.xml | 58 ++++++++ .../src/broken/OldJacksonImport.java | 9 ++ .../main/java/com/ankurm/masker/Masker.java | 12 ++ .../masker/MaskerAutoConfiguration.java | 25 ++++ .../MaskerJacksonAutoConfiguration.java | 27 ++++ .../ankurm/masker/MaskerJacksonModule.java | 20 +++ .../com/ankurm/masker/MaskerProperties.java | 18 +++ .../java/com/ankurm/masker/Sensitive.java | 5 + .../java/com/ankurm/masker/TailMasker.java | 22 +++ ...itional-spring-configuration-metadata.json | 12 ++ ...ot.autoconfigure.AutoConfiguration.imports | 2 + .../ankurm/consumer/ConsumerApplication.java | 8 ++ .../com/ankurm/masker/ConditionTrapTests.java | 68 +++++++++ .../masker/ForgottenImportsEntryTests.java | 73 ++++++++++ .../masker/JacksonIntegrationTests.java | 58 ++++++++ .../masker/MaskerAutoConfigurationTests.java | 93 ++++++++++++ .../java/com/ankurm/masker/ReportSupport.java | 25 ++++ .../java/com/ankurm/masker/Transcript.java | 52 +++++++ .../scanapp/ForgottenAutoConfiguration.java | 17 +++ .../scanapp/ForgottenPlainConfiguration.java | 14 ++ .../masker/scanapp/OwnersApplication.java | 11 ++ .../traps/DefaultOffAutoConfiguration.java | 16 +++ .../traps/EarlyModuleAutoConfiguration.java | 20 +++ .../test/java/com/ankurm/traps/Marker.java | 5 + ...MethodLevelConditionAutoConfiguration.java | 23 +++ .../com/ankurm/traps/MethodLevelMain.java | 36 +++++ .../NestedConditionAutoConfiguration.java | 25 ++++ .../traps/OrderedModuleAutoConfiguration.java | 17 +++ .../test/resources/META-INF/spring.factories | 2 + .../masker-spring-boot-starter/pom.xml | 28 ++++ .../01-defaults-without-any-configuration.txt | 6 + .../02-properties-and-the-off-switch.txt | 9 ++ ...on-missing-bean-and-registration-order.txt | 13 ++ .../output/04-jackson-integration-active.txt | 11 ++ ...ckson-integration-absent-without-error.txt | 9 ++ ...-conditional-on-bean-needs-an-ordering.txt | 17 +++ ...-conditional-on-class-on-a-bean-method.txt | 12 ++ ...guration-missing-from-the-imports-file.txt | 14 ++ .../09-reading-the-imports-file-in-a-test.txt | 6 + .../10-demo-app-with-only-the-starter.txt | 17 +++ .../11-demo-app-properties-and-off-switch.txt | 16 +++ .../12-jar-contents-and-dependency-trees.txt | 74 ++++++++++ .../13-configuration-metadata-in-the-jar.txt | 80 +++++++++++ .../14-boot4-autoconfiguration-locations.txt | 30 ++++ .../15-annotation-processor-version-trap.txt | 3 + ...l-on-property-without-match-if-missing.txt | 10 ++ .../output/17-where-the-classes-live.txt | 15 ++ custom-starter/pom.xml | 30 ++++ custom-starter/scripts/capture-jar-facts.sh | 136 ++++++++++++++++++ custom-starter/scripts/run-all.sh | 20 +++ 57 files changed, 1590 insertions(+) create mode 100644 custom-starter/README.md create mode 100644 custom-starter/masker-demo/pom.xml create mode 100644 custom-starter/masker-demo/src/main/java/com/ankurm/demo/Customer.java create mode 100644 custom-starter/masker-demo/src/main/java/com/ankurm/demo/DemoApplication.java create mode 100644 custom-starter/masker-demo/src/test/java/com/ankurm/demo/DemoApplicationTests.java create mode 100644 custom-starter/masker-demo/src/test/java/com/ankurm/demo/ReportSupport.java create mode 100644 custom-starter/masker-demo/src/test/java/com/ankurm/demo/Transcript.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/pom.xml create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/broken/OldJacksonImport.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/Masker.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerAutoConfiguration.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerJacksonAutoConfiguration.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerJacksonModule.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerProperties.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/Sensitive.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/TailMasker.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/consumer/ConsumerApplication.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/ConditionTrapTests.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/ForgottenImportsEntryTests.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/JacksonIntegrationTests.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/MaskerAutoConfigurationTests.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/ReportSupport.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/Transcript.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/scanapp/ForgottenAutoConfiguration.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/scanapp/ForgottenPlainConfiguration.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/scanapp/OwnersApplication.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/DefaultOffAutoConfiguration.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/EarlyModuleAutoConfiguration.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/Marker.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/MethodLevelConditionAutoConfiguration.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/MethodLevelMain.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/NestedConditionAutoConfiguration.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/OrderedModuleAutoConfiguration.java create mode 100644 custom-starter/masker-spring-boot-autoconfigure/src/test/resources/META-INF/spring.factories create mode 100644 custom-starter/masker-spring-boot-starter/pom.xml create mode 100644 custom-starter/output/01-defaults-without-any-configuration.txt create mode 100644 custom-starter/output/02-properties-and-the-off-switch.txt create mode 100644 custom-starter/output/03-conditional-on-missing-bean-and-registration-order.txt create mode 100644 custom-starter/output/04-jackson-integration-active.txt create mode 100644 custom-starter/output/05-jackson-integration-absent-without-error.txt create mode 100644 custom-starter/output/06-conditional-on-bean-needs-an-ordering.txt create mode 100644 custom-starter/output/07-conditional-on-class-on-a-bean-method.txt create mode 100644 custom-starter/output/08-auto-configuration-missing-from-the-imports-file.txt create mode 100644 custom-starter/output/09-reading-the-imports-file-in-a-test.txt create mode 100644 custom-starter/output/10-demo-app-with-only-the-starter.txt create mode 100644 custom-starter/output/11-demo-app-properties-and-off-switch.txt create mode 100644 custom-starter/output/12-jar-contents-and-dependency-trees.txt create mode 100644 custom-starter/output/13-configuration-metadata-in-the-jar.txt create mode 100644 custom-starter/output/14-boot4-autoconfiguration-locations.txt create mode 100644 custom-starter/output/15-annotation-processor-version-trap.txt create mode 100644 custom-starter/output/16-conditional-on-property-without-match-if-missing.txt create mode 100644 custom-starter/output/17-where-the-classes-live.txt create mode 100644 custom-starter/pom.xml create mode 100755 custom-starter/scripts/capture-jar-facts.sh create mode 100755 custom-starter/scripts/run-all.sh diff --git a/custom-starter/README.md b/custom-starter/README.md new file mode 100644 index 0000000..7539bb0 --- /dev/null +++ b/custom-starter/README.md @@ -0,0 +1,60 @@ +# custom-starter + +A Spring Boot 4 starter written from scratch, and the application that uses it. Companion to +[Write Your Own Spring Boot 4 Starter: Auto-Configuration, @Conditional and Properties](https://ankurm.com/write-your-own-spring-boot-4-starter-auto-configuration-conditional-properties/). + +The starter provides one thing, a `Masker` that hides all but the last few characters of a value, plus an optional +Jackson integration that writes a `Sensitive` value masked. The subject is the packaging, not the masking. + +| | | +|---|---| +| Spring Boot | 4.1.1 (parent pom, published 20 Aug 2026) | +| Spring Framework | 7.0.9 | +| Java | 25 | +| Maven | 3.9 | + +There is **no `docs/` folder**. The explanations live in the article, in collapsible sections next to the paragraph +each one extends, and every console block in the article is quoted from a file in [`output/`](output). + +## Layout + +| Module | What it is | +|---|---| +| [`masker-spring-boot-autoconfigure/`](masker-spring-boot-autoconfigure) | `MaskerAutoConfiguration`, `MaskerJacksonAutoConfiguration`, `MaskerProperties`, the `AutoConfiguration.imports` file, the configuration metadata, and the tests | +| [`masker-spring-boot-starter/`](masker-spring-boot-starter) | The dependency an application adds. No code, only a pom | +| [`masker-demo/`](masker-demo) | An application that adds the starter and nothing else | + +`masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/` holds auto-configurations that are wrong on +purpose. `src/broken/` holds a source file that is made not to compile on purpose. Neither is part of the shipped jar. + +## Run it + +```bash +export JAVA_HOME=/path/to/jdk-25 +./scripts/run-all.sh # tests, then the jar and failure captures: regenerates all of output/ +mvn -B test # only the tests +``` + +## Captured output + +| File | What it shows | Made by | +|---|---|---| +| [`01`](output/01-defaults-without-any-configuration.txt) | the auto-configuration with no properties and no user beans | `MaskerAutoConfigurationTests` | +| [`02`](output/02-properties-and-the-off-switch.txt) | `masker.*` properties and `masker.enabled=false` | `MaskerAutoConfigurationTests` | +| [`03`](output/03-conditional-on-missing-bean-and-registration-order.txt) | `@ConditionalOnMissingBean` backing off, and the registration order that decides it | `MaskerAutoConfigurationTests` | +| [`04`](output/04-jackson-integration-active.txt) | the Jackson module registered, a `Sensitive` written masked, the recorded condition outcomes | `JacksonIntegrationTests` | +| [`05`](output/05-jackson-integration-absent-without-error.txt) | Jackson hidden with `FilteredClassLoader`: no failure, no module | `JacksonIntegrationTests` | +| [`06`](output/06-conditional-on-bean-needs-an-ordering.txt) | `@ConditionalOnBean` with and without an ordering constraint, and the sorted order | `ConditionTrapTests` | +| [`07`](output/07-conditional-on-class-on-a-bean-method.txt) | `@ConditionalOnClass` on a `@Bean` method, on a classpath with no Jackson | `capture-jar-facts.sh` | +| [`08`](output/08-auto-configuration-missing-from-the-imports-file.txt) | an auto-configuration missing from the imports file: passes a runner test, absent everywhere | `ForgottenImportsEntryTests` | +| [`09`](output/09-reading-the-imports-file-in-a-test.txt) | `ImportCandidates`, the guard test for the omission | `ForgottenImportsEntryTests` | +| [`10`](output/10-demo-app-with-only-the-starter.txt) | an application with only the starter dependency, and Boot's recorded conditions | `DemoApplicationTests` | +| [`11`](output/11-demo-app-properties-and-off-switch.txt) | the same application with properties, then switched off | `DemoApplicationTests` | +| [`12`](output/12-jar-contents-and-dependency-trees.txt) | what is in the two jars, and what the starter pulls in | `capture-jar-facts.sh` | +| [`13`](output/13-configuration-metadata-in-the-jar.txt) | the generated and the hand-written configuration metadata, and the auto-configuration conditions metadata | `capture-jar-facts.sh` | +| [`14`](output/14-boot4-autoconfiguration-locations.txt) | Boot 4's split of `spring-boot-autoconfigure`, and the Boot 3 import that no longer compiles | `capture-jar-facts.sh` | +| [`15`](output/15-annotation-processor-version-trap.txt) | the processor path failing when the version is taken from your own parent | `capture-jar-facts.sh` | +| [`17`](output/17-where-the-classes-live.txt) | which Boot 4.1.1 jar holds each class this starter imports | `capture-jar-facts.sh` | +| [`16`](output/16-conditional-on-property-without-match-if-missing.txt) | `@ConditionalOnProperty` without `matchIfMissing` | `ConditionTrapTests` | + +Transcripts are deterministic; only build-directory paths are rewritten, to ``. diff --git a/custom-starter/masker-demo/pom.xml b/custom-starter/masker-demo/pom.xml new file mode 100644 index 0000000..7f8a35e --- /dev/null +++ b/custom-starter/masker-demo/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + + com.ankurm.masker + masker-parent + 1.0.0 + + + masker-demo + masker-demo + An ordinary application that adds the starter and nothing else + + + + com.ankurm.masker + masker-spring-boot-starter + ${project.version} + + + org.springframework.boot + spring-boot-starter-jackson + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/custom-starter/masker-demo/src/main/java/com/ankurm/demo/Customer.java b/custom-starter/masker-demo/src/main/java/com/ankurm/demo/Customer.java new file mode 100644 index 0000000..c0f326f --- /dev/null +++ b/custom-starter/masker-demo/src/main/java/com/ankurm/demo/Customer.java @@ -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) { +} diff --git a/custom-starter/masker-demo/src/main/java/com/ankurm/demo/DemoApplication.java b/custom-starter/masker-demo/src/main/java/com/ankurm/demo/DemoApplication.java new file mode 100644 index 0000000..d8a6bb0 --- /dev/null +++ b/custom-starter/masker-demo/src/main/java/com/ankurm/demo/DemoApplication.java @@ -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); + } +} diff --git a/custom-starter/masker-demo/src/test/java/com/ankurm/demo/DemoApplicationTests.java b/custom-starter/masker-demo/src/test/java/com/ankurm/demo/DemoApplicationTests.java new file mode 100644 index 0000000..40488fb --- /dev/null +++ b/custom-starter/masker-demo/src/test/java/com/ankurm/demo/DemoApplicationTests.java @@ -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"); + } + } + } +} diff --git a/custom-starter/masker-demo/src/test/java/com/ankurm/demo/ReportSupport.java b/custom-starter/masker-demo/src/test/java/com/ankurm/demo/ReportSupport.java new file mode 100644 index 0000000..0fc2722 --- /dev/null +++ b/custom-starter/masker-demo/src/test/java/com/ankurm/demo/ReportSupport.java @@ -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())); + }); + } +} diff --git a/custom-starter/masker-demo/src/test/java/com/ankurm/demo/Transcript.java b/custom-starter/masker-demo/src/test/java/com/ankurm/demo/Transcript.java new file mode 100644 index 0000000..3d6c12f --- /dev/null +++ b/custom-starter/masker-demo/src/test/java/com/ankurm/demo/Transcript.java @@ -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(), ""); + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, text); + } catch (IOException e) { + throw new IllegalStateException("could not write " + path, e); + } + System.out.print(text); + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/pom.xml b/custom-starter/masker-spring-boot-autoconfigure/pom.xml new file mode 100644 index 0000000..811ed77 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + + com.ankurm.masker + masker-parent + 1.0.0 + + + masker-spring-boot-autoconfigure + masker-spring-boot-autoconfigure + The auto-configuration, the properties and the two optional integrations + + + + + org.springframework.boot + spring-boot-autoconfigure + + + + + org.springframework.boot + spring-boot-starter-jackson + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.springframework.boot + spring-boot-configuration-processor + + + org.springframework.boot + spring-boot-autoconfigure-processor + + + + + + + diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/broken/OldJacksonImport.java b/custom-starter/masker-spring-boot-autoconfigure/src/broken/OldJacksonImport.java new file mode 100644 index 0000000..8739d7e --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/broken/OldJacksonImport.java @@ -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 { +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/Masker.java b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/Masker.java new file mode 100644 index 0000000..4c6fb23 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/Masker.java @@ -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. + * + *

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); +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerAutoConfiguration.java b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerAutoConfiguration.java new file mode 100644 index 0000000..d870fc0 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerAutoConfiguration.java @@ -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()); + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerJacksonAutoConfiguration.java b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerJacksonAutoConfiguration.java new file mode 100644 index 0000000..3cfa003 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerJacksonAutoConfiguration.java @@ -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. + * + *

{@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); + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerJacksonModule.java b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerJacksonModule.java new file mode 100644 index 0000000..a380a17 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerJacksonModule.java @@ -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())); + } + }); + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerProperties.java b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerProperties.java new file mode 100644 index 0000000..e4dca96 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/MaskerProperties.java @@ -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) { +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/Sensitive.java b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/Sensitive.java new file mode 100644 index 0000000..c00b231 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/Sensitive.java @@ -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) { +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/TailMasker.java b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/TailMasker.java new file mode 100644 index 0000000..9e3bddb --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/main/java/com/ankurm/masker/TailMasker.java @@ -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); + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/custom-starter/masker-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..51b6412 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,12 @@ +{ + "hints": [ + { + "name": "masker.replacement", + "values": [ + { "value": "*", "description": "Asterisk (the default)." }, + { "value": "#", "description": "Hash sign." }, + { "value": "•", "description": "Bullet." } + ] + } + ] +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/custom-starter/masker-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..915e8dc --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.ankurm.masker.MaskerAutoConfiguration +com.ankurm.masker.MaskerJacksonAutoConfiguration diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/consumer/ConsumerApplication.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/consumer/ConsumerApplication.java new file mode 100644 index 0000000..4d01192 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/consumer/ConsumerApplication.java @@ -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 { +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/ConditionTrapTests.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/ConditionTrapTests.java new file mode 100644 index 0000000..c1a22c4 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/ConditionTrapTests.java @@ -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); + }); + } + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/ForgottenImportsEntryTests.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/ForgottenImportsEntryTests.java new file mode 100644 index 0000000..19e1a19 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/ForgottenImportsEntryTests.java @@ -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()); + } + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/JacksonIntegrationTests.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/JacksonIntegrationTests.java new file mode 100644 index 0000000..5d3dad2 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/JacksonIntegrationTests.java @@ -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"); + }); + } + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/MaskerAutoConfigurationTests.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/MaskerAutoConfigurationTests.java new file mode 100644 index 0000000..833ac29 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/MaskerAutoConfigurationTests.java @@ -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 -> ""; + } + } + + @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))); + }); + } + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/ReportSupport.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/ReportSupport.java new file mode 100644 index 0000000..0d95ab9 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/ReportSupport.java @@ -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())); + }); + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/Transcript.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/Transcript.java new file mode 100644 index 0000000..1463903 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/Transcript.java @@ -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(), ""); + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, text); + } catch (IOException e) { + throw new IllegalStateException("could not write " + path, e); + } + System.out.print(text); + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/scanapp/ForgottenAutoConfiguration.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/scanapp/ForgottenAutoConfiguration.java new file mode 100644 index 0000000..7737a78 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/scanapp/ForgottenAutoConfiguration.java @@ -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"; + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/scanapp/ForgottenPlainConfiguration.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/scanapp/ForgottenPlainConfiguration.java new file mode 100644 index 0000000..0a1cf5d --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/scanapp/ForgottenPlainConfiguration.java @@ -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"; + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/scanapp/OwnersApplication.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/scanapp/OwnersApplication.java new file mode 100644 index 0000000..9689747 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/masker/scanapp/OwnersApplication.java @@ -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 { +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/DefaultOffAutoConfiguration.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/DefaultOffAutoConfiguration.java new file mode 100644 index 0000000..c22adba --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/DefaultOffAutoConfiguration.java @@ -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"); + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/EarlyModuleAutoConfiguration.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/EarlyModuleAutoConfiguration.java new file mode 100644 index 0000000..c19a632 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/EarlyModuleAutoConfiguration.java @@ -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"); + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/Marker.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/Marker.java new file mode 100644 index 0000000..17c049f --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/Marker.java @@ -0,0 +1,5 @@ +package com.ankurm.traps; + +/** A bean that only says which auto-configuration created it. */ +public record Marker(String createdBy) { +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/MethodLevelConditionAutoConfiguration.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/MethodLevelConditionAutoConfiguration.java new file mode 100644 index 0000000..3839d4e --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/MethodLevelConditionAutoConfiguration.java @@ -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); + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/MethodLevelMain.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/MethodLevelMain.java new file mode 100644 index 0000000..a9ba9bf --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/MethodLevelMain.java @@ -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. + * + *

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()); + } + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/NestedConditionAutoConfiguration.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/NestedConditionAutoConfiguration.java new file mode 100644 index 0000000..d56ec8f --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/NestedConditionAutoConfiguration.java @@ -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); + } + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/OrderedModuleAutoConfiguration.java b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/OrderedModuleAutoConfiguration.java new file mode 100644 index 0000000..a391d59 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/java/com/ankurm/traps/OrderedModuleAutoConfiguration.java @@ -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"); + } +} diff --git a/custom-starter/masker-spring-boot-autoconfigure/src/test/resources/META-INF/spring.factories b/custom-starter/masker-spring-boot-autoconfigure/src/test/resources/META-INF/spring.factories new file mode 100644 index 0000000..76ef582 --- /dev/null +++ b/custom-starter/masker-spring-boot-autoconfigure/src/test/resources/META-INF/spring.factories @@ -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 diff --git a/custom-starter/masker-spring-boot-starter/pom.xml b/custom-starter/masker-spring-boot-starter/pom.xml new file mode 100644 index 0000000..3d122f0 --- /dev/null +++ b/custom-starter/masker-spring-boot-starter/pom.xml @@ -0,0 +1,28 @@ + + + 4.0.0 + + + com.ankurm.masker + masker-parent + 1.0.0 + + + masker-spring-boot-starter + masker-spring-boot-starter + The dependency an application adds. It has no code: it pulls in the auto-configuration module. + + + + org.springframework.boot + spring-boot-starter + + + com.ankurm.masker + masker-spring-boot-autoconfigure + ${project.version} + + + diff --git a/custom-starter/output/01-defaults-without-any-configuration.txt b/custom-starter/output/01-defaults-without-any-configuration.txt new file mode 100644 index 0000000..a43311a --- /dev/null +++ b/custom-starter/output/01-defaults-without-any-configuration.txt @@ -0,0 +1,6 @@ +# The auto-configuration with no properties and no user beans + +Masker beans : [masker] +Masker implementation : TailMasker +defaults bound : enabled=true replacement='*' visibleTail=4 +mask("4111111111111111") = ************1111 diff --git a/custom-starter/output/02-properties-and-the-off-switch.txt b/custom-starter/output/02-properties-and-the-off-switch.txt new file mode 100644 index 0000000..2a8a423 --- /dev/null +++ b/custom-starter/output/02-properties-and-the-off-switch.txt @@ -0,0 +1,9 @@ +# masker.* properties, and masker.enabled=false + + +--- masker.replacement=# masker.visible-tail=2 --- +mask("4111111111111111") = ##############11 + +--- masker.enabled=false --- +Masker beans : [] +MaskerProperties beans : [] diff --git a/custom-starter/output/03-conditional-on-missing-bean-and-registration-order.txt b/custom-starter/output/03-conditional-on-missing-bean-and-registration-order.txt new file mode 100644 index 0000000..7b83b07 --- /dev/null +++ b/custom-starter/output/03-conditional-on-missing-bean-and-registration-order.txt @@ -0,0 +1,13 @@ +# @ConditionalOnMissingBean: backing off, and the order that decides it + + +--- user configuration + auto-configuration (the way Boot loads them) --- +Masker beans : [customMasker] +getBean(Masker.class) : mask -> + +--- the same two classes registered as ordinary configuration, starter first --- +Masker beans : [masker, customMasker] +getBean(Masker.class) : NoUniqueBeanDefinitionException + +--- the same two classes, user configuration first --- +Masker beans : [customMasker] diff --git a/custom-starter/output/04-jackson-integration-active.txt b/custom-starter/output/04-jackson-integration-active.txt new file mode 100644 index 0000000..928aa42 --- /dev/null +++ b/custom-starter/output/04-jackson-integration-active.txt @@ -0,0 +1,11 @@ +# Jackson on the classpath: the module is registered and Sensitive is written masked + +module beans : [maskerJacksonModule] +JSON : {"card":"************1111"} + +--- what Boot recorded for the Jackson auto-configuration --- +com.ankurm.masker.MaskerJacksonAutoConfiguration [matched] + matched: @ConditionalOnClass found required class 'tools.jackson.databind.json.JsonMapper' + matched: @ConditionalOnBean (types: com.ankurm.masker.Masker; SearchStrategy: all) found bean 'masker' +com.ankurm.masker.MaskerJacksonAutoConfiguration#maskerJacksonModule [matched] + matched: @ConditionalOnMissingBean (types: com.ankurm.masker.MaskerJacksonModule; SearchStrategy: all) did not find any beans diff --git a/custom-starter/output/05-jackson-integration-absent-without-error.txt b/custom-starter/output/05-jackson-integration-absent-without-error.txt new file mode 100644 index 0000000..dc34725 --- /dev/null +++ b/custom-starter/output/05-jackson-integration-absent-without-error.txt @@ -0,0 +1,9 @@ +# Jackson hidden with FilteredClassLoader: nothing fails, the integration just is not there + +context failed : false +Masker beans : [masker] +module beans : [] + +--- what Boot recorded for the Jackson auto-configuration --- +com.ankurm.masker.MaskerJacksonAutoConfiguration [not matched] + did not match: @ConditionalOnClass did not find required class 'tools.jackson.databind.json.JsonMapper' diff --git a/custom-starter/output/06-conditional-on-bean-needs-an-ordering.txt b/custom-starter/output/06-conditional-on-bean-needs-an-ordering.txt new file mode 100644 index 0000000..76f9162 --- /dev/null +++ b/custom-starter/output/06-conditional-on-bean-needs-an-ordering.txt @@ -0,0 +1,17 @@ +# @ConditionalOnBean(JsonMapper) with and without an ordering constraint + + +--- the order Boot evaluates them in --- +com.ankurm.masker.MaskerAutoConfiguration +com.ankurm.traps.EarlyModuleAutoConfiguration +org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration +com.ankurm.traps.OrderedModuleAutoConfiguration + +--- the beans each one produced --- +Marker bean created by OrderedModuleAutoConfiguration + +--- what Boot recorded --- +com.ankurm.traps.EarlyModuleAutoConfiguration [not matched] + did not match: @ConditionalOnBean (types: tools.jackson.databind.json.JsonMapper; SearchStrategy: all) did not find any beans of type tools.jackson.databind.json.JsonMapper +com.ankurm.traps.OrderedModuleAutoConfiguration [matched] + matched: @ConditionalOnBean (types: tools.jackson.databind.json.JsonMapper; SearchStrategy: all) found bean 'jacksonJsonMapper' diff --git a/custom-starter/output/07-conditional-on-class-on-a-bean-method.txt b/custom-starter/output/07-conditional-on-class-on-a-bean-method.txt new file mode 100644 index 0000000..e63481a --- /dev/null +++ b/custom-starter/output/07-conditional-on-class-on-a-bean-method.txt @@ -0,0 +1,12 @@ +# @ConditionalOnClass(JsonMapper.class) on a @Bean method, versus on a nested class, with and without Jackson + +--- condition on the @Bean method, classpath without any Jackson jar --- +context failed +exception chain: BeanDefinitionStoreException -> IllegalStateException -> NoClassDefFoundError -> ClassNotFoundException +root cause : java.lang.ClassNotFoundException: tools.jackson.databind.module.SimpleModule + +--- condition on a nested class, classpath without any Jackson jar --- +context started; nestedModule bean present: false + +--- condition on the @Bean method, classpath with Jackson --- +context started; methodLevelModule bean present: true diff --git a/custom-starter/output/08-auto-configuration-missing-from-the-imports-file.txt b/custom-starter/output/08-auto-configuration-missing-from-the-imports-file.txt new file mode 100644 index 0000000..fdddb69 --- /dev/null +++ b/custom-starter/output/08-auto-configuration-missing-from-the-imports-file.txt @@ -0,0 +1,14 @@ +# An auto-configuration that is not listed in AutoConfiguration.imports (it is listed in spring.factories, which Boot 4 ignores for this) + + +--- ApplicationContextRunner: the test names the class itself --- +forgottenMarker bean : true + +--- the starter author's application: @SpringBootApplication scanning the package that holds both classes --- +forgottenMarker (@AutoConfiguration) : false +forgottenPlainMarker (@Configuration) : true + +--- a consumer application: its own package, the starter on the classpath --- +forgottenMarker (@AutoConfiguration) : false +forgottenPlainMarker (@Configuration) : false +Masker beans : [masker] diff --git a/custom-starter/output/09-reading-the-imports-file-in-a-test.txt b/custom-starter/output/09-reading-the-imports-file-in-a-test.txt new file mode 100644 index 0000000..60b6971 --- /dev/null +++ b/custom-starter/output/09-reading-the-imports-file-in-a-test.txt @@ -0,0 +1,6 @@ +# ImportCandidates: what Boot itself reads from META-INF/spring/...AutoConfiguration.imports + +entries under com.ankurm.masker: 2 + com.ankurm.masker.MaskerAutoConfiguration + com.ankurm.masker.MaskerJacksonAutoConfiguration +ForgottenAutoConfiguration listed: false diff --git a/custom-starter/output/10-demo-app-with-only-the-starter.txt b/custom-starter/output/10-demo-app-with-only-the-starter.txt new file mode 100644 index 0000000..7e1c5a8 --- /dev/null +++ b/custom-starter/output/10-demo-app-with-only-the-starter.txt @@ -0,0 +1,17 @@ +# An application that adds masker-spring-boot-starter and nothing else + +Masker beans : [masker] +MaskerProperties beans : [masker-com.ankurm.masker.MaskerProperties] +Jackson module beans : [maskerJacksonModule] +JSON of a Customer : {"name":"Asha","card":"************1111"} + +--- what Boot recorded (the same lines --debug prints) --- +com.ankurm.masker.MaskerAutoConfiguration [matched] + matched: @ConditionalOnProperty (masker.enabled) matched +com.ankurm.masker.MaskerAutoConfiguration#masker [matched] + matched: @ConditionalOnMissingBean (types: com.ankurm.masker.Masker; SearchStrategy: all) did not find any beans +com.ankurm.masker.MaskerJacksonAutoConfiguration [matched] + matched: @ConditionalOnClass found required class 'tools.jackson.databind.json.JsonMapper' + matched: @ConditionalOnBean (types: com.ankurm.masker.Masker; SearchStrategy: all) found bean 'masker' +com.ankurm.masker.MaskerJacksonAutoConfiguration#maskerJacksonModule [matched] + matched: @ConditionalOnMissingBean (types: com.ankurm.masker.MaskerJacksonModule; SearchStrategy: all) did not find any beans diff --git a/custom-starter/output/11-demo-app-properties-and-off-switch.txt b/custom-starter/output/11-demo-app-properties-and-off-switch.txt new file mode 100644 index 0000000..0177732 --- /dev/null +++ b/custom-starter/output/11-demo-app-properties-and-off-switch.txt @@ -0,0 +1,16 @@ +# The same application configured with masker.* properties, then switched off + + +--- --masker.replacement=# --masker.visible-tail=6 --- +JSON of a Customer : {"name":"Asha","card":"##########111111"} + +--- --masker.enabled=false --- +Masker beans : [] +Jackson module beans : [] + +--- what Boot recorded --- +com.ankurm.masker.MaskerAutoConfiguration [not matched] + did not match: @ConditionalOnProperty (masker.enabled) found different value in property 'enabled' +com.ankurm.masker.MaskerJacksonAutoConfiguration [not matched] + matched: @ConditionalOnClass found required class 'tools.jackson.databind.json.JsonMapper' + did not match: @ConditionalOnBean (types: com.ankurm.masker.Masker; SearchStrategy: all) did not find any beans of type com.ankurm.masker.Masker diff --git a/custom-starter/output/12-jar-contents-and-dependency-trees.txt b/custom-starter/output/12-jar-contents-and-dependency-trees.txt new file mode 100644 index 0000000..7f01ecf --- /dev/null +++ b/custom-starter/output/12-jar-contents-and-dependency-trees.txt @@ -0,0 +1,74 @@ +# What is inside the two jars, and what the starter drags in + +--- masker-spring-boot-starter-1.0.0.jar --- +META-INF/MANIFEST.MF +META-INF/maven/com.ankurm.masker/masker-spring-boot-starter/pom.xml +META-INF/maven/com.ankurm.masker/masker-spring-boot-starter/pom.properties + +--- masker-spring-boot-autoconfigure-1.0.0.jar (META-INF and the classes) --- +META-INF/spring-autoconfigure-metadata.properties +META-INF/additional-spring-configuration-metadata.json +META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +META-INF/spring-configuration-metadata.json +com/ankurm/masker/MaskerJacksonAutoConfiguration.class +com/ankurm/masker/Sensitive.class +com/ankurm/masker/Masker.class +com/ankurm/masker/MaskerAutoConfiguration.class +com/ankurm/masker/TailMasker.class +com/ankurm/masker/MaskerJacksonModule.class +com/ankurm/masker/MaskerProperties.class +com/ankurm/masker/MaskerJacksonModule$1.class + +--- META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports --- +com.ankurm.masker.MaskerAutoConfiguration +com.ankurm.masker.MaskerJacksonAutoConfiguration + +--- dependency tree of masker-spring-boot-starter (Jackson is not in it) --- +com.ankurm.masker:masker-spring-boot-starter:jar:1.0.0 ++- org.springframework.boot:spring-boot-starter:jar:4.1.1:compile +| +- org.springframework.boot:spring-boot-starter-logging:jar:4.1.1:compile +| | +- ch.qos.logback:logback-classic:jar:1.5.38:compile +| | | +- ch.qos.logback:logback-core:jar:1.5.38:compile +| | | \- org.slf4j:slf4j-api:jar:2.0.18:compile +| | +- org.apache.logging.log4j:log4j-to-slf4j:jar:2.25.5:compile +| | | \- org.apache.logging.log4j:log4j-api:jar:2.25.5:compile +| | \- org.slf4j:jul-to-slf4j:jar:2.0.18:compile +| +- org.springframework.boot:spring-boot-autoconfigure:jar:4.1.1:compile +| | \- org.springframework.boot:spring-boot:jar:4.1.1:compile +| | +- org.springframework:spring-core:jar:7.0.9:compile +| | | +- commons-logging:commons-logging:jar:1.3.6:compile +| | | \- org.jspecify:jspecify:jar:1.0.1:compile +| | \- org.springframework:spring-context:jar:7.0.9:compile +| | +- org.springframework:spring-aop:jar:7.0.9:compile +| | +- org.springframework:spring-beans:jar:7.0.9:compile +| | +- org.springframework:spring-expression:jar:7.0.9:compile +| | \- io.micrometer:micrometer-observation:jar:1.17.1:compile +| | \- io.micrometer:micrometer-commons:jar:1.17.1:compile +| +- jakarta.annotation:jakarta.annotation-api:jar:3.0.0:compile +| \- org.yaml:snakeyaml:jar:2.6:compile +\- com.ankurm.masker:masker-spring-boot-autoconfigure:jar:1.0.0:compile + +--- dependency tree of masker-spring-boot-autoconfigure (Jackson is there, marked optional in the pom) --- +com.ankurm.masker:masker-spring-boot-autoconfigure:jar:1.0.0 ++- org.springframework.boot:spring-boot-autoconfigure:jar:4.1.1:compile +| \- org.springframework.boot:spring-boot:jar:4.1.1:compile +| \- org.springframework:spring-context:jar:7.0.9:compile +| +- org.springframework:spring-aop:jar:7.0.9:compile +| +- org.springframework:spring-beans:jar:7.0.9:compile +| +- org.springframework:spring-expression:jar:7.0.9:compile +| \- io.micrometer:micrometer-observation:jar:1.17.1:compile +| \- io.micrometer:micrometer-commons:jar:1.17.1:compile +\- org.springframework.boot:spring-boot-starter-jackson:jar:4.1.1:compile (optional) + +- org.springframework.boot:spring-boot-starter:jar:4.1.1:compile + | +- org.springframework.boot:spring-boot-starter-logging:jar:4.1.1:compile + | | +- ch.qos.logback:logback-classic:jar:1.5.38:compile + | | | \- ch.qos.logback:logback-core:jar:1.5.38:compile + | | +- org.apache.logging.log4j:log4j-to-slf4j:jar:2.25.5:compile + | | | \- org.apache.logging.log4j:log4j-api:jar:2.25.5:compile + | | \- org.slf4j:jul-to-slf4j:jar:2.0.18:compile + | +- jakarta.annotation:jakarta.annotation-api:jar:3.0.0:compile + | \- org.yaml:snakeyaml:jar:2.6:compile + \- org.springframework.boot:spring-boot-jackson:jar:4.1.1:compile (optional) + \- tools.jackson.core:jackson-databind:jar:3.1.5:compile (optional) + +- com.fasterxml.jackson.core:jackson-annotations:jar:2.21:compile (optional) + \- tools.jackson.core:jackson-core:jar:3.1.5:compile (optional) diff --git a/custom-starter/output/13-configuration-metadata-in-the-jar.txt b/custom-starter/output/13-configuration-metadata-in-the-jar.txt new file mode 100644 index 0000000..ceb635d --- /dev/null +++ b/custom-starter/output/13-configuration-metadata-in-the-jar.txt @@ -0,0 +1,80 @@ +# Metadata the two annotation processors write into the auto-configuration jar + +--- META-INF/spring-configuration-metadata.json, written by spring-boot-configuration-processor --- + +{ + "groups": [ + { + "name": "masker", + "type": "com.ankurm.masker.MaskerProperties", + "sourceType": "com.ankurm.masker.MaskerProperties" + } + ], + "properties": [ + { + "name": "masker.enabled", + "type": "java.lang.Boolean", + "description": "whether the starter configures anything at all", + "sourceType": "com.ankurm.masker.MaskerProperties", + "defaultValue": true + }, + { + "name": "masker.replacement", + "type": "java.lang.String", + "description": "the text that stands in for each hidden character", + "sourceType": "com.ankurm.masker.MaskerProperties", + "defaultValue": "*" + }, + { + "name": "masker.visible-tail", + "type": "java.lang.Integer", + "description": "how many characters at the end of the value stay readable", + "sourceType": "com.ankurm.masker.MaskerProperties", + "defaultValue": 4 + } + ], + "hints": [ + { + "name": "masker.replacement", + "values": [ + { + "value": "*", + "description": "Asterisk (the default)." + }, + { + "value": "#", + "description": "Hash sign." + }, + { + "value": "•", + "description": "Bullet." + } + ] + } + ], + "ignored": { + "properties": [] + } +} + +--- META-INF/additional-spring-configuration-metadata.json, hand-written and kept as is --- +{ + "hints": [ + { + "name": "masker.replacement", + "values": [ + { "value": "*", "description": "Asterisk (the default)." }, + { "value": "#", "description": "Hash sign." }, + { "value": "•", "description": "Bullet." } + ] + } + ] +} + + +--- META-INF/spring-autoconfigure-metadata.properties, written by spring-boot-autoconfigure-processor --- +com.ankurm.masker.MaskerAutoConfiguration= +com.ankurm.masker.MaskerJacksonAutoConfiguration= +com.ankurm.masker.MaskerJacksonAutoConfiguration.AutoConfigureAfter=com.ankurm.masker.MaskerAutoConfiguration +com.ankurm.masker.MaskerJacksonAutoConfiguration.ConditionalOnBean=com.ankurm.masker.Masker +com.ankurm.masker.MaskerJacksonAutoConfiguration.ConditionalOnClass=tools.jackson.databind.json.JsonMapper diff --git a/custom-starter/output/14-boot4-autoconfiguration-locations.txt b/custom-starter/output/14-boot4-autoconfiguration-locations.txt new file mode 100644 index 0000000..bf6457c --- /dev/null +++ b/custom-starter/output/14-boot4-autoconfiguration-locations.txt @@ -0,0 +1,30 @@ +# Boot 4.1.1: where the auto-configuration classes a starter refers to now live + +--- entries in spring-boot-autoconfigure's own AutoConfiguration.imports --- +12 + +--- JacksonAutoConfiguration: which jar, which package --- +spring-boot-autoconfigure-4.1.1.jar, classes named JacksonAutoConfiguration: 0 +spring-boot-jackson-4.1.1.jar: org/springframework/boot/jackson/autoconfigure/JacksonAutoConfiguration.class + +--- what @AutoConfiguration is made of (javap -v, class-level annotations) --- +org.springframework.context.annotation.Configuration( +proxyBeanMethods=false +org.springframework.boot.autoconfigure.AutoConfigureBefore +org.springframework.boot.autoconfigure.AutoConfigureAfter + +--- AutoConfigurationExcludeFilter, the filter @SpringBootApplication's scan applies: the classes it tests for (javap -c) --- +ldc #26 // class org/springframework/context/annotation/Configuration +ldc #40 // class org/springframework/boot/autoconfigure/AutoConfiguration +ldc #40 // class org/springframework/boot/autoconfigure/AutoConfiguration +invokestatic #65 // Method org/springframework/boot/context/annotation/ImportCandidates.load:(Ljava/lang/Class;Ljava/lang/ClassLoader;)Lorg/springframework/boot/context/annotation/ImportCandidates; + +--- the Boot 3 import, compiled against the 4.1.1 jars --- +masker-spring-boot-autoconfigure/src/broken/OldJacksonImport.java:4: error: package org.springframework.boot.autoconfigure.jackson does not exist +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; + ^ +masker-spring-boot-autoconfigure/src/broken/OldJacksonImport.java:7: error: cannot find symbol +@AutoConfiguration(after = JacksonAutoConfiguration.class) + ^ + symbol: class JacksonAutoConfiguration +2 errors diff --git a/custom-starter/output/15-annotation-processor-version-trap.txt b/custom-starter/output/15-annotation-processor-version-trap.txt new file mode 100644 index 0000000..cac48e8 --- /dev/null +++ b/custom-starter/output/15-annotation-processor-version-trap.txt @@ -0,0 +1,3 @@ +# The processor path with ${project.parent.version}, when the module's parent is not Spring Boot's + +Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project masker-spring-boot-autoconfigure: Resolution of annotationProcessorPath dependencies failed: The following artifacts could not be resolved: org.springframework.boot:spring-boot-configuration-processor:jar:1.0.0 (absent): Could not find artifact org.springframework.boot:spring-boot-configuration-processor:jar:1.0.0 in central (https://repo.maven.apache.org/maven2) diff --git a/custom-starter/output/16-conditional-on-property-without-match-if-missing.txt b/custom-starter/output/16-conditional-on-property-without-match-if-missing.txt new file mode 100644 index 0000000..8d99cfd --- /dev/null +++ b/custom-starter/output/16-conditional-on-property-without-match-if-missing.txt @@ -0,0 +1,10 @@ +# @ConditionalOnProperty(prefix = "traps", name = "enabled") with no matchIfMissing + + +--- no property set --- +Marker beans : 0 +com.ankurm.traps.DefaultOffAutoConfiguration [not matched] + did not match: @ConditionalOnProperty (traps.enabled) did not find property 'enabled' + +--- traps.enabled=true --- +Marker beans : 1 diff --git a/custom-starter/output/17-where-the-classes-live.txt b/custom-starter/output/17-where-the-classes-live.txt new file mode 100644 index 0000000..78291fe --- /dev/null +++ b/custom-starter/output/17-where-the-classes-live.txt @@ -0,0 +1,15 @@ +# Which 4.1.1 jar holds each Boot class this starter and its tests import (searched in every Boot jar the build resolved) + +spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/AutoConfiguration.class +spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/AutoConfigureAfter.class +spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/AutoConfigurations.class +spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/condition/ConditionalOnClass.class +spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/condition/ConditionalOnBean.class +spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/condition/ConditionalOnMissingBean.class +spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/condition/ConditionalOnProperty.class +spring-boot-autoconfigure-4.1.1.jar org/springframework/boot/autoconfigure/logging/ConditionEvaluationReportLoggingListener.class +spring-boot-4.1.1.jar org/springframework/boot/context/annotation/ImportCandidates.class +spring-boot-4.1.1.jar org/springframework/boot/context/annotation/Configurations.class +spring-boot-jackson-4.1.1.jar org/springframework/boot/jackson/autoconfigure/JacksonAutoConfiguration.class +spring-boot-test-4.1.1.jar org/springframework/boot/test/context/runner/ApplicationContextRunner.class +spring-boot-test-4.1.1.jar org/springframework/boot/test/context/FilteredClassLoader.class diff --git a/custom-starter/pom.xml b/custom-starter/pom.xml new file mode 100644 index 0000000..e3fef05 --- /dev/null +++ b/custom-starter/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm.masker + masker-parent + 1.0.0 + pom + masker-parent + A custom Spring Boot 4 starter: auto-configuration, @Conditional, properties metadata and tests + + + 25 + + + + masker-spring-boot-autoconfigure + masker-spring-boot-starter + masker-demo + + diff --git a/custom-starter/scripts/capture-jar-facts.sh b/custom-starter/scripts/capture-jar-facts.sh new file mode 100755 index 0000000..a9b7183 --- /dev/null +++ b/custom-starter/scripts/capture-jar-facts.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# Facts that a test cannot show, read from the built jars and from deliberately failing builds: +# 07 @ConditionalOnClass on a @Bean method versus on a nested class, on classpaths with and without Jackson +# 12 what is in the starter jar and the auto-configuration jar, and what the starter pulls in +# 13 the generated configuration metadata and auto-configuration metadata, as they sit in the jar +# 14 Boot 4's split of spring-boot-autoconfigure, and the Boot 3 import that no longer compiles +# 15 the annotation-processor version trap when the module has its own parent pom +# 17 which Boot 4.1.1 jar holds each class the starter imports +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p output +M2=~/.m2/repository/org/springframework/boot +AC=masker-spring-boot-autoconfigure +JP() { "$@" 2>&1 | grep -v -E '^(Picked up|WARNING)'; } +ROOT=$(pwd) +clean() { sed "s#$ROOT##g; s#$HOME#~#g"; } + +echo "== build the jars" +mvn -B -q package -DskipTests dependency:tree -Dscope=compile -DoutputFile=target/tree.txt >/dev/null 2>&1 || mvn -B package -DskipTests dependency:tree -Dscope=compile -DoutputFile=target/tree.txt + +echo "== 07" +mvn -B -q -pl "$AC" test-compile dependency:build-classpath -Dmdep.outputFile=/tmp/masker-cp.txt -Dmdep.includeScope=test >/dev/null 2>&1 +FULL=$(cat /tmp/masker-cp.txt) +NOJACKSON=$(tr ':' '\n' < /tmp/masker-cp.txt | grep -v jackson | paste -sd:) +{ + echo "# @ConditionalOnClass(JsonMapper.class) on a @Bean method, versus on a nested class, with and without Jackson" + echo + echo "--- condition on the @Bean method, classpath without any Jackson jar ---" + JP java -cp "$AC/target/test-classes:$AC/target/classes:$NOJACKSON" com.ankurm.traps.MethodLevelMain method | grep -v -E '^[0-9:.]+ \[main\]' + echo + echo "--- condition on a nested class, classpath without any Jackson jar ---" + JP java -cp "$AC/target/test-classes:$AC/target/classes:$NOJACKSON" com.ankurm.traps.MethodLevelMain nested | grep -v -E '^[0-9:.]+ \[main\]' + echo + echo "--- condition on the @Bean method, classpath with Jackson ---" + JP java -cp "$AC/target/test-classes:$AC/target/classes:$FULL" com.ankurm.traps.MethodLevelMain method | grep -v -E '^[0-9:.]+ \[main\]' +} | clean > output/07-conditional-on-class-on-a-bean-method.txt + +echo "== 12" +{ + echo "# What is inside the two jars, and what the starter drags in" + echo + echo "--- masker-spring-boot-starter-1.0.0.jar ---" + unzip -l masker-spring-boot-starter/target/masker-spring-boot-starter-1.0.0.jar | awk 'NR>3 && $4!="" {print $4}' | grep -v '/$' + echo + echo "--- masker-spring-boot-autoconfigure-1.0.0.jar (META-INF and the classes) ---" + unzip -l $AC/target/$AC-1.0.0.jar | awk 'NR>3 && $4!="" {print $4}' | grep -v '/$' | grep -v -E 'pom\.(xml|properties)|MANIFEST' + echo + echo "--- META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports ---" + unzip -p $AC/target/$AC-1.0.0.jar META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports + echo + echo "--- dependency tree of masker-spring-boot-starter (Jackson is not in it) ---" + cat masker-spring-boot-starter/target/tree.txt + echo + echo "--- dependency tree of masker-spring-boot-autoconfigure (Jackson is there, marked optional in the pom) ---" + cat $AC/target/tree.txt +} | clean > output/12-jar-contents-and-dependency-trees.txt + +echo "== 13" +{ + echo "# Metadata the two annotation processors write into the auto-configuration jar" + echo + echo "--- META-INF/spring-configuration-metadata.json, written by spring-boot-configuration-processor ---" + echo + unzip -p $AC/target/$AC-1.0.0.jar META-INF/spring-configuration-metadata.json + echo + echo + echo "--- META-INF/additional-spring-configuration-metadata.json, hand-written and kept as is ---" + unzip -p $AC/target/$AC-1.0.0.jar META-INF/additional-spring-configuration-metadata.json + echo + echo + echo "--- META-INF/spring-autoconfigure-metadata.properties, written by spring-boot-autoconfigure-processor ---" + unzip -p $AC/target/$AC-1.0.0.jar META-INF/spring-autoconfigure-metadata.properties +} > output/13-configuration-metadata-in-the-jar.txt + +echo "== 14" +BOOT_AC=$M2/spring-boot-autoconfigure/4.1.1/spring-boot-autoconfigure-4.1.1.jar +BOOT_JACKSON=$M2/spring-boot-jackson/4.1.1/spring-boot-jackson-4.1.1.jar +{ + echo "# Boot 4.1.1: where the auto-configuration classes a starter refers to now live" + echo + echo "--- entries in spring-boot-autoconfigure's own AutoConfiguration.imports ---" + unzip -p "$BOOT_AC" META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | grep -c . + echo + echo "--- JacksonAutoConfiguration: which jar, which package ---" + echo "spring-boot-autoconfigure-4.1.1.jar, classes named JacksonAutoConfiguration: $(unzip -l "$BOOT_AC" | grep -c 'JacksonAutoConfiguration' || true)" + unzip -l "$BOOT_JACKSON" | awk '/JacksonAutoConfiguration.class/ {print "spring-boot-jackson-4.1.1.jar: " $4}' + echo + X=$(mktemp -d) + unzip -q -o "$BOOT_AC" 'org/springframework/boot/autoconfigure/AutoConfiguration.class' \ + 'org/springframework/boot/autoconfigure/AutoConfigurationExcludeFilter.class' -d "$X" + echo "--- what @AutoConfiguration is made of (javap -v, class-level annotations) ---" + JP javap -v -cp "$X" org.springframework.boot.autoconfigure.AutoConfiguration \ + | sed -n '/^RuntimeVisibleAnnotations/,$p' | grep -E '^ (org\.springframework)|proxyBeanMethods' | sed 's/^ *//' + echo + echo "--- AutoConfigurationExcludeFilter, the filter @SpringBootApplication's scan applies: the classes it tests for (javap -c) ---" + JP javap -p -c -cp "$X" org.springframework.boot.autoconfigure.AutoConfigurationExcludeFilter \ + | grep -E 'ldc|ImportCandidates.load' | sed -E 's/^ *[0-9]+: //; s/ +/ /g' + rm -rf "$X" + echo + echo "--- the Boot 3 import, compiled against the 4.1.1 jars ---" + mkdir -p /tmp/masker-broken + JP javac -d /tmp/masker-broken -cp "$BOOT_AC:$BOOT_JACKSON" $AC/src/broken/OldJacksonImport.java | clean || true +} > output/14-boot4-autoconfiguration-locations.txt + +echo "== 17" +{ + echo "# Which 4.1.1 jar holds each Boot class this starter and its tests import (searched in every Boot jar the build resolved)" + echo + for c in AutoConfiguration AutoConfigureAfter AutoConfigurations ConditionalOnClass ConditionalOnBean ConditionalOnMissingBean ConditionalOnProperty \ + ConditionEvaluationReportLoggingListener ImportCandidates Configurations JacksonAutoConfiguration ApplicationContextRunner FilteredClassLoader; do + for j in "$M2"/spring-boot*/4.1.1/spring-boot*-4.1.1.jar; do + unzip -l "$j" | awk -v c="/$c.class" -v j="$(basename "$j")" 'index($4, c) && $4 !~ /\$/ {printf "%-46s %s\n", j, $4}' + done + done +} > output/17-where-the-classes-live.txt + +echo "== 15" +TMP=$(mktemp -d) +cp -r pom.xml "$AC" "$TMP/" +mkdir -p "$TMP/masker-spring-boot-starter" "$TMP/masker-demo" +cp masker-spring-boot-starter/pom.xml "$TMP/masker-spring-boot-starter/"; cp masker-demo/pom.xml "$TMP/masker-demo/" +rm -rf "$TMP/$AC/target" +python3 - "$TMP/$AC/pom.xml" <<'PY' +import sys +p=sys.argv[1] +s=open(p).read() +s=s.replace("spring-boot-configuration-processor","spring-boot-configuration-processor\n ${project.parent.version}") +open(p,'w').write(s) +PY +{ + echo "# The processor path with \${project.parent.version}, when the module's parent is not Spring Boot's" + echo + (cd "$TMP" && mvn -B -U -pl $AC compile 2>&1 | grep -E 'ERROR.*(annotationProcessorPath|Could not find artifact)' | head -1 | sed -E 's/^\[ERROR\] //; s/ -> \[Help 1\]//') +} > output/15-annotation-processor-version-trap.txt +rm -rf "$TMP" +echo "done" diff --git a/custom-starter/scripts/run-all.sh b/custom-starter/scripts/run-all.sh new file mode 100755 index 0000000..8951361 --- /dev/null +++ b/custom-starter/scripts/run-all.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Regenerates every file under output/. +# +# ./scripts/run-all.sh +# +# Needs a JDK 25 and Maven 3.9. Transcripts 01-06, 08-11 and 16 come out of the test suite, which is the point: +# the figures in the article are assertions that fail the build if they stop being true. +# Transcripts 07 and 12-15 are read from the built jars and from builds that are made to fail on purpose. +set -euo pipefail +cd "$(dirname "$0")/.." + +echo "== test suite (transcripts 01-06, 08-11, 16)" +mvn -B test + +echo "== jar contents, metadata, Boot 4 locations and deliberate failures (07, 12-15, 17)" +./scripts/capture-jar-facts.sh + +echo +echo "output:" +ls -1 output