diff --git a/pom.xml b/pom.xml index ed6d7687..908dd89d 100644 --- a/pom.xml +++ b/pom.xml @@ -20,17 +20,18 @@ spring-modulith-actuator spring-modulith-api + spring-modulith-apt spring-modulith-bom spring-modulith-core spring-modulith-docs spring-modulith-events + spring-modulith-junit spring-modulith-moments spring-modulith-observability spring-modulith-runtime spring-modulith-starters spring-modulith-test - spring-modulith-junit - + diff --git a/spring-modulith-apt/pom.xml b/spring-modulith-apt/pom.xml new file mode 100644 index 00000000..1a8ff3b4 --- /dev/null +++ b/spring-modulith-apt/pom.xml @@ -0,0 +1,131 @@ + + 4.0.0 + + + org.springframework.modulith + spring-modulith + 1.3.0-SNAPSHOT + + + Spring Modulith - APT + spring-modulith-apt + + + org.springframework.modulith.apt + + + + + + io.toolisticon.aptk + aptk-tools + 0.28.0 + + + + org.springframework.boot + spring-boot + + + + org.springframework.modulith + spring-modulith-docs + ${project.version} + + + + io.toolisticon.cute + cute + 1.7.0 + test + + + + org.junit.jupiter + junit-jupiter + test + + + + org.assertj + assertj-core + test + + + + com.jayway.jsonpath + json-path + test + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + default-compile + + -proc:none + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + + io.toolisticon.aptk:* + + + + + + io.toolisticon.aptk + org.springframework.modulith.aptk + + + + ${project.build.directory}/dependency-reduced-pom.xml + true + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + annotation-processor + + + + + + + + + \ No newline at end of file diff --git a/spring-modulith-apt/src/main/java/org/springframework/modulith/apt/SpringModulithProcessor.java b/spring-modulith-apt/src/main/java/org/springframework/modulith/apt/SpringModulithProcessor.java new file mode 100644 index 00000000..a9b22b83 --- /dev/null +++ b/spring-modulith-apt/src/main/java/org/springframework/modulith/apt/SpringModulithProcessor.java @@ -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 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 metadata = new HashSet<>(); + + static { + JSON_LOCATION = BuildSystemUtils.getTarget("generated-spring-modulith/javadoc.json"); + } + + /* + * (non-Javadoc) + * @see javax.annotation.processing.Processor#getSupportedAnnotationTypes() + */ + @Override + public Set getSupportedAnnotationTypes() { + return Collections.singleton("*"); + } + + /* + * (non-Javadoc) + * @see javax.annotation.processing.Processor#getSupportedOptions() + */ + @Override + public Set 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 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 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. of(inner -> { + inner.add("name", MethodMetadata::name); + inner.add("signature", MethodMetadata::signature); + inner.add("comment", MethodMetadata::comment) + .whenNotNull(); + }); + + var typeJson = JsonWriter. 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 handle(TypeElementWrapper type) { + return getTypes(type).flatMap(this::toMetadata); + } + + private Stream 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. getFirstEnclosingElementOfKind(element.unwrap(), + ElementKind.CLASS, + ElementKind.INTERFACE, + ElementKind.RECORD); + + return enclosing != null + ? getQualifiedName(TypeElementWrapper.wrap(enclosing)) + "$" + element.getSimpleName() + : element.getQualifiedName(); + } + + private Stream 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 getTypes(TypeElementWrapper type) { + + var enclosed = type.getEnclosedElements().stream() + .filter(ElementWrapper::isTypeElement) + .map(TypeElementWrapper::toTypeElement); + + return Stream.concat(Stream.of(type), enclosed); + } +} diff --git a/spring-modulith-apt/src/main/resources/META-INF/services/javax.annotation.processing.Processor b/spring-modulith-apt/src/main/resources/META-INF/services/javax.annotation.processing.Processor new file mode 100644 index 00000000..8bdf2669 --- /dev/null +++ b/spring-modulith-apt/src/main/resources/META-INF/services/javax.annotation.processing.Processor @@ -0,0 +1 @@ +org.springframework.modulith.apt.SpringModulithProcessor diff --git a/spring-modulith-apt/src/test/java/org/springframework/modulith/apt/SpringModulithProcessorUnitTests.java b/spring-modulith-apt/src/test/java/org/springframework/modulith/apt/SpringModulithProcessorUnitTests.java new file mode 100644 index 00000000..90094423 --- /dev/null +++ b/spring-modulith-apt/src/test/java/org/springframework/modulith/apt/SpringModulithProcessorUnitTests.java @@ -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"; + } +} diff --git a/spring-modulith-apt/src/test/resources/example/SampleComponent.java b/spring-modulith-apt/src/test/resources/example/SampleComponent.java new file mode 100644 index 00000000..900f2b46 --- /dev/null +++ b/spring-modulith-apt/src/test/resources/example/SampleComponent.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() {} +} diff --git a/spring-modulith-bom/pom.xml b/spring-modulith-bom/pom.xml index d3d340e9..0a5abdf5 100644 --- a/spring-modulith-bom/pom.xml +++ b/spring-modulith-bom/pom.xml @@ -29,6 +29,11 @@ spring-modulith-api 1.3.0-SNAPSHOT + + org.springframework.modulith + spring-modulith-apt + 1.3.0-SNAPSHOT + org.springframework.modulith spring-modulith-core diff --git a/spring-modulith-docs/pom.xml b/spring-modulith-docs/pom.xml index 64818457..259d0cfe 100644 --- a/spring-modulith-docs/pom.xml +++ b/spring-modulith-docs/pom.xml @@ -39,11 +39,15 @@ com.jayway.jsonpath json-path - + - capital.scalable - spring-auto-restdocs-core - 2.0.11 + org.springframework.boot + spring-boot + + + + com.fasterxml.jackson.core + jackson-databind true diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java index 1cb16dce..348061d4 100644 --- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Asciidoctor.java @@ -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 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 properties) { if (properties.isEmpty()) { @@ -232,8 +264,9 @@ class Asciidoctor { } public String typesToBulletPoints(List types) { - return toBulletPoints(types.stream() // - .map(this::toOptionalLink)); + + return toBulletPoints(types.stream() + .map(it -> withDocumentation(toOptionalLink(it), it))); } private String toBulletPoints(Stream types) { @@ -253,8 +286,8 @@ class Asciidoctor { private String toOptionalLink(JavaClass source, Optional 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 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 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 getSpringModulithDocsSource() { + + return SpringModulithDocumentationSource.getInstance() + .map(it -> { + LOG.debug("Using Javadoc extracted by Spring Modulith in {}.", + SpringModulithDocumentationSource.getMetadataLocation()); + return it; + }); + } } diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/CodeReplacingDocumentationSource.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/CodeReplacingDocumentationSource.java index 29fd2d40..3d076d81 100644 --- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/CodeReplacingDocumentationSource.java +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/CodeReplacingDocumentationSource.java @@ -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 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 getDocumentation(JavaClass type) { + return delegate.getDocumentation(type) + .map(asciidoctor::toAsciidoctor); } } diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSource.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSource.java index 35fa35bb..ee661277 100644 --- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSource.java +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/DocumentationSource.java @@ -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 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 getDocumentation(JavaClass type); } diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java index 662dbcd6..93ed13da 100644 --- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/Documenter.java @@ -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 String addTableRow(List types, String header, Function, String> mapper, - CanvasOptions options) { + private static String addTableRow(String header, CanvasOptions options, List types, + Function, String> mapper) { return options.hideEmptyLines && types.isEmpty() ? "" : writeTableRow(header, mapper.apply(types)); } diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/SpringAutoRestDocsDocumentationSource.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/SpringAutoRestDocsDocumentationSource.java index 9d23c0b7..a4471296 100644 --- a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/SpringAutoRestDocsDocumentationSource.java +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/SpringAutoRestDocsDocumentationSource.java @@ -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 getDocumentation(JavaClass type) { + return Optional.of(reader.resolveClassComment(type.reflect())) + .filter(Predicate.not(String::isEmpty)); + } + + static class ClassJavadoc { + + private String comment; + private Map 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 parameters = new HashMap<>(); + private Map 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 classCache = new ConcurrentHashMap<>(); + private final ObjectMapper mapper; + private final List absoluteBaseDirs; + + private JavadocReader(ObjectMapper mapper, List 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 toAbsoluteDirs(String javadocJsonDirs) { + List 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. + *

+ * 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. + *

+ * 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); + } + } } diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/SpringModulithDocumentationSource.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/SpringModulithDocumentationSource.java new file mode 100644 index 00000000..dfae4aeb --- /dev/null +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/SpringModulithDocumentationSource.java @@ -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 INSTANCE = BuildSystemUtils + .getTargetResource(METADATA_FILE).map(SpringModulithDocumentationSource::new); + + private Collection 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 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 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 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 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) it)) + .toList(); + + } catch (IOException o_O) { + throw new RuntimeException(o_O); + } + } + + @SuppressWarnings("unchecked") + private static TypeMetadata typeMetadata(Map source) { + + var methods = source.containsKey("methods") + ? ((List>) source.get("methods")).stream() + .map(SpringModulithDocumentationSource::methodMetadata) + .toList() + : Collections. emptyList(); + + return new TypeMetadata( + source.get("name").toString(), + getString(source, "comment"), + methods); + } + + private static MethodMetadata methodMetadata(Map source) { + + return new MethodMetadata( + source.get("name").toString(), + source.get("signature").toString(), + getString(source, "comment")); + } + + @Nullable + private static String getString(Map source, String key) { + + Object result = source.get(key); + + return result == null ? null : result.toString(); + } +} diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/metadata/MethodMetadata.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/metadata/MethodMetadata.java new file mode 100644 index 00000000..6b2629f1 --- /dev/null +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/metadata/MethodMetadata.java @@ -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)); + } +} diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/metadata/TypeMetadata.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/metadata/TypeMetadata.java new file mode 100644 index 00000000..6902fc61 --- /dev/null +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/metadata/TypeMetadata.java @@ -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 methods) {} diff --git a/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/util/BuildSystemUtils.java b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/util/BuildSystemUtils.java new file mode 100644 index 00000000..62a3a2a2 --- /dev/null +++ b/spring-modulith-docs/src/main/java/org/springframework/modulith/docs/util/BuildSystemUtils.java @@ -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 getTargetResource(String path) { + return Optional. 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(); + } +} diff --git a/spring-modulith-starters/spring-modulith-starter-core/pom.xml b/spring-modulith-starters/spring-modulith-starter-core/pom.xml index 501c73bf..e035fad7 100644 --- a/spring-modulith-starters/spring-modulith-starter-core/pom.xml +++ b/spring-modulith-starters/spring-modulith-starter-core/pom.xml @@ -24,6 +24,12 @@ 1.3.0-SNAPSHOT + + org.springframework.modulith + spring-modulith-apt + 1.3.0-SNAPSHOT + + org.springframework.modulith spring-modulith-core diff --git a/src/docs/antora/modules/ROOT/pages/appendix.adoc b/src/docs/antora/modules/ROOT/pages/appendix.adoc index f3ef222e..44221311 100644 --- a/src/docs/antora/modules/ROOT/pages/appendix.adoc +++ b/src/docs/antora/modules/ROOT/pages/appendix.adoc @@ -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.