Add custom-starter module: a Boot 4 auto-configuration starter with conditions, metadata and runner tests
Two-jar starter (autoconfigure + starter), a demo app, and 17 captured transcripts covering AutoConfiguration.imports, @ConditionalOn*, ordering, optional Jackson 3 integration, configuration metadata and the Boot 4 module split. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Uu7q8vPeREyT4218EJPzz1
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
package com.ankurm.demo;
|
||||
|
||||
import com.ankurm.masker.Sensitive;
|
||||
|
||||
/** An application type that carries a value which must not reach a log or a response in clear. */
|
||||
public record Customer(String name, Sensitive card) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.ankurm.demo;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* An ordinary application. Its only knowledge of the masker is the one dependency in its pom:
|
||||
* no {@code @Import}, no {@code @Bean}, no {@code @ComponentScan} mentioning the starter.
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class DemoApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DemoApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.ankurm.demo;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import com.ankurm.masker.Masker;
|
||||
import com.ankurm.masker.MaskerJacksonModule;
|
||||
import com.ankurm.masker.MaskerProperties;
|
||||
import com.ankurm.masker.Sensitive;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.Banner;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** The consumer's view: a real application started with SpringApplication, the starter reached only through its pom. */
|
||||
class DemoApplicationTests {
|
||||
|
||||
private static ConfigurableApplicationContext start(String... args) {
|
||||
return new SpringApplicationBuilder(DemoApplication.class).web(WebApplicationType.NONE)
|
||||
.bannerMode(Banner.Mode.OFF).properties("logging.level.root=WARN").run(args);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addingTheDependencyIsEnough() {
|
||||
try (Transcript t = new Transcript("10-demo-app-with-only-the-starter.txt",
|
||||
"An application that adds masker-spring-boot-starter and nothing else");
|
||||
ConfigurableApplicationContext context = start()) {
|
||||
assertThat(context).isNotNull();
|
||||
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
|
||||
t.line("MaskerProperties beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerProperties.class)));
|
||||
t.line("Jackson module beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerJacksonModule.class)));
|
||||
String json = context.getBean(JsonMapper.class).writeValueAsString(
|
||||
new Customer("Asha", new Sensitive("4111111111111111")));
|
||||
t.line("JSON of a Customer : %s", json);
|
||||
assertThat(json).isEqualTo("{\"name\":\"Asha\",\"card\":\"************1111\"}");
|
||||
t.section("what Boot recorded (the same lines --debug prints)");
|
||||
ReportSupport.print(t, context, "com.ankurm.masker.Masker");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void propertiesAndTheOffSwitchWorkFromTheApplication() {
|
||||
try (Transcript t = new Transcript("11-demo-app-properties-and-off-switch.txt",
|
||||
"The same application configured with masker.* properties, then switched off")) {
|
||||
t.section("--masker.replacement=# --masker.visible-tail=6");
|
||||
try (ConfigurableApplicationContext context = start("--masker.replacement=#", "--masker.visible-tail=6")) {
|
||||
String json = context.getBean(JsonMapper.class).writeValueAsString(
|
||||
new Customer("Asha", new Sensitive("4111111111111111")));
|
||||
t.line("JSON of a Customer : %s", json);
|
||||
assertThat(json).contains("##########111111");
|
||||
}
|
||||
|
||||
t.section("--masker.enabled=false");
|
||||
try (ConfigurableApplicationContext context = start("--masker.enabled=false")) {
|
||||
t.line("Masker beans : %s", Arrays.toString(context.getBeanNamesForType(Masker.class)));
|
||||
t.line("Jackson module beans : %s", Arrays.toString(context.getBeanNamesForType(MaskerJacksonModule.class)));
|
||||
assertThat(context).isNotNull();
|
||||
assertThat(context.getBeanNamesForType(Masker.class)).isEmpty();
|
||||
t.section("what Boot recorded");
|
||||
ReportSupport.print(t, context, "com.ankurm.masker.Masker");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ankurm.demo;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionEvaluationReport;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
/** Prints the condition outcomes Boot recorded for the classes whose name contains a filter, as {@code --debug} would. */
|
||||
final class ReportSupport {
|
||||
|
||||
private ReportSupport() {
|
||||
}
|
||||
|
||||
static void print(Transcript t, ConfigurableApplicationContext context, String filter) {
|
||||
ConditionEvaluationReport report = ConditionEvaluationReport.get(context.getBeanFactory());
|
||||
report.getConditionAndOutcomesBySource().entrySet().stream()
|
||||
.filter(e -> e.getKey().contains(filter))
|
||||
.sorted(Map.Entry.comparingByKey())
|
||||
.forEach(e -> {
|
||||
t.line("%s [%s]", e.getKey(), e.getValue().isFullMatch() ? "matched" : "not matched");
|
||||
e.getValue().forEach(o -> t.line(" %s %s",
|
||||
o.getOutcome().isMatch() ? "matched:" : "did not match:", o.getOutcome().getMessage()));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.ankurm.demo;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Writes a numbered transcript under {@code custom-starter/output/} and echoes it to the console.
|
||||
* Every console block quoted in the article comes out of one of these files verbatim.
|
||||
*/
|
||||
public final class Transcript implements AutoCloseable {
|
||||
|
||||
private static final Path ROOT =
|
||||
Path.of(System.getProperty("basedir", ".")).toAbsolutePath().normalize().getParent();
|
||||
|
||||
private final Path path;
|
||||
private final StringWriter buffer = new StringWriter();
|
||||
private final PrintWriter out = new PrintWriter(buffer);
|
||||
|
||||
public Transcript(String fileName, String title) {
|
||||
this.path = ROOT.resolve("output").resolve(fileName);
|
||||
out.println("# " + title);
|
||||
out.println();
|
||||
}
|
||||
|
||||
public Transcript line(String format, Object... args) {
|
||||
out.println(args.length == 0 ? format : String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript section(String heading) {
|
||||
out.println();
|
||||
out.println("--- " + heading + " ---");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
out.flush();
|
||||
// Absolute paths of whoever ran the build are environment noise, not a finding.
|
||||
String text = buffer.toString().replace(ROOT.toString(), "<custom-starter>");
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, text);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(text);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user