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:
5
pom.xml
5
pom.xml
@@ -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
131
spring-modulith-apt/pom.xml
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
org.springframework.modulith.apt.SpringModulithProcessor
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user