GH-854 - Spring Modulith-native support for Javadoc usage in Application Module Canvases.

We now ship our own APT (included in the core starter) that automatically extracts the user code's Javadoc into a metadata file in generated-spring-modulith/javadoc.json. The documentation generation support picks up that file through SpringModulithDocumentationSource. Asciidoctor has been extended to make more use of the Javadoc where ever it renders a plain type.
This commit is contained in:
Oliver Drotbohm
2024-10-03 23:29:47 +02:00
parent 2a807cc213
commit 1708f5e181
19 changed files with 1286 additions and 52 deletions

View File

@@ -20,17 +20,18 @@
<modules>
<module>spring-modulith-actuator</module>
<module>spring-modulith-api</module>
<module>spring-modulith-apt</module>
<module>spring-modulith-bom</module>
<module>spring-modulith-core</module>
<module>spring-modulith-docs</module>
<module>spring-modulith-events</module>
<module>spring-modulith-junit</module>
<module>spring-modulith-moments</module>
<module>spring-modulith-observability</module>
<module>spring-modulith-runtime</module>
<module>spring-modulith-starters</module>
<module>spring-modulith-test</module>
<module>spring-modulith-junit</module>
</modules>
</modules>
<properties>

131
spring-modulith-apt/pom.xml Normal file
View File

@@ -0,0 +1,131 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith</artifactId>
<version>1.3.0-SNAPSHOT</version>
</parent>
<name>Spring Modulith - APT</name>
<artifactId>spring-modulith-apt</artifactId>
<properties>
<module.name>org.springframework.modulith.apt</module.name>
</properties>
<dependencies>
<dependency>
<groupId>io.toolisticon.aptk</groupId>
<artifactId>aptk-tools</artifactId>
<version>0.28.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-docs</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>io.toolisticon.cute</groupId>
<artifactId>cute</artifactId>
<version>1.7.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<executions>
<execution>
<id>default-compile</id>
<configuration>
<compilerArgument>-proc:none</compilerArgument>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<includes>
<include>io.toolisticon.aptk:*</include>
</includes>
</artifactSet>
<relocations>
<relocation>
<pattern>io.toolisticon.aptk</pattern>
<shadedPattern>org.springframework.modulith.aptk</shadedPattern>
</relocation>
</relocations>
<dependencyReducedPomLocation>${project.build.directory}/dependency-reduced-pom.xml</dependencyReducedPomLocation>
<minimizeJar>true</minimizeJar>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifestEntries>
<Spring-Boot-Jar-Type>annotation-processor</Spring-Boot-Jar-Type>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,308 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.modulith.apt;
import io.toolisticon.aptk.tools.ElementUtils;
import io.toolisticon.aptk.tools.wrapper.ElementWrapper;
import io.toolisticon.aptk.tools.wrapper.ExecutableElementWrapper;
import io.toolisticon.aptk.tools.wrapper.TypeElementWrapper;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.annotation.processing.Completion;
import javax.annotation.processing.Messager;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.Processor;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.AnnotationMirror;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.NestingKind;
import javax.lang.model.element.TypeElement;
import javax.lang.model.util.Elements;
import javax.tools.Diagnostic.Kind;
import javax.tools.StandardLocation;
import org.springframework.boot.json.JsonWriter;
import org.springframework.lang.Nullable;
import org.springframework.modulith.docs.metadata.MethodMetadata;
import org.springframework.modulith.docs.metadata.TypeMetadata;
import org.springframework.modulith.docs.util.BuildSystemUtils;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
/**
* An annotation processor to extract Javadoc from all compiled files assembling it into a JSON file located under
* {@code $target/generated-spring-modulith/javadoc.json}.
*
* @author Oliver Drotbohm
* @since 1.3
*/
public class SpringModulithProcessor implements Processor {
private static final Collection<String> JAVADOC_TAGS = Set.of("@param", "@return", "@author", "@since", "@see");
static final String JSON_LOCATION;
private Elements elements;
private Messager messager;
private boolean testExecution;
private Set<TypeMetadata> metadata = new HashSet<>();
static {
JSON_LOCATION = BuildSystemUtils.getTarget("generated-spring-modulith/javadoc.json");
}
/*
* (non-Javadoc)
* @see javax.annotation.processing.Processor#getSupportedAnnotationTypes()
*/
@Override
public Set<String> getSupportedAnnotationTypes() {
return Collections.singleton("*");
}
/*
* (non-Javadoc)
* @see javax.annotation.processing.Processor#getSupportedOptions()
*/
@Override
public Set<String> getSupportedOptions() {
return Collections.emptySet();
}
/*
* (non-Javadoc)
* @see javax.annotation.processing.Processor#getSupportedSourceVersion()
*/
@Override
public SourceVersion getSupportedSourceVersion() {
return SourceVersion.latest();
}
/*
* (non-Javadoc)
* @see javax.annotation.processing.Processor#getCompletions(javax.lang.model.element.Element, javax.lang.model.element.AnnotationMirror, javax.lang.model.element.ExecutableElement, java.lang.String)
*/
@Override
public Iterable<? extends Completion> getCompletions(Element element, AnnotationMirror annotation,
ExecutableElement member, String userText) {
return Collections.emptyList();
}
/*
* (non-Javadoc)
* @see javax.annotation.processing.Processor#init(javax.annotation.processing.ProcessingEnvironment)
*/
@Override
public void init(ProcessingEnvironment environment) {
this.elements = environment.getElementUtils();
this.messager = environment.getMessager();
try {
var path = environment.getFiler()
.createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/spring-modulith")
.toUri()
.toString();
if (path.contains(BuildSystemUtils.getTestTarget())) {
this.testExecution = true;
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/*
* (non-Javadoc)
* @see javax.annotation.processing.Processor#process(java.util.Set, javax.annotation.processing.RoundEnvironment)
*/
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
if (testExecution) {
return false;
}
if (!roundEnv.processingOver()) {
roundEnv.getRootElements().stream()
.map(ElementWrapper::wrap)
.filter(ElementWrapper::isTypeElement)
.map(TypeElementWrapper::toTypeElement)
.flatMap(this::handle)
.forEach(metadata::add);
return false;
}
if (roundEnv.processingOver()) {
messager.printMessage(Kind.NOTE, "Extracting Javadoc into " + JSON_LOCATION + ".");
var methodJson = JsonWriter.<MethodMetadata> of(inner -> {
inner.add("name", MethodMetadata::name);
inner.add("signature", MethodMetadata::signature);
inner.add("comment", MethodMetadata::comment)
.whenNotNull();
});
var typeJson = JsonWriter.<TypeMetadata> of(members -> {
members.add("name", TypeMetadata::name);
members.add("comment", TypeMetadata::comment)
.whenNotNull();
members.add("methods", TypeMetadata::methods)
.whenNotEmpty()
.as(methods -> {
return methods.stream().map(methodJson::write).toList();
});
});
File file = new File(JSON_LOCATION);
if (!file.exists()) {
try {
Files.createDirectories(file.toPath().getParent());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
var output = JsonWriter.standard()
.withNewLineAtEnd()
.writeToString(metadata.stream()
.sorted(Comparator.comparing(TypeMetadata::name))
.map(typeJson::write)
.toList());
try (var writer = new FileWriter(file)) {
writer.write(output);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
return false;
}
private Stream<TypeMetadata> handle(TypeElementWrapper type) {
return getTypes(type).flatMap(this::toMetadata);
}
private Stream<TypeMetadata> toMetadata(TypeElementWrapper it) {
var methods = it.getMethods().stream()
.flatMap(this::toMetadata)
.toList();
var comment = getComment(it);
return comment != null || !methods.isEmpty()
? Stream.of(new TypeMetadata(getQualifiedName(it), comment, methods))
: Stream.empty();
}
/**
* Workaround for https://github.com/toolisticon/aptk/issues/163
*
* @param element must not be {@literal null}.
* @return will never be {@literal null}.
*/
private String getQualifiedName(TypeElementWrapper element) {
Assert.notNull(element, "Element must not be null!");
if (element.getNestingKind() != NestingKind.MEMBER) {
return element.getQualifiedName();
}
var enclosing = ElementUtils.AccessEnclosingElements.<TypeElement> getFirstEnclosingElementOfKind(element.unwrap(),
ElementKind.CLASS,
ElementKind.INTERFACE,
ElementKind.RECORD);
return enclosing != null
? getQualifiedName(TypeElementWrapper.wrap(enclosing)) + "$" + element.getSimpleName()
: element.getQualifiedName();
}
private Stream<MethodMetadata> toMetadata(ExecutableElementWrapper method) {
var comment = getComment(method);
return comment != null
? Stream.of(new MethodMetadata(method.getSimpleName(), getSignature(method), comment))
: Stream.empty();
}
@Nullable
private String getComment(ElementWrapper<?> element) {
var result = elements.getDocComment(element.unwrap());
if (result == null) {
return null;
}
for (var tag : JAVADOC_TAGS) {
var index = result.indexOf(tag);
if (index == -1) {
continue;
}
result = result.substring(0, index);
}
result = result.trim()
.replaceAll("\\n\s*", " "); // replace newlines
return StringUtils.hasText(result) ? result : null;
}
private static String getSignature(ExecutableElementWrapper wrapper) {
var parameters = wrapper.getParameters().stream()
.map(it -> it.asType().getBinaryName())
.collect(Collectors.joining(", ", "(", ")"));
return wrapper.getSimpleName() + parameters;
}
private static Stream<TypeElementWrapper> getTypes(TypeElementWrapper type) {
var enclosed = type.getEnclosedElements().stream()
.filter(ElementWrapper::isTypeElement)
.map(TypeElementWrapper::toTypeElement);
return Stream.concat(Stream.of(type), enclosed);
}
}

View File

@@ -0,0 +1 @@
org.springframework.modulith.apt.SpringModulithProcessor

View File

@@ -0,0 +1,85 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.modulith.apt;
import static org.assertj.core.api.Assertions.*;
import static org.springframework.modulith.apt.SpringModulithProcessor.*;
import io.toolisticon.cute.Cute;
import io.toolisticon.cute.CuteApi.BlackBoxTestInterface;
import io.toolisticon.cute.CuteApi.BlackBoxTestSourceFilesAndProcessorInterface;
import io.toolisticon.cute.CuteApi.DoCustomAssertions;
import java.io.File;
import org.junit.jupiter.api.Test;
import com.jayway.jsonpath.JsonPath;
/**
* Unit tests for {@link SpringModulithProcessor}.
*
* @author Oliver Drotbohm
*/
class SpringModulithProcessorUnitTests {
BlackBoxTestSourceFilesAndProcessorInterface baseBlackBoxSetup = Cute.blackBoxTest()
.given()
.processor(SpringModulithProcessor.class);
@Test // GH-854
void extractsJavadoc() throws Exception {
assertSucceded(getSourceFile("SampleComponent"));
var output = new File(JSON_LOCATION);
assertThat(output).exists();
}
@Test // GH-854
void stripsNewLinesFromComments() throws Exception {
assertSucceded(getSourceFile("SampleComponent"));
var output = new File(JSON_LOCATION);
var context = JsonPath.parse(output);
var comment = context.read("$[?(@.name == 'example.SampleComponent')].comment", String[].class)[0];
assertThat(comment).isNotBlank().doesNotContain("\n");
}
private static DoCustomAssertions assertSucceded(String source) {
return assertSourceProcessed(source)
.thenExpectThat().compilationSucceeds()
.executeTest();
}
private static BlackBoxTestInterface assertSourceProcessed(String source) {
return Cute.blackBoxTest()
.given()
.processor(SpringModulithProcessor.class)
.andSourceFiles(source)
.whenCompiled();
}
private static String getSourceFile(String name) {
return "/example/" + name + ".java";
}
}

View File

@@ -0,0 +1,26 @@
package example;
/**
* Multi-line, type-level
* Javadoc.
*
* @author Oliver Drotbohm
*/
class SampleComponent {
/**
* Javadoc of {@link #on(Object)}.
*
* @param object
*/
void on(Object object) { }
/**
* Javadoc of {@link #on(String)}.
*
* @param string
*/
void on(String string) { }
void noJavadoc() {}
}

View File

@@ -29,6 +29,11 @@
<artifactId>spring-modulith-api</artifactId>
<version>1.3.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-apt</artifactId>
<version>1.3.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-core</artifactId>

View File

@@ -39,11 +39,15 @@
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
</dependency>
<dependency>
<groupId>capital.scalable</groupId>
<artifactId>spring-auto-restdocs-core</artifactId>
<version>2.0.11</version>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<optional>true</optional>
</dependency>

View File

@@ -24,11 +24,14 @@ import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.lang.Nullable;
import org.springframework.modulith.core.ApplicationModule;
import org.springframework.modulith.core.ApplicationModuleDependency;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.core.ArchitecturallyEvidentType;
import org.springframework.modulith.core.ArchitecturallyEvidentType.ReferenceMethod;
import org.springframework.modulith.core.DependencyType;
import org.springframework.modulith.core.EventType;
import org.springframework.modulith.core.FormattableType;
@@ -36,8 +39,8 @@ import org.springframework.modulith.core.Source;
import org.springframework.modulith.core.SpringBean;
import org.springframework.modulith.docs.ConfigurationProperties.ModuleProperty;
import org.springframework.modulith.docs.Documenter.CanvasOptions;
import org.springframework.modulith.docs.util.BuildSystemUtils;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;
import com.tngtech.archunit.core.domain.JavaClass;
@@ -49,7 +52,11 @@ import com.tngtech.archunit.core.domain.JavaModifier;
class Asciidoctor {
private static String PLACEHOLDER = "¯\\_(ツ)_/¯";
private static final Pattern JAVADOC_CODE = Pattern.compile("\\{\\@(?>link|code|literal)\\s(.*)\\}");
private static final Pattern JAVADOC_CODE = Pattern.compile("\\{\\@(link|code|literal)\\s*(.*?)\\}");
private static final Logger LOG = LoggerFactory.getLogger(Asciidoctor.class);
private static final Optional<DocumentationSource> DOC_SOURCE = getSpringModulithDocsSource()
.or(() -> getSpringAutoRestDocsSource());
private final ApplicationModules modules;
private final String javaDocBase;
@@ -62,10 +69,7 @@ class Asciidoctor {
this.javaDocBase = javaDocBase;
this.modules = modules;
this.docSource = Optional.of("capital.scalable.restdocs.javadoc.JavadocReaderImpl")
.filter(it -> ClassUtils.isPresent(it, Asciidoctor.class.getClassLoader()))
.map(__ -> new SpringAutoRestDocsDocumentationSource())
.map(it -> new CodeReplacingDocumentationSource(it, this));
this.docSource = DOC_SOURCE.map(it -> new CodeReplacingDocumentationSource(it, this));
}
/**
@@ -113,7 +117,8 @@ class Asciidoctor {
public String toInlineCode(SpringBean bean) {
var base = toInlineCode(bean.toArchitecturallyEvidentType());
var type = bean.toArchitecturallyEvidentType();
var base = toInlineCode(type);
var interfaces = bean.getInterfacesWithinModule();
if (interfaces.isEmpty()) {
@@ -124,7 +129,14 @@ class Asciidoctor {
.map(this::toInlineCode) //
.collect(Collectors.joining(", "));
return String.format("%s (via %s)", interfacesAsString, base);
return "%s (via %s)".formatted(interfacesAsString, base);
}
private String withDocumentation(String base, JavaClass type) {
return docSource.flatMap(it -> it.getDocumentation(type))
.map(it -> base + " -- " + it)
.orElse(base);
}
public String renderSpringBeans(ApplicationModule module, CanvasOptions options) {
@@ -160,7 +172,7 @@ class Asciidoctor {
return builder.length() == 0 ? "None" : builder.toString();
}
public String renderEvents(ApplicationModule module) {
public String renderPublishedEvents(ApplicationModule module) {
var events = module.getPublishedEvents();
@@ -172,13 +184,17 @@ class Asciidoctor {
for (EventType eventType : events) {
var documentation = docSource.flatMap(it -> it.getDocumentation(eventType.getType()))
.map(" -- "::concat);
builder.append("* ")
.append(toInlineCode(eventType.getType()));
documentation.ifPresent(builder::append);
if (!eventType.hasSources()) {
builder.append("\n");
} else {
builder.append(" created by:\n");
builder.append((documentation.isPresent() ? " C" : " c") + "reated by:\n");
}
for (Source source : eventType.getSources()) {
@@ -192,6 +208,22 @@ class Asciidoctor {
return builder.toString();
}
public String renderEventsListenedTo(ApplicationModule module) {
var builder = new StringBuilder();
module.getSpringBeans().stream()
.map(SpringBean::toArchitecturallyEvidentType)
.filter(ArchitecturallyEvidentType::isEventListener)
.forEach(it -> {
it.getReferenceMethods()
.map(method -> renderReferenceMethod(method, 0))
.forEach(builder::append);
});
return builder.toString();
}
public String renderConfigurationProperties(List<ModuleProperty> properties) {
if (properties.isEmpty()) {
@@ -232,8 +264,9 @@ class Asciidoctor {
}
public String typesToBulletPoints(List<JavaClass> types) {
return toBulletPoints(types.stream() //
.map(this::toOptionalLink));
return toBulletPoints(types.stream()
.map(it -> withDocumentation(toOptionalLink(it), it)));
}
private String toBulletPoints(Stream<String> types) {
@@ -253,8 +286,8 @@ class Asciidoctor {
private String toOptionalLink(JavaClass source, Optional<String> methodSignature) {
var module = modules.getModuleByType(source).orElse(null);
var typeAndMethod = toCode(
toTypeAndMethod(FormattableType.of(source).getAbbreviatedFullName(module), methodSignature));
var formattable = FormattableType.of(source).getAbbreviatedFullName(module);
var typeAndMethod = toCode(toTypeAndMethod(formattable, methodSignature));
if (module == null
|| !source.getModifiers().contains(JavaModifier.PUBLIC)
@@ -279,6 +312,9 @@ class Asciidoctor {
private String toInlineCode(ArchitecturallyEvidentType type) {
var javaType = type.getType();
var code = toInlineCode(javaType);
if (type.isEventListener()) {
if (!docSource.isPresent()) {
@@ -286,29 +322,32 @@ class Asciidoctor {
var referenceTypes = type.getReferenceTypes();
return String.format("%s listening to %s", //
toInlineCode(type.getType()), //
toInlineCode(javaType), //
toInlineCode(referenceTypes));
}
String header = String.format("%s listening to:\n", toInlineCode(type.getType()));
String header = "%s listening to:\n".formatted(withDocumentation(code, javaType));
return header + type.getReferenceMethods().map(it -> {
var method = it.getMethod();
Assert.isTrue(method.getRawParameterTypes().size() > 0,
() -> String.format("Method %s must have at least one parameter!", method));
var parameterType = method.getRawParameterTypes().get(0);
var isAsync = it.isAsync() ? "(async) " : "";
return docSource.flatMap(source -> source.getDocumentation(method))
.map(doc -> String.format("** %s %s-- %s", toInlineCode(parameterType), isAsync, doc))
.orElseGet(() -> String.format("** %s %s", toInlineCode(parameterType), isAsync));
}).collect(Collectors.joining("\n"));
return header + type.getReferenceMethods().map(it -> renderReferenceMethod(it, 1))
.collect(Collectors.joining("\n"));
}
return toInlineCode(type.getType());
return withDocumentation(toInlineCode(type.getType()), type.getType());
}
private String renderReferenceMethod(ReferenceMethod it, int level) {
var method = it.getMethod();
Assert.isTrue(method.getRawParameterTypes().size() > 0,
() -> String.format("Method %s must have at least one parameter!", method));
var parameterType = method.getRawParameterTypes().get(0);
var isAsync = it.isAsync() ? "(async) " : "";
var indent = "*".repeat(level + 1);
return docSource.flatMap(source -> source.getDocumentation(method))
.map(doc -> String.format("%s %s %s-- %s", indent, toInlineCode(parameterType), isAsync, doc))
.orElseGet(() -> String.format("%s %s %s", indent, toInlineCode(parameterType), isAsync));
}
private String toInlineCode(Stream<JavaClass> types) {
@@ -345,7 +384,7 @@ class Asciidoctor {
while (matcher.find()) {
String type = matcher.group(1);
String type = matcher.group(2);
source = source.replace(matcher.group(), toInlineCode(type));
}
@@ -361,7 +400,13 @@ class Asciidoctor {
var bullets = module.getDirectDependencies(modules, DependencyType.USES_COMPONENT)
.uniqueStream(ApplicationModuleDependency::getTargetType)
.map(it -> "%s (in %s)".formatted(toInlineCode(it.getTargetType()), it.getTargetModule().getDisplayName()))
.map(it -> {
var targetType = it.getTargetType();
var result = "%s (in %s)".formatted(toInlineCode(targetType), it.getTargetModule().getDisplayName());
return withDocumentation(result, targetType);
})
.map(this::toBulletPoint)
.collect(Collectors.joining("\n"));
@@ -379,4 +424,27 @@ class Asciidoctor {
public String renderGeneralInclude(String componentsFilename) {
return "include::" + componentsFilename + "[]" + System.lineSeparator();
}
@SuppressWarnings("deprecation")
private static Optional<DocumentationSource> getSpringAutoRestDocsSource() {
return BuildSystemUtils.getTargetResource("generated-javadoc-json")
.map(__ -> SpringAutoRestDocsDocumentationSource.INSTANCE)
.map(it -> {
LOG.debug("Using Javadoc generated by Spring Auto RESTDocs found in generated-javadoc-json.");
LOG.warn(
"Javadoc metadata generated by Spring Auto RESTDocs is deprecated! Switch to spring-modulith-apt instead!");
return it;
});
}
private static Optional<DocumentationSource> getSpringModulithDocsSource() {
return SpringModulithDocumentationSource.getInstance()
.map(it -> {
LOG.debug("Using Javadoc extracted by Spring Modulith in {}.",
SpringModulithDocumentationSource.getMetadataLocation());
return it;
});
}
}

View File

@@ -19,6 +19,7 @@ import java.util.Optional;
import org.springframework.util.Assert;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaMethod;
/**
@@ -30,7 +31,7 @@ import com.tngtech.archunit.core.domain.JavaMethod;
class CodeReplacingDocumentationSource implements DocumentationSource {
private final DocumentationSource delegate;
private final Asciidoctor codeSource;
private final Asciidoctor asciidoctor;
/**
* Creates a new {@link CodeReplacingDocumentationSource} for the given delegate {@link DocumentationSource} and
@@ -45,7 +46,7 @@ class CodeReplacingDocumentationSource implements DocumentationSource {
Assert.notNull(asciidoctor, "Asciidoctor must not be null!");
this.delegate = delegate;
this.codeSource = asciidoctor;
this.asciidoctor = asciidoctor;
}
/*
@@ -56,6 +57,16 @@ class CodeReplacingDocumentationSource implements DocumentationSource {
public Optional<String> getDocumentation(JavaMethod method) {
return delegate.getDocumentation(method)
.map(codeSource::toAsciidoctor);
.map(asciidoctor::toAsciidoctor);
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.docs.DocumentationSource#getDocumentation(com.tngtech.archunit.core.domain.JavaClass)
*/
@Override
public Optional<String> getDocumentation(JavaClass type) {
return delegate.getDocumentation(type)
.map(asciidoctor::toAsciidoctor);
}
}

View File

@@ -17,6 +17,7 @@ package org.springframework.modulith.docs;
import java.util.Optional;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaMethod;
/**
@@ -33,4 +34,13 @@ interface DocumentationSource {
* @return will never be {@literal null}.
*/
Optional<String> getDocumentation(JavaMethod method);
/**
* Returns the documentation to be used for the given {@link JavaClass}.
*
* @param type must not be {@literal null}.
* @return will never be {@literal null}.
* @since 1.3
*/
Optional<String> getDocumentation(JavaClass type);
}

View File

@@ -415,12 +415,12 @@ public class Documenter {
.append(addTableRow("Bean references", asciidoctor.renderBeanReferences(module), options)) //
// Aggregates
.append(addTableRow(aggregates, "Aggregate roots", mapper, options)) //
.append(addTableRow(valueTypes, "Value types", mapper, options)) //
.append(addTableRow("Aggregate roots", options, aggregates, mapper)) //
.append(addTableRow("Value types", options, valueTypes, mapper)) //
// Events
.append(addTableRow("Published events", asciidoctor.renderEvents(module), options)) //
.append(addTableRow(module.getEventsListenedTo(modules), "Events listened to", mapper, options)) //
.append(addTableRow("Published events", asciidoctor.renderPublishedEvents(module), options)) //
.append(addTableRow("Events listened to", asciidoctor.renderEventsListenedTo(module), options)) //
// Properties
.append(addTableRow("Properties",
@@ -632,8 +632,8 @@ public class Documenter {
: writeTableRow(title, content);
}
private static <T> String addTableRow(List<T> types, String header, Function<List<T>, String> mapper,
CanvasOptions options) {
private static <T> String addTableRow(String header, CanvasOptions options, List<T> types,
Function<List<T>, String> mapper) {
return options.hideEmptyLines && types.isEmpty() ? "" : writeTableRow(header, mapper.apply(types));
}

View File

@@ -15,21 +15,51 @@
*/
package org.springframework.modulith.docs;
import capital.scalable.restdocs.javadoc.JavadocReader;
import capital.scalable.restdocs.javadoc.JavadocReaderImpl;
import static org.slf4j.LoggerFactory.*;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Predicate;
import org.slf4j.Logger;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaMethod;
/**
* A {@link DocumentationSource} that uses metadata generated by Spring Auto REST Docs' Javadoc Doclet.
*
* @author Oliver Drotbohm
* @deprecated since 1.3, use {@link SpringModulithDocumentationSource} instead.
*/
class SpringAutoRestDocsDocumentationSource implements DocumentationSource {
@Deprecated
enum SpringAutoRestDocsDocumentationSource implements DocumentationSource {
private final JavadocReader reader = JavadocReaderImpl.createWithSystemProperty();
INSTANCE;
static {
Assert.isTrue(
ClassUtils.isPresent("com.fasterxml.jackson.databind.ObjectMapper",
SpringModulithDocumentationSource.class.getClassLoader()),
"Jackson is required on the classpath for Spring Auto RESTDocs generated Javadoc metadata!");
}
private final JavadocReader reader = JavadocReader.createWithSystemProperty();
/*
* (non-Javadoc)
@@ -40,4 +70,236 @@ class SpringAutoRestDocsDocumentationSource implements DocumentationSource {
return Optional.of(reader.resolveMethodComment(method.getOwner().reflect(), method.getName()))
.filter(it -> !it.isEmpty());
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.docs.DocumentationSource#getDocumentation(com.tngtech.archunit.core.domain.JavaClass)
*/
@Override
public Optional<String> getDocumentation(JavaClass type) {
return Optional.of(reader.resolveClassComment(type.reflect()))
.filter(Predicate.not(String::isEmpty));
}
static class ClassJavadoc {
private String comment;
private Map<String, MethodJavadoc> methods = new HashMap<>();
public String getClassComment() {
return comment;
}
public String getMethodComment(String methodName) {
MethodJavadoc methodJavadoc = methods.get(methodName);
if (methodJavadoc != null) {
return trimToEmpty(methodJavadoc.getComment());
} else {
return "";
}
}
private static String trimToEmpty(@Nullable String source) {
return source == null ? "" : source.trim();
}
static class MethodJavadoc {
private String comment;
private Map<String, String> parameters = new HashMap<>();
private Map<String, String> tags = new HashMap<>();
public String getComment() {
return comment;
}
public String getParameterComment(String parameterName) {
return parameters.get(parameterName);
}
public String getTag(String tagName) {
return tags.get(tagName);
}
}
}
static class JavadocReader {
private static final Logger log = getLogger(JavadocReader.class);
private static final String PATH_DELIMITER = ",";
private static final String JAVADOC_JSON_DIR_PROPERTY = "org.springframework.restdocs.javadocJsonDir";
private final Map<String, ClassJavadoc> classCache = new ConcurrentHashMap<>();
private final ObjectMapper mapper;
private final List<File> absoluteBaseDirs;
private JavadocReader(ObjectMapper mapper, List<File> absoluteBaseDirs) {
this.mapper = mapper;
this.absoluteBaseDirs = absoluteBaseDirs;
}
public static JavadocReader createWithSystemProperty() {
String jsonDir = System.getProperties().getProperty(JAVADOC_JSON_DIR_PROPERTY);
if (!StringUtils.hasText(jsonDir)) {
jsonDir = getDefaultJsonDirectory();
}
return new JavadocReader(objectMapper(), toAbsoluteDirs(jsonDir));
}
private static String getDefaultJsonDirectory() {
if (new File("pom.xml").exists()) {
return "target/generated-javadoc-json";
}
return "build/generated-javadoc-json";
}
/**
* Used for testing.
*/
static JavadocReader createWith(String javadocJsonDir) {
return new JavadocReader(objectMapper(), toAbsoluteDirs(javadocJsonDir));
}
public String resolveMethodComment(Class<?> javaBaseClass, final String javaMethodName) {
return resolveCommentFromClassHierarchy(javaBaseClass,
classJavadoc -> classJavadoc.getMethodComment(javaMethodName));
}
public String resolveClassComment(Class<?> javaBaseClass) {
return classJavadoc(javaBaseClass).getClassComment();
}
private ClassJavadoc classJavadoc(Class<?> clazz) {
String relativePath = classToRelativePath(clazz);
ClassJavadoc classJavadocFromCache = classCache.get(relativePath);
if (classJavadocFromCache != null) {
return classJavadocFromCache;
} else {
ClassJavadoc classJavadoc = readFiles(clazz, relativePath);
classCache.put(relativePath, classJavadoc);
return classJavadoc;
}
}
private String classToRelativePath(Class<?> clazz) {
String packageName = clazz.getPackage().getName();
String packageDir = packageName.replace(".", File.separator);
String className = clazz.getCanonicalName().replaceAll(packageName + "\\.?", "");
return new File(packageDir, className + ".json").getPath();
}
private ClassJavadoc readFiles(Class<?> clazz, String relativePath) {
if (absoluteBaseDirs.isEmpty()) {
// No absolute directory is configured and thus we try to find the file relative.
ClassJavadoc classJavadoc = readJson(new File(relativePath));
if (classJavadoc != null) {
return classJavadoc;
}
} else {
// Try to find the file in all configured directories.
for (File dir : absoluteBaseDirs) {
ClassJavadoc classJavadoc = readJson(new File(dir, relativePath));
if (classJavadoc != null) {
return classJavadoc;
}
}
}
// might be in some jar on the classpath
URL url = getClass().getClassLoader().getResource(relativePath);
if (url != null) {
return readJson(url);
}
log.debug("No Javadoc found for class {} in any of the found JSON files", clazz.getCanonicalName());
return new ClassJavadoc();
}
private ClassJavadoc readJson(File docSource) {
try {
return mapper
.readerFor(ClassJavadoc.class)
.readValue(docSource);
} catch (FileNotFoundException e) {
// Ignored as we might try more than one file and we warn if no Javadoc file
// is found at the end.
} catch (IOException e) {
log.error("Failed to read file {}", docSource.getName(), e);
}
return null;
}
private ClassJavadoc readJson(URL docSource) {
try {
return mapper
.readerFor(ClassJavadoc.class)
.readValue(docSource);
} catch (IOException e) {
log.error("Failed to read url {}", docSource, e);
}
return null;
}
private static ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
mapper.setVisibility(mapper.getSerializationConfig().getDefaultVisibilityChecker()
.withFieldVisibility(JsonAutoDetect.Visibility.ANY)
.withGetterVisibility(JsonAutoDetect.Visibility.NONE)
.withSetterVisibility(JsonAutoDetect.Visibility.NONE)
.withCreatorVisibility(JsonAutoDetect.Visibility.NONE));
return mapper;
}
private static List<File> toAbsoluteDirs(String javadocJsonDirs) {
List<File> absoluteDirs = new ArrayList<>();
if (StringUtils.hasText(javadocJsonDirs)) {
String[] dirs = javadocJsonDirs.split(PATH_DELIMITER);
for (String dir : dirs) {
if (StringUtils.hasText(dir)) {
absoluteDirs.add(new File(dir.trim()).getAbsoluteFile());
}
}
}
return absoluteDirs;
}
/**
* Walks up the class hierarchy and interfaces until a comment is found or top most class is reached.
* <p>
* Javadoc on super classes and Javadoc on interfaces of super classes has precedence over the Javadoc on direct
* interfaces of the class. This is only important in the rare case of competing Javadoc comments.
* <p>
* As we do not know the full method signature here, we can not check whether a method in the super class actually
* overwrites the given method. However, the Javadoc model ignores method signatures anyway and it should not cause
* issues for the usual use case.
*/
private String resolveCommentFromClassHierarchy(Class<?> javaBaseClass,
CommentExtractor commentExtractor) {
String comment = commentExtractor.comment(classJavadoc(javaBaseClass));
if (StringUtils.hasText(comment)) {
// Direct Javadoc on a method always wins.
return comment;
}
// Super class has precedence over interfaces, but this also means that interfaces
// of super classes have precedence over interfaces of the class itself.
if (javaBaseClass.getSuperclass() != null) {
String superClassComment = resolveCommentFromClassHierarchy(javaBaseClass.getSuperclass(),
commentExtractor);
if (StringUtils.hasText(superClassComment)) {
return superClassComment;
}
}
for (Class<?> i : javaBaseClass.getInterfaces()) {
String interfaceComment = resolveCommentFromClassHierarchy(i, commentExtractor);
if (StringUtils.hasText(interfaceComment)) {
return interfaceComment;
}
}
return "";
}
private interface CommentExtractor {
String comment(ClassJavadoc classJavadoc);
}
}
}

View File

@@ -0,0 +1,165 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.modulith.docs;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.boot.json.BasicJsonParser;
import org.springframework.core.io.Resource;
import org.springframework.lang.Nullable;
import org.springframework.modulith.docs.metadata.MethodMetadata;
import org.springframework.modulith.docs.metadata.TypeMetadata;
import org.springframework.modulith.docs.util.BuildSystemUtils;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaMethod;
/**
* A {@link DocumentationSource} using metadata found in {@value #METADATA_FILE}, usually generated via
* {@code spring-modulith-apt}.
*
* @author Oliver Drotbohm
* @since 1.3
* @see the {@code spring-modulith-apt} artifact.
*/
class SpringModulithDocumentationSource implements DocumentationSource {
private static final String METADATA_FILE = "generated-spring-modulith/javadoc.json";
private static final Optional<DocumentationSource> INSTANCE = BuildSystemUtils
.getTargetResource(METADATA_FILE).map(SpringModulithDocumentationSource::new);
private Collection<TypeMetadata> metadata;
/**
* Creates a new {@link SpringModulithDocumentationSource} for the given file.
*
* @param resource must not be {@literal null}.
*/
private SpringModulithDocumentationSource(Resource resource) {
Assert.notNull(resource, "Resource must not be null!");
this.metadata = from(resource);
}
/**
* Creates a new {@link DocumentationSource} if the backing metadata file (in {@value #METADATA_FILE}) is present.
*
* @return will never be {@literal null}.
*/
public static Optional<DocumentationSource> getInstance() {
return INSTANCE;
}
/**
* Returns the location of the metadata file.
*
* @return will never be {@literal null}.
*/
public static String getMetadataLocation() {
return METADATA_FILE;
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.docs.DocumentationSource#getDocumentation(com.tngtech.archunit.core.domain.JavaClass)
*/
@Override
public Optional<String> getDocumentation(JavaClass type) {
return metadata.stream()
.filter(it -> it.name().equals(type.getName()))
.findFirst()
.map(TypeMetadata::comment)
.filter(StringUtils::hasText);
}
/*
* (non-Javadoc)
* @see org.springframework.modulith.docs.DocumentationSource#getDocumentation(com.tngtech.archunit.core.domain.JavaMethod)
*/
@Override
public Optional<String> getDocumentation(JavaMethod method) {
var owner = method.getOwner();
return metadata.stream()
.filter(it -> it.name().equals(owner.getName()))
.findFirst()
.stream()
.flatMap(it -> it.methods().stream())
.filter(it -> it.hasSignatureOf(method.reflect()))
.findFirst()
.map(MethodMetadata::comment)
.filter(StringUtils::hasText);
}
@SuppressWarnings("unchecked")
private static Collection<TypeMetadata> from(Resource resource) {
try {
var content = resource.getContentAsString(StandardCharsets.UTF_8);
var parsed = new BasicJsonParser().parseList(content);
return parsed.stream()
.map(it -> it instanceof TypeMetadata metadata ? metadata : typeMetadata((Map<String, Object>) it))
.toList();
} catch (IOException o_O) {
throw new RuntimeException(o_O);
}
}
@SuppressWarnings("unchecked")
private static TypeMetadata typeMetadata(Map<String, Object> source) {
var methods = source.containsKey("methods")
? ((List<Map<String, Object>>) source.get("methods")).stream()
.map(SpringModulithDocumentationSource::methodMetadata)
.toList()
: Collections.<MethodMetadata> emptyList();
return new TypeMetadata(
source.get("name").toString(),
getString(source, "comment"),
methods);
}
private static MethodMetadata methodMetadata(Map<String, Object> source) {
return new MethodMetadata(
source.get("name").toString(),
source.get("signature").toString(),
getString(source, "comment"));
}
@Nullable
private static String getString(Map<String, Object> source, String key) {
Object result = source.get(key);
return result == null ? null : result.toString();
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.modulith.docs.metadata;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.stream.Collectors;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* Metadata about a Java {@link Method}.
*
* @author Oliver Drotbohm
* @since 1.3
*/
public record MethodMetadata(String name, String signature, @Nullable String comment) {
/**
* Returns whether the method represented has the same signature as the given one.
*
* @param method must not be {@literal null}.
* @return
*/
public boolean hasSignatureOf(Method method) {
Assert.notNull(method, "Method must not be null!");
var parameters = Arrays.stream(method.getParameterTypes())
.map(Class::getName)
.collect(Collectors.joining(", ", "(", ")"));
return signature.equals(method.getName().concat(parameters));
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.modulith.docs.metadata;
import java.util.List;
import org.springframework.lang.Nullable;
/**
* Metadata about a Java type.
*
* @author Oliver Drotbohm
* @since 1.3
*/
public record TypeMetadata(String name, @Nullable String comment, List<MethodMetadata> methods) {}

View File

@@ -0,0 +1,72 @@
/*
* Copyright 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.modulith.docs.util;
import java.io.File;
import java.util.Optional;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.util.Assert;
/**
* Utilities to detect the build system used.
*
* @author Oliver Drotbohm
* @since 1.3
*/
public class BuildSystemUtils {
/**
* Returns a path to a resource in the build target folder.
*
* @param path must not be {@literal null} or empty.
* @return will never be {@literal null}.
*/
public static String getTarget(String path) {
Assert.notNull(path, "Path must not be null!");
return getTargetFolder() + (path.startsWith("/") ? path : "/" + path);
}
/**
* Returns a {@link Resource} in the build target folder.
*
* @param path must not be {@literal null} or empty.
* @return will never be {@literal null}.
*/
public static Optional<Resource> getTargetResource(String path) {
return Optional.<Resource> of(new FileSystemResource(getTarget(path))).filter(Resource::exists);
}
/**
* Returns the path to the folder containing test classes.
*
* @return will never be {@literal null}.
*/
public static String getTestTarget() {
return isMaven() ? "target/test-classes" : "build/classes/java/test";
}
private static String getTargetFolder() {
return isMaven() ? "target" : "build";
}
private static boolean isMaven() {
return new File("pom.xml").exists();
}
}

View File

@@ -24,6 +24,12 @@
<version>1.3.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-apt</artifactId>
<version>1.3.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.springframework.modulith</groupId>
<artifactId>spring-modulith-core</artifactId>

View File

@@ -101,6 +101,7 @@ Usually propagated in CI environments to consider all changes since the last suc
|`spring-modulith-starter-core`
|`compile`
a|* `spring-modulith-api`
* `spring-modulith-apt`
* `spring-modulith-moments`
* `spring-modulith-core` (runtime)
* `spring-modulith-runtime` (runtime)
@@ -155,6 +156,7 @@ a|* `spring-modulith-docs`
|Module|Typical scope|Description
|`spring-modulith-actuator`|`runtime`|A Spring Boot actuator to expose the application module structure via an actuator.
|`spring-modulith-api`|`compile`|The abstractions to be used in your production code to customize Spring Modulith's default behavior.
|`spring-modulith-apt`|`compile`|An annotation processor to extract Javadoc for inclusion in xref:documentation.adoc#application-module-canvas[Application Module Canvases].
|`spring-modulith-core`|`runtime`|The core application module model and API.
|`spring-modulith-docs`|`test`|The `Documenter` API to create Asciidoctor and PlantUML documentation from the module model.
|`spring-modulith-events-amqp`|`runtime`|Event externalization support for AMQP.