From f1ac22b5425331e07ac1a33097879dd48f2f8699 Mon Sep 17 00:00:00 2001 From: Cora Iberkleid Date: Sat, 6 Jul 2024 11:02:22 -0400 Subject: [PATCH] GH-638 - Create aggregating Asciidoc document including all files generated --- .../modulith/docs/Asciidoctor.java | 15 +++ .../modulith/docs/Documenter.java | 98 ++++++++++++++++++- .../modulith/docs/DocumenterTest.java | 77 ++++++++++++++- .../modules/ROOT/pages/documentation.adoc | 45 +++++++++ 4 files changed, 229 insertions(+), 6 deletions(-) diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java index 4c484f9ce..a4120317f 100644 --- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java @@ -367,4 +367,19 @@ public String renderBeanReferences(ApplicationModule module) { return bullets.isBlank() ? "None" : bullets; } + + public String renderHeadline(int i, String modules) { + + return "=".repeat(i) + " " + modules + System.lineSeparator(); + } + + public String renderPlantUmlInclude(String componentsFilename) { + + return "plantuml::" + componentsFilename + "[]" + System.lineSeparator(); + } + + public String renderGeneralInclude(String componentsFilename) { + + return "include::" + componentsFilename + "[]" + System.lineSeparator(); + } } diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java index 5a8049cd8..c04e0b0e3 100644 --- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java @@ -184,8 +184,89 @@ public Documenter writeDocumentation() { public Documenter writeDocumentation(DiagramOptions options, CanvasOptions canvasOptions) { return writeModulesAsPlantUml(options) - .writeIndividualModulesAsPlantUml(options) // - .writeModuleCanvases(canvasOptions); + .writeIndividualModulesAsPlantUml(options) + .writeModuleCanvases(canvasOptions) + .writeAggregatingDocument(options, canvasOptions); + } + + /** + * Writes aggregating document called 'all-docs.adoc' that includes any existing component diagrams and canvases. + * using {@link DiagramOptions#defaults()} and {@link CanvasOptions#defaults()}. + * + * @return the current instance, will never be {@literal null}. + */ + public Documenter writeAggregatingDocument(){ + + return writeAggregatingDocument(DiagramOptions.defaults(), CanvasOptions.defaults()); + } + + /** + * Writes aggregating document called 'all-docs.adoc' that includes any existing component diagrams and canvases. + * + * @param options must not be {@literal null}. + * @param canvasOptions must not be {@literal null}. + * @return the current instance, will never be {@literal null}. + */ + public Documenter writeAggregatingDocument(DiagramOptions options, CanvasOptions canvasOptions){ + + Assert.notNull(options, "DiagramOptions must not be null!"); + Assert.notNull(canvasOptions, "CanvasOptions must not be null!"); + + var asciidoctor = Asciidoctor.withJavadocBase(modules, canvasOptions.getApiBase()); + var outputFolder = new OutputFolder(this.outputFolder); + + // Get file name for module overview diagram + var componentsFilename = options.getTargetFileName().orElse(DEFAULT_COMPONENTS_FILE); + var componentsDoc = new StringBuilder(); + + if (outputFolder.contains(componentsFilename)) { + componentsDoc.append(asciidoctor.renderHeadline(2, modules.getSystemName().orElse("Modules"))) + .append(asciidoctor.renderPlantUmlInclude(componentsFilename)) + .append(System.lineSeparator()); + } + + // Get file names for individual module diagrams and canvases + var moduleDocs = modules.stream().map(it -> { + + // Get diagram file name, e.g. module-inventory.puml + var fileNamePattern = options.getTargetFileName().orElse(DEFAULT_MODULE_COMPONENTS_FILE); + Assert.isTrue(fileNamePattern.contains("%s"), () -> String.format(INVALID_FILE_NAME_PATTERN, fileNamePattern)); + var filename = String.format(fileNamePattern, it.getName()); + + // Get canvas file name, e.g. module-inventory.adoc + var canvasFilename = canvasOptions.getTargetFileName(it.getName()); + + // Generate output, e.g.: + /* + == Inventory + plantuml::module-inventory.puml[] + include::module-inventory.adoc[] + */ + var content = new StringBuilder(); + content.append((outputFolder.contains(filename) ? asciidoctor.renderPlantUmlInclude(filename) : "")) + .append((outputFolder.contains(canvasFilename) ? asciidoctor.renderGeneralInclude(canvasFilename) : "")); + if (!content.isEmpty()) { + content.insert(0, asciidoctor.renderHeadline(2, it.getDisplayName())) + .append(System.lineSeparator()); + } + return content.toString(); + + }).collect(Collectors.joining()); + + var allDocs = componentsDoc.append(moduleDocs).toString(); + + // Write file to all-docs.adoc + if (!allDocs.isBlank()) { + Path file = recreateFile("all-docs.adoc"); + + try (Writer writer = new FileWriter(file.toFile())) { + writer.write(allDocs); + } catch (IOException o_O) { + throw new RuntimeException(o_O); + } + } + + return this; } /** @@ -1177,4 +1258,17 @@ protected void startContainerBoundary(ModelView view, Container container, Inden @Override protected void endContainerBoundary(ModelView view, IndentingWriter writer) {}; }; + + private static class OutputFolder { + + private final String path; + + OutputFolder(String path) { + this.path = path; + } + + boolean contains(String filename) { + return Files.exists(Paths.get(path, filename)); + } + } } diff --git a/spring-modulith-integration-test/src/test/java/org/springframework/modulith/docs/DocumenterTest.java b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/docs/DocumenterTest.java index 4bbdc0868..212c12829 100644 --- a/spring-modulith-integration-test/src/test/java/org/springframework/modulith/docs/DocumenterTest.java +++ b/spring-modulith-integration-test/src/test/java/org/springframework/modulith/docs/DocumenterTest.java @@ -24,6 +24,7 @@ import java.nio.file.Paths; import java.util.Comparator; import java.util.Optional; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.springframework.modulith.core.ApplicationModule; @@ -86,10 +87,78 @@ void customizesOutputLocation() throws IOException { } finally { - Files.walk(path) - .sorted(Comparator.reverseOrder()) - .map(Path::toFile) - .forEach(File::delete); + deleteDirectory(path); } } + + @Test // GH-638 + void writesAggregatingDocumentOnlyIfOtherDocsExist() throws IOException { + + String customOutputFolder = "build/spring-modulith"; + Path path = Paths.get(customOutputFolder); + + Documenter documenter = new Documenter(ApplicationModules.of(Application.class), customOutputFolder); + + try { + + // all-docs.adoc should be created + documenter.writeDocumentation(); + + // Count files + long actualFiles; + try (Stream stream = Files.walk(path)) { + actualFiles = stream.filter(Files::isRegularFile).count(); + } + // Expect 2 files per module plus components diagram and all-docs.adoc + long expectedFiles = (documenter.getModules().stream().count() * 2) + 2; + assertThat(actualFiles).isEqualTo(expectedFiles); + + Optional optionalPath = Files.walk(path) + .filter(p -> p.getFileName().toString().equals("all-docs.adoc")) + .findFirst(); + assertThat(optionalPath.isPresent()); + + // Count non-blank lines in all-docs.adoc + long actualLines; + try (Stream lines = Files.lines(optionalPath.get())) { + actualLines = lines.filter(line -> !line.trim().isEmpty()) + .count(); + } + // Expect 3 lines per module and 2 lines for components + long expectedLines = (documenter.getModules().stream().count() * 3) + 2; + assertThat(actualLines).isEqualTo(expectedLines); + + // all-docs.adoc should not be created + deleteDirectoryContents(path); + + documenter.writeAggregatingDocument(); + + optionalPath = Files.walk(path) + .filter(p -> p.getFileName().toString().equals("all-docs.adoc")) + .findFirst(); + assertThat(optionalPath.isEmpty()); + + } finally { + + deleteDirectory(path); + } + } + + private static void deleteDirectoryContents(Path path) throws IOException { + + if (Files.exists(path) && Files.isDirectory(path)) { + try (Stream walk = Files.walk(path)) { + walk.sorted(Comparator.reverseOrder()) + .filter(p -> !p.equals(path)) // Ensure we don't delete the directory itself + .map(Path::toFile) + .forEach(File::delete); + } + } + } + + private static void deleteDirectory(Path path) throws IOException { + + deleteDirectoryContents(path); + Files.deleteIfExists(path); + } } diff --git a/src/docs/antora/modules/ROOT/pages/documentation.adoc b/src/docs/antora/modules/ROOT/pages/documentation.adoc index 4d1be19cc..05db00595 100644 --- a/src/docs/antora/modules/ROOT/pages/documentation.adoc +++ b/src/docs/antora/modules/ROOT/pages/documentation.adoc @@ -7,6 +7,8 @@ Spring Modulith's `Documenter` abstraction can produce two different kinds of sn * C4 and UML component diagrams describing the relationships between the individual application modules * A so-called __Application Module Canvas__, a tabular overview about the module and the most relevant elements in those (Spring beans, aggregate roots, events published and listened to as well as configuration properties). +Additionally, `Documenter` can produce an aggregating Asciidoc file that includes all existing component diagrams and canvases. + [[component-diagrams]] == Generating Application Module Component diagrams @@ -302,3 +304,46 @@ This will detect component stereotypes defined by https://github.com/xmolecules/ * __Application events listened to by the module__ -- Derived from methods annotated with Spring's `@EventListener`, `@TransactionalEventListener`, jMolecules' `@DomainEventHandler` or beans implementing `ApplicationListener`. * __Configuration properties__ -- Spring Boot Configuration properties exposed by the application module. Requires the usage of the `spring-boot-configuration-processor` artifact to extract the metadata attached to the properties. + +[[aggregating-document]] +== Generating an Aggregating Document + +The aggregating document can be generated by calling `Documenter.writeAggregatingDocument()`: + +.Generating an aggregating document using `Documenter` +[tabs] +====== +Java:: ++ +[source, java, role="primary"] +---- +class DocumentationTests { + + ApplicationModules modules = ApplicationModules.of(Application.class); + + @Test + void writeDocumentationSnippets() { + + new Documenter(modules) + .writeAggregatingDocument(); + } +} +---- +Kotlin:: ++ +[source, kotlin, role="secondary"] +---- +class DocumentationTests { + + private val modules = ApplicationModules.of(Application::class) + + @Test + fun writeDocumentationSnippets() { + Documenter(modules) + .writeAggregatingDocument() + } +} +---- +====== + +The aggregating document will include any existing application module component diagrams and application module canvases. If there are none, then this method will not produce an output file. \ No newline at end of file