GH-1 - Initial port of Moduliths project.
Basically the state of commit c7cf939 of https://github.com/moduliths/moduliths for further development under the Spring umbrella.
This commit is contained in:
72
moduliths-docs/pom.xml
Normal file
72
moduliths-docs/pom.xml
Normal file
@@ -0,0 +1,72 @@
|
||||
<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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.moduliths</groupId>
|
||||
<artifactId>moduliths</artifactId>
|
||||
<version>1.4.0-SNAPSHOT</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
<name>Moduliths - Docs</name>
|
||||
<artifactId>moduliths-docs</artifactId>
|
||||
|
||||
<properties>
|
||||
<module.name>org.moduliths.docs</module.name>
|
||||
<structurizr.version>1.3.0</structurizr.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>moduliths-core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>${project.groupId}</groupId>
|
||||
<artifactId>moduliths-sample</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.structurizr</groupId>
|
||||
<artifactId>structurizr-core</artifactId>
|
||||
<version>1.9.7</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.structurizr</groupId>
|
||||
<artifactId>structurizr-plantuml</artifactId>
|
||||
<version>1.6.3</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.structurizr</groupId>
|
||||
<artifactId>structurizr-export</artifactId>
|
||||
<version>1.2.0</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.jayway.jsonpath</groupId>
|
||||
<artifactId>json-path</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>capital.scalable</groupId>
|
||||
<artifactId>spring-auto-restdocs-core</artifactId>
|
||||
<version>2.0.8</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
358
moduliths-docs/src/main/java/org/moduliths/docs/Asciidoctor.java
Normal file
358
moduliths-docs/src/main/java/org/moduliths/docs/Asciidoctor.java
Normal file
@@ -0,0 +1,358 @@
|
||||
/*
|
||||
* Copyright 2020 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.moduliths.docs;
|
||||
|
||||
import static org.springframework.util.ClassUtils.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.moduliths.docs.ConfigurationProperties.ModuleProperty;
|
||||
import org.moduliths.docs.Documenter.CanvasOptions;
|
||||
import org.moduliths.docs.Documenter.CanvasOptions.Groupings;
|
||||
import org.moduliths.model.ArchitecturallyEvidentType;
|
||||
import org.moduliths.model.EventType;
|
||||
import org.moduliths.model.FormatableJavaClass;
|
||||
import org.moduliths.model.Module;
|
||||
import org.moduliths.model.Modules;
|
||||
import org.moduliths.model.Source;
|
||||
import org.moduliths.model.SpringBean;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ClassUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClass;
|
||||
import com.tngtech.archunit.core.domain.JavaMethod;
|
||||
import com.tngtech.archunit.core.domain.JavaModifier;
|
||||
|
||||
/**
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
class Asciidoctor {
|
||||
|
||||
private static String PLACEHOLDER = "¯\\_(ツ)_/¯";
|
||||
private static final Pattern JAVADOC_CODE = Pattern.compile("\\{\\@(?>link|code|literal)\\s(.*)\\}");
|
||||
|
||||
private final Modules modules;
|
||||
private final String javaDocBase;
|
||||
private final Optional<DocumentationSource> docSource;
|
||||
|
||||
private Asciidoctor(Modules modules, String javaDocBase) {
|
||||
|
||||
Assert.notNull(modules, "Modules must not be null!");
|
||||
Assert.hasText(javaDocBase, "Javadoc base must not be null or empty!");
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link Asciidoctor} instance for the given {@link Modules} and Javadoc base URI.
|
||||
*
|
||||
* @param modules must not be {@literal null}.
|
||||
* @param javadocBase can be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public static Asciidoctor withJavadocBase(Modules modules, @Nullable String javadocBase) {
|
||||
return new Asciidoctor(modules, javadocBase == null ? PLACEHOLDER : javadocBase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link Asciidoctor} instance for the given {@link Modules}.
|
||||
*
|
||||
* @param modules must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public static Asciidoctor withoutJavadocBase(Modules modules) {
|
||||
return new Asciidoctor(modules, PLACEHOLDER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns the given source string into inline code.
|
||||
*
|
||||
* @param source must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public String toInlineCode(String source) {
|
||||
|
||||
String[] parts = source.split("#");
|
||||
|
||||
String type = parts[0];
|
||||
Optional<String> methodSignature = parts.length == 2 ? Optional.of(parts[1]) : Optional.empty();
|
||||
|
||||
return modules.getModuleByType(type)
|
||||
.flatMap(it -> it.getType(type))
|
||||
.map(it -> toOptionalLink(it, methodSignature))
|
||||
.orElseGet(() -> String.format("`%s`", type));
|
||||
}
|
||||
|
||||
public String toInlineCode(JavaClass type) {
|
||||
return toOptionalLink(type);
|
||||
}
|
||||
|
||||
public String toInlineCode(SpringBean bean) {
|
||||
|
||||
String base = toInlineCode(bean.toArchitecturallyEvidentType());
|
||||
|
||||
List<JavaClass> interfaces = bean.getInterfacesWithinModule();
|
||||
|
||||
if (interfaces.isEmpty()) {
|
||||
return base;
|
||||
}
|
||||
|
||||
String interfacesAsString = interfaces.stream() //
|
||||
.map(this::toInlineCode) //
|
||||
.collect(Collectors.joining(", "));
|
||||
|
||||
return String.format("%s implementing %s", base, interfacesAsString);
|
||||
}
|
||||
|
||||
public String renderSpringBeans(CanvasOptions options, Module module) {
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
Groupings groupings = options.groupBeans(module);
|
||||
|
||||
if (groupings.hasOnlyFallbackGroup()) {
|
||||
return toBulletPoints(groupings.byGrouping(CanvasOptions.FALLBACK_GROUP));
|
||||
}
|
||||
|
||||
groupings.forEach((grouping, beans) -> {
|
||||
|
||||
if (beans.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (builder.length() != 0) {
|
||||
builder.append("\n\n");
|
||||
}
|
||||
|
||||
builder.append("_").append(grouping.getName()).append("_");
|
||||
|
||||
if (grouping.getDescription() != null) {
|
||||
builder.append(" -- ").append(grouping.getDescription());
|
||||
}
|
||||
|
||||
builder.append("\n\n");
|
||||
builder.append(toBulletPoints(beans));
|
||||
|
||||
});
|
||||
|
||||
return builder.length() == 0 ? "None" : builder.toString();
|
||||
}
|
||||
|
||||
public String renderEvents(Module module) {
|
||||
|
||||
List<EventType> events = module.getPublishedEvents();
|
||||
|
||||
if (events.isEmpty()) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
for (EventType eventType : events) {
|
||||
|
||||
builder.append("* ")
|
||||
.append(toInlineCode(eventType.getType()));
|
||||
|
||||
if (!eventType.hasSources()) {
|
||||
builder.append("\n");
|
||||
} else {
|
||||
builder.append(" created by:\n");
|
||||
}
|
||||
|
||||
for (Source source : eventType.getSources()) {
|
||||
|
||||
builder.append("** ")
|
||||
.append(toInlineCode(source.toString(module)))
|
||||
.append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public String renderConfigurationProperties(Module module, List<ModuleProperty> properties) {
|
||||
|
||||
if (properties.isEmpty()) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
Stream<String> stream = properties.stream()
|
||||
.map(it -> {
|
||||
|
||||
StringBuilder builder = new StringBuilder()
|
||||
.append(toCode(it.getName()))
|
||||
.append(" -- ")
|
||||
.append(toInlineCode(it.getType()));
|
||||
|
||||
String defaultValue = it.getDefaultValue();
|
||||
|
||||
if (defaultValue != null && StringUtils.hasText(defaultValue)) {
|
||||
|
||||
builder = builder.append(", default ")
|
||||
.append(toInlineCode(defaultValue))
|
||||
.append("");
|
||||
}
|
||||
|
||||
String description = it.getDescription();
|
||||
|
||||
if (description != null && StringUtils.hasText(description)) {
|
||||
builder = builder.append(". ")
|
||||
.append(toAsciidoctor(description));
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
});
|
||||
|
||||
return toBulletPoints(stream);
|
||||
}
|
||||
|
||||
private String toBulletPoints(List<SpringBean> beans) {
|
||||
return toBulletPoints(beans.stream().map(this::toInlineCode));
|
||||
}
|
||||
|
||||
public String typesToBulletPoints(List<JavaClass> types) {
|
||||
return toBulletPoints(types.stream() //
|
||||
.map(this::toOptionalLink));
|
||||
}
|
||||
|
||||
private String toBulletPoints(Stream<String> types) {
|
||||
|
||||
return types//
|
||||
.collect(Collectors.joining("\n* ", "* ", ""));
|
||||
}
|
||||
|
||||
public String toBulletPoint(String source) {
|
||||
return String.format("* %s", source);
|
||||
}
|
||||
|
||||
private String toOptionalLink(JavaClass source) {
|
||||
return toOptionalLink(source, Optional.empty());
|
||||
}
|
||||
|
||||
private String toOptionalLink(JavaClass source, Optional<String> methodSignature) {
|
||||
|
||||
Module module = modules.getModuleByType(source).orElse(null);
|
||||
String typeAndMethod = toCode(
|
||||
toTypeAndMethod(FormatableJavaClass.of(source).getAbbreviatedFullName(module), methodSignature));
|
||||
|
||||
if (module == null
|
||||
|| !source.getModifiers().contains(JavaModifier.PUBLIC)
|
||||
|| !module.contains(source)) {
|
||||
return typeAndMethod;
|
||||
}
|
||||
|
||||
String classPath = convertClassNameToResourcePath(source.getFullName()) //
|
||||
.replace('$', '.');
|
||||
|
||||
return Optional.ofNullable(javaDocBase == PLACEHOLDER ? null : javaDocBase) //
|
||||
.map(it -> it.concat("/").concat(classPath).concat(".html")) //
|
||||
.map(it -> toLink(typeAndMethod, it)) //
|
||||
.orElseGet(() -> typeAndMethod);
|
||||
}
|
||||
|
||||
private static String toTypeAndMethod(String type, Optional<String> methodSignature) {
|
||||
return methodSignature
|
||||
.map(it -> type.concat("#").concat(it))
|
||||
.orElse(type);
|
||||
}
|
||||
|
||||
private String toInlineCode(ArchitecturallyEvidentType type) {
|
||||
|
||||
if (type.isEventListener()) {
|
||||
|
||||
if (!docSource.isPresent()) {
|
||||
|
||||
Stream<JavaClass> referenceTypes = type.getReferenceTypes();
|
||||
|
||||
return String.format("%s listening to %s", //
|
||||
toInlineCode(type.getType()), //
|
||||
toInlineCode(referenceTypes));
|
||||
}
|
||||
|
||||
String header = String.format("%s listening to:\n", toInlineCode(type.getType()));
|
||||
|
||||
return header + type.getReferenceMethods().map(it -> {
|
||||
|
||||
JavaMethod method = it.getMethod();
|
||||
Assert.isTrue(method.getRawParameterTypes().size() > 0,
|
||||
() -> String.format("Method %s must have at least one parameter!", method));
|
||||
|
||||
JavaClass parameterType = it.getMethod().getRawParameterTypes().get(0);
|
||||
String isAsync = it.isAsync() ? "(async) " : "";
|
||||
|
||||
return docSource.flatMap(source -> source.getDocumentation(it.getMethod()))
|
||||
.map(doc -> String.format("** %s %s-- %s", toInlineCode(parameterType), isAsync, doc))
|
||||
.orElseGet(() -> String.format("** %s %s", toInlineCode(parameterType), isAsync));
|
||||
|
||||
}).collect(Collectors.joining("\n"));
|
||||
}
|
||||
|
||||
return toInlineCode(type.getType());
|
||||
}
|
||||
|
||||
private String toInlineCode(Stream<JavaClass> types) {
|
||||
|
||||
return types.map(this::toInlineCode) //
|
||||
.collect(Collectors.joining(", "));
|
||||
}
|
||||
|
||||
private static String toLink(String source, String href) {
|
||||
return String.format("link:%s[%s]", href, source);
|
||||
}
|
||||
|
||||
private static String toCode(String source) {
|
||||
return String.format("`%s`", source);
|
||||
}
|
||||
|
||||
public static String startTable(String tableSpec) {
|
||||
return String.format("[%s]\n|===\n", tableSpec);
|
||||
}
|
||||
|
||||
public static String startOrEndTable() {
|
||||
return "|===\n";
|
||||
}
|
||||
|
||||
public static String writeTableRow(String... columns) {
|
||||
|
||||
return Stream.of(columns) //
|
||||
.collect(Collectors.joining("\n|", "|", "\n"));
|
||||
}
|
||||
|
||||
public String toAsciidoctor(String source) {
|
||||
|
||||
Matcher matcher = JAVADOC_CODE.matcher(source);
|
||||
|
||||
while (matcher.find()) {
|
||||
|
||||
String type = matcher.group(1);
|
||||
|
||||
source = source.replace(matcher.group(), toInlineCode(type));
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2020 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.moduliths.docs;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaMethod;
|
||||
|
||||
/**
|
||||
* A {@link DocumentationSource} that replaces {@literal {@code …}} or {@literal {@link …}} blocks into inline code
|
||||
* references
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
* @since 1.1
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
class CodeReplacingDocumentationSource implements DocumentationSource {
|
||||
|
||||
private final DocumentationSource delegate;
|
||||
private final Asciidoctor codeSource;
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.moduliths.docs.DocumentationSource#getDocumentation(com.tngtech.archunit.core.domain.JavaMethod)
|
||||
*/
|
||||
@Override
|
||||
public Optional<String> getDocumentation(JavaMethod method) {
|
||||
|
||||
return delegate.getDocumentation(method)
|
||||
.map(codeSource::toAsciidoctor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright 2022 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.moduliths.docs;
|
||||
|
||||
import lombok.Value;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Arrays;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.moduliths.docs.ConfigurationProperties.ConfigurationProperty;
|
||||
import org.moduliths.model.Module;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import com.jayway.jsonpath.DocumentContext;
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.tngtech.archunit.core.domain.JavaType;
|
||||
|
||||
/**
|
||||
* Represents all {@link ConfigurationProperty} instances found for the current project.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
* @since 1.3
|
||||
*/
|
||||
class ConfigurationProperties implements Iterable<ConfigurationProperty> {
|
||||
|
||||
private static final String METADATA_PATH = "classpath:META-INF/spring-configuration-metadata.json";
|
||||
private static final JsonPath PATH = JsonPath.compile("$.properties");
|
||||
|
||||
private final List<ConfigurationProperty> properties;
|
||||
|
||||
/**
|
||||
* Creates a new {@link ConfigurationProperties} instance.
|
||||
*/
|
||||
ConfigurationProperties() {
|
||||
|
||||
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
||||
|
||||
try {
|
||||
Resource[] resources = resolver.getResources(METADATA_PATH);
|
||||
|
||||
this.properties = Arrays.stream(resources)
|
||||
.flatMap(ConfigurationProperties::parseProperties)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all {@link ModuleProperty} instances for the given {@link Module}.
|
||||
*
|
||||
* @param module must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public List<ModuleProperty> getModuleProperties(Module module) {
|
||||
|
||||
Assert.notNull(module, "Module must not be null!");
|
||||
|
||||
return properties.stream()
|
||||
.flatMap(it -> getModuleProperty(module, it))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see java.lang.Iterable#iterator()
|
||||
*/
|
||||
@Override
|
||||
public Iterator<ConfigurationProperty> iterator() {
|
||||
return properties.iterator();
|
||||
}
|
||||
|
||||
private Stream<ModuleProperty> getModuleProperty(Module module,
|
||||
ConfigurationProperty property) {
|
||||
|
||||
return module.getType(property.getSourceType())
|
||||
.map(it -> new ModuleProperty(property.getName(), property.getDescription(), property.getType(), it,
|
||||
property.getDefaultValue()))
|
||||
.map(Stream::of)
|
||||
.orElseGet(Stream::empty);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Stream<ConfigurationProperty> parseProperties(Resource source) {
|
||||
|
||||
if (!source.exists()) {
|
||||
return Stream.empty();
|
||||
}
|
||||
|
||||
try (InputStream stream = source.getInputStream()) {
|
||||
|
||||
DocumentContext context = JsonPath.parse(stream);
|
||||
List<Object> read = context.read(PATH, List.class);
|
||||
|
||||
return read.stream()
|
||||
.map(it -> (Map<String, Object>) it)
|
||||
.flatMap(ConfigurationProperty::of);
|
||||
|
||||
} catch (Exception o_O) {
|
||||
return Stream.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Value
|
||||
static class ConfigurationProperty {
|
||||
|
||||
String name;
|
||||
@Nullable String description;
|
||||
String type, sourceType;
|
||||
@Nullable String defaultValue;
|
||||
|
||||
@SuppressWarnings("null")
|
||||
static Stream<ConfigurationProperty> of(Map<String, Object> source) {
|
||||
|
||||
String sourceType = getAsString(source, "sourceType");
|
||||
|
||||
if (!StringUtils.hasText(sourceType)) {
|
||||
return Stream.empty();
|
||||
}
|
||||
|
||||
ConfigurationProperty property = new ConfigurationProperty(getAsString(source, "name"),
|
||||
getAsString(source, "description"),
|
||||
getAsString(source, "type"),
|
||||
sourceType,
|
||||
getAsString(source, "defaultValue"));
|
||||
|
||||
return Stream.of(property);
|
||||
}
|
||||
|
||||
boolean hasSourceType() {
|
||||
return StringUtils.hasText(sourceType);
|
||||
}
|
||||
|
||||
private static @Nullable String getAsString(Map<String, Object> source, String key) {
|
||||
|
||||
Object value = source.get(key);
|
||||
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@Value
|
||||
static class ModuleProperty {
|
||||
String name;
|
||||
@Nullable String description;
|
||||
String type;
|
||||
JavaType sourceType;
|
||||
@Nullable String defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2020 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.moduliths.docs;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaMethod;
|
||||
|
||||
/**
|
||||
* Interface to abstract different ways of looking up documentation for code abstractions.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
* @since 1.1
|
||||
*/
|
||||
interface DocumentationSource {
|
||||
|
||||
/**
|
||||
* Returns the documentation to be used for the given {@link JavaMethod}.
|
||||
*
|
||||
* @param method must not be {@literal null}.
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
Optional<String> getDocumentation(JavaMethod method);
|
||||
}
|
||||
821
moduliths-docs/src/main/java/org/moduliths/docs/Documenter.java
Normal file
821
moduliths-docs/src/main/java/org/moduliths/docs/Documenter.java
Normal file
@@ -0,0 +1,821 @@
|
||||
/*
|
||||
* Copyright 2018-2020 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
|
||||
*
|
||||
* http://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.moduliths.docs;
|
||||
|
||||
import static org.moduliths.docs.Asciidoctor.*;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.Value;
|
||||
import lombok.With;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.io.StringWriter;
|
||||
import java.io.Writer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.moduliths.model.Module;
|
||||
import org.moduliths.model.Module.DependencyDepth;
|
||||
import org.moduliths.model.Module.DependencyType;
|
||||
import org.moduliths.model.Modules;
|
||||
import org.moduliths.model.SpringBean;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
|
||||
import com.structurizr.Workspace;
|
||||
import com.structurizr.io.Diagram;
|
||||
import com.structurizr.io.plantuml.BasicPlantUMLWriter;
|
||||
import com.structurizr.io.plantuml.C4PlantUMLExporter;
|
||||
import com.structurizr.io.plantuml.PlantUMLWriter;
|
||||
import com.structurizr.model.Component;
|
||||
import com.structurizr.model.Container;
|
||||
import com.structurizr.model.Element;
|
||||
import com.structurizr.model.Model;
|
||||
import com.structurizr.model.Relationship;
|
||||
import com.structurizr.model.SoftwareSystem;
|
||||
import com.structurizr.model.Tags;
|
||||
import com.structurizr.view.ComponentView;
|
||||
import com.structurizr.view.RelationshipView;
|
||||
import com.structurizr.view.Shape;
|
||||
import com.structurizr.view.Styles;
|
||||
import com.structurizr.view.View;
|
||||
import com.tngtech.archunit.core.domain.JavaClass;
|
||||
|
||||
/**
|
||||
* API to create documentation for {@link Modules}.
|
||||
*
|
||||
* @author Oliver Gierke
|
||||
*/
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public class Documenter {
|
||||
|
||||
private static final Map<DependencyType, String> DEPENDENCY_DESCRIPTIONS = new LinkedHashMap<>();
|
||||
|
||||
private static final String INVALID_FILE_NAME_PATTERN = "Configured file name pattern does not include a '%s' placeholder for the module name!";
|
||||
|
||||
static {
|
||||
DEPENDENCY_DESCRIPTIONS.put(DependencyType.EVENT_LISTENER, "listens to");
|
||||
DEPENDENCY_DESCRIPTIONS.put(DependencyType.DEFAULT, "depends on");
|
||||
}
|
||||
|
||||
private final @Getter Modules modules;
|
||||
private final Workspace workspace;
|
||||
private final Container container;
|
||||
private final ConfigurationProperties properties;
|
||||
private final String outputFolder;
|
||||
|
||||
private Map<Module, Component> components;
|
||||
|
||||
/**
|
||||
* Creates a new {@link Documenter} for the {@link Modules} created for the given modulith type.
|
||||
*
|
||||
* @param modulithType must not be {@literal null}.
|
||||
*/
|
||||
public Documenter(Class<?> modulithType) {
|
||||
this(Modules.of(modulithType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@link Documenter} for the given {@link Modules} instance.
|
||||
*
|
||||
* @param modules must not be {@literal null}.
|
||||
*/
|
||||
public Documenter(Modules modules) {
|
||||
this(modules, getDefaultOutputDirectory());
|
||||
}
|
||||
|
||||
private Documenter(Modules modules, String outputFolder) {
|
||||
|
||||
Assert.notNull(modules, "Modules must not be null!");
|
||||
Assert.hasText(outputFolder, "Output folder must not be null or empty!");
|
||||
|
||||
this.modules = modules;
|
||||
this.outputFolder = outputFolder;
|
||||
this.workspace = new Workspace("Modulith", "");
|
||||
|
||||
workspace.getViews().getConfiguration()
|
||||
.getStyles()
|
||||
.addElementStyle(Tags.COMPONENT)
|
||||
.shape(Shape.Component);
|
||||
|
||||
Model model = workspace.getModel();
|
||||
String systemName = modules.getSystemName().orElse("Modulith");
|
||||
|
||||
SoftwareSystem system = model.addSoftwareSystem(systemName, "");
|
||||
|
||||
this.container = system.addContainer("Application", "", "");
|
||||
this.properties = new ConfigurationProperties();
|
||||
}
|
||||
|
||||
private Map<Module, Component> getComponents(Options options) {
|
||||
|
||||
if (components == null) {
|
||||
|
||||
this.components = modules.stream() //
|
||||
.collect(Collectors.toMap(Function.identity(),
|
||||
it -> container.addComponent(options.getDefaultDisplayName().apply(it), "", "Module")));
|
||||
|
||||
this.components.forEach((key, value) -> addDependencies(key, value, options));
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customize the output folder to write the generated files to. Defaults to {@value #DEFAULT_LOCATION}.
|
||||
*
|
||||
* @param outputFolder must not be {@literal null} or empty.
|
||||
* @return
|
||||
* @see #DEFAULT_LOCATION
|
||||
*/
|
||||
public Documenter withOutputFolder(String outputFolder) {
|
||||
return new Documenter(modules, workspace, container, properties, outputFolder, components);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes all available documentation:
|
||||
* <ul>
|
||||
* <li>The entire set of modules as overview component diagram.</li>
|
||||
* <li>Individual component diagrams per module to include all upstream modules.</li>
|
||||
* <li>The Module Canvas for each module.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param options must not be {@literal null}, use {@link Options#defaults()} for default.
|
||||
* @param canvasOptions must not be {@literal null}, use {@link CanvasOptions#defaults()} for default.
|
||||
* @return the current instance, will never be {@literal null}.
|
||||
* @throws IOException
|
||||
* @since 1.1
|
||||
*/
|
||||
public Documenter writeDocumentation(Options options, CanvasOptions canvasOptions) throws IOException {
|
||||
|
||||
return writeModulesAsPlantUml(options)
|
||||
.writeIndividualModulesAsPlantUml(options) //
|
||||
.writeModuleCanvases(canvasOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the PlantUML component diagram for all {@link Modules}.
|
||||
*
|
||||
* @param options must not be {@literal null}.
|
||||
* @throws IOException
|
||||
*/
|
||||
public Documenter writeModulesAsPlantUml(Options options) throws IOException {
|
||||
|
||||
Assert.notNull(options, "Options must not be null!");
|
||||
|
||||
Path file = recreateFile(options.getTargetFileName().orElse("components.uml"));
|
||||
|
||||
try (Writer writer = new FileWriter(file.toFile())) {
|
||||
writer.write(createPlantUml(options));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the component diagrams for all individual modules.
|
||||
*
|
||||
* @param options must not be {@literal null}.
|
||||
* @return the current instance, will never be {@literal null}.
|
||||
* @since 1.1
|
||||
*/
|
||||
public Documenter writeIndividualModulesAsPlantUml(Options options) {
|
||||
|
||||
modules.forEach(it -> writeModuleAsPlantUml(it, options));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the PlantUML component diagram for the given {@link Module}.
|
||||
*
|
||||
* @param module must not be {@literal null}.
|
||||
* @return the current instance, will never be {@literal null}.
|
||||
*/
|
||||
public Documenter writeModuleAsPlantUml(Module module) {
|
||||
|
||||
Assert.notNull(module, "Module must not be null!");
|
||||
|
||||
return writeModuleAsPlantUml(module, Options.defaults());
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the PlantUML component diagram for the given {@link Module} with the given rendering {@link Options}.
|
||||
*
|
||||
* @param module must not be {@literal null}.
|
||||
* @param options must not be {@literal null}.
|
||||
* @return the current instance, will never be {@literal null}.
|
||||
*/
|
||||
public Documenter writeModuleAsPlantUml(Module module, Options options) {
|
||||
|
||||
Assert.notNull(module, "Module must not be null!");
|
||||
Assert.notNull(options, "Options must not be null!");
|
||||
|
||||
ComponentView view = createComponentView(options, module);
|
||||
view.setTitle(options.getDefaultDisplayName().apply(module));
|
||||
|
||||
addComponentsToView(module, view, options);
|
||||
|
||||
String fileNamePattern = options.getTargetFileName().orElse("module-%s.uml");
|
||||
|
||||
Assert.isTrue(fileNamePattern.contains("%s"), () -> String.format(INVALID_FILE_NAME_PATTERN, fileNamePattern));
|
||||
|
||||
return writeViewAsPlantUml(view, String.format(fileNamePattern, module.getName()), options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes all module canvases using {@link Options#defaults()}.
|
||||
*
|
||||
* @return the current instance, will never be {@literal null}.
|
||||
*/
|
||||
public Documenter writeModuleCanvases() {
|
||||
return writeModuleCanvases(CanvasOptions.defaults());
|
||||
}
|
||||
|
||||
public Documenter writeModuleCanvases(CanvasOptions options) {
|
||||
|
||||
modules.forEach(module -> {
|
||||
|
||||
String filename = String.format(options.getTargetFileName().orElse("module-%s.adoc"), module.getName());
|
||||
Path file = recreateFile(filename);
|
||||
|
||||
try (FileWriter writer = new FileWriter(file.toFile())) {
|
||||
|
||||
writer.write(toModuleCanvas(module, options));
|
||||
|
||||
} catch (IOException o_O) {
|
||||
throw new RuntimeException(o_O);
|
||||
}
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param javadocBase
|
||||
* @deprecated since 1.1, use {@link #writeModuleCanvases(CanvasOptions)} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public Documenter writeModuleCanvases(String javadocBase) {
|
||||
return writeModuleCanvases(CanvasOptions.defaults().withApiBase(javadocBase));
|
||||
}
|
||||
|
||||
public String toModuleCanvas(Module module) {
|
||||
return toModuleCanvas(module, CanvasOptions.defaults());
|
||||
}
|
||||
|
||||
public String toModuleCanvas(Module module, String apiBase) {
|
||||
return toModuleCanvas(module, CanvasOptions.defaults().withApiBase(apiBase));
|
||||
}
|
||||
|
||||
public String toModuleCanvas(Module module, CanvasOptions options) {
|
||||
|
||||
Asciidoctor asciidoctor = Asciidoctor.withJavadocBase(modules, options.getApiBase());
|
||||
Function<List<JavaClass>, String> mapper = asciidoctor::typesToBulletPoints;
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(startTable("%autowidth.stretch, cols=\"h,a\""));
|
||||
builder.append(writeTableRow("Base package", asciidoctor.toInlineCode(module.getBasePackage().getName())));
|
||||
builder.append(writeTableRow("Spring components", asciidoctor.renderSpringBeans(options, module)));
|
||||
builder.append(addTableRow(module.getAggregateRoots(), "Aggregate roots", mapper));
|
||||
builder.append(writeTableRow("Published events", asciidoctor.renderEvents(module)));
|
||||
builder.append(addTableRow(module.getEventsListenedTo(modules), "Events listened to", mapper));
|
||||
builder.append(writeTableRow("Properties",
|
||||
asciidoctor.renderConfigurationProperties(module, properties.getModuleProperties(module))));
|
||||
builder.append(startOrEndTable());
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private <T> String addTableRow(List<T> types, String header, Function<List<T>, String> mapper) {
|
||||
return types.isEmpty() ? "" : writeTableRow(header, mapper.apply(types));
|
||||
}
|
||||
|
||||
public String toPlantUml() throws IOException {
|
||||
return createPlantUml(Options.defaults());
|
||||
}
|
||||
|
||||
private void addDependencies(Module module, Component component, Options options) {
|
||||
|
||||
DEPENDENCY_DESCRIPTIONS.entrySet().stream().forEach(entry -> {
|
||||
|
||||
module.getDependencies(modules, entry.getKey()).stream() //
|
||||
.map(it -> getComponents(options).get(it)) //
|
||||
// .filter(it -> !component.hasEfferentRelationshipWith(it)) //
|
||||
.forEach(it -> {
|
||||
|
||||
Relationship relationship = component.uses(it, entry.getValue());
|
||||
relationship.addTags(entry.getKey().toString());
|
||||
});
|
||||
});
|
||||
|
||||
module.getBootstrapDependencies(modules) //
|
||||
.forEach(it -> {
|
||||
Relationship relationship = component.uses(getComponents(options).get(it), "uses");
|
||||
relationship.addTags(DependencyType.USES_COMPONENT.toString());
|
||||
});
|
||||
}
|
||||
|
||||
private void addComponentsToView(Module module, ComponentView view, Options options) {
|
||||
|
||||
Supplier<Stream<Module>> bootstrapDependencies = () -> module.getBootstrapDependencies(modules,
|
||||
options.getDependencyDepth());
|
||||
Supplier<Stream<Module>> otherDependencies = () -> options.getDependencyTypes()
|
||||
.flatMap(it -> module.getDependencies(modules, it).stream());
|
||||
|
||||
Supplier<Stream<Module>> dependencies = () -> Stream.concat(bootstrapDependencies.get(), otherDependencies.get());
|
||||
|
||||
addComponentsToView(dependencies, view, options, it -> it.add(getComponents(options).get(module)));
|
||||
}
|
||||
|
||||
private void addComponentsToView(Supplier<Stream<Module>> modules, ComponentView view, Options options,
|
||||
Consumer<ComponentView> afterCleanup) {
|
||||
|
||||
Styles styles = view.getViewSet().getConfiguration().getStyles();
|
||||
Map<Module, Component> components = getComponents(options);
|
||||
|
||||
modules.get() //
|
||||
.distinct()
|
||||
.filter(options.getExclusions().negate()) //
|
||||
.map(it -> applyBackgroundColor(it, components, options, styles)) //
|
||||
.filter(options.getComponentFilter()) //
|
||||
.forEach(view::add);
|
||||
|
||||
// view.getViewSet().getConfiguration().getStyles().findElementStyle(element).getBackground()
|
||||
|
||||
// Remove filtered dependency types
|
||||
DependencyType.allBut(options.getDependencyTypes()) //
|
||||
.map(Object::toString) //
|
||||
.forEach(it -> view.removeRelationshipsWithTag(it));
|
||||
|
||||
afterCleanup.accept(view);
|
||||
|
||||
// Filter outgoing relationships of target-only modules
|
||||
modules.get().filter(options.getTargetOnly()) //
|
||||
.forEach(module -> {
|
||||
|
||||
Component component = components.get(module);
|
||||
|
||||
view.getRelationships().stream() //
|
||||
.map(RelationshipView::getRelationship) //
|
||||
.filter(it -> it.getSource().equals(component)) //
|
||||
.forEach(it -> view.remove(it));
|
||||
});
|
||||
|
||||
// … as well as all elements left without a relationship
|
||||
if (options.hideElementsWithoutRelationships()) {
|
||||
view.removeElementsWithNoRelationships();
|
||||
}
|
||||
|
||||
afterCleanup.accept(view);
|
||||
|
||||
// Remove default relationships if more qualified ones exist
|
||||
view.getRelationships().stream() //
|
||||
.map(RelationshipView::getRelationship) //
|
||||
.collect(Collectors.groupingBy(Connection::of)) //
|
||||
.values().stream() //
|
||||
.forEach(it -> potentiallyRemoveDefaultRelationship(view, it));
|
||||
}
|
||||
|
||||
private void potentiallyRemoveDefaultRelationship(View view, Collection<Relationship> relationships) {
|
||||
|
||||
if (relationships.size() <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
relationships.stream().filter(it -> it.getTagsAsSet().contains(DependencyType.DEFAULT.toString())) //
|
||||
.findFirst().ifPresent(view::remove);
|
||||
}
|
||||
|
||||
private static Component applyBackgroundColor(Module module, Map<Module, Component> components, Options options,
|
||||
Styles styles) {
|
||||
|
||||
Component component = components.get(module);
|
||||
Function<Module, Optional<String>> selector = options.getColorSelector();
|
||||
|
||||
// Apply custom color if configured
|
||||
selector.apply(module).ifPresent(color -> {
|
||||
|
||||
String tag = module.getName() + "-" + color;
|
||||
component.addTags(tag);
|
||||
|
||||
// Add or update background color
|
||||
styles.getElements().stream()
|
||||
.filter(it -> it.getTag().equals(tag))
|
||||
.findFirst()
|
||||
.orElseGet(() -> styles.addElementStyle(tag))
|
||||
.background(color);
|
||||
});
|
||||
|
||||
return component;
|
||||
}
|
||||
|
||||
private Documenter writeViewAsPlantUml(ComponentView view, String filename, Options options) {
|
||||
|
||||
Path file = recreateFile(filename);
|
||||
|
||||
try (Writer writer = new FileWriter(file.toFile())) {
|
||||
|
||||
writer.write(render(view, options));
|
||||
|
||||
return this;
|
||||
|
||||
} catch (IOException o_O) {
|
||||
throw new RuntimeException(o_O);
|
||||
}
|
||||
}
|
||||
|
||||
private String render(ComponentView view, Options options) {
|
||||
|
||||
switch (options.style) {
|
||||
|
||||
case C4:
|
||||
|
||||
C4PlantUMLExporter exporter = new C4PlantUMLExporter();
|
||||
Diagram diagram = exporter.export(view);
|
||||
return diagram.getDefinition();
|
||||
|
||||
case UML:
|
||||
default:
|
||||
|
||||
Writer writer = new StringWriter();
|
||||
PlantUMLWriter umlWriter = new BasicPlantUMLWriter();
|
||||
umlWriter.addSkinParam("componentStyle", "uml1");
|
||||
umlWriter.write(view, writer);
|
||||
|
||||
return writer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private String createPlantUml(Options options) throws IOException {
|
||||
|
||||
ComponentView componentView = createComponentView(options);
|
||||
componentView.setTitle(modules.getSystemName().orElse("Modules"));
|
||||
|
||||
addComponentsToView(() -> modules.stream(), componentView, options, it -> {});
|
||||
|
||||
return render(componentView, options);
|
||||
}
|
||||
|
||||
private ComponentView createComponentView(Options options) {
|
||||
return createComponentView(options, null);
|
||||
}
|
||||
|
||||
private ComponentView createComponentView(Options options, @Nullable Module module) {
|
||||
|
||||
String prefix = module == null ? "modules-" : module.getName();
|
||||
|
||||
return workspace.getViews() //
|
||||
.createComponentView(container, prefix + options.toString(), "");
|
||||
}
|
||||
|
||||
private Path recreateFile(String name) {
|
||||
|
||||
try {
|
||||
|
||||
Files.createDirectories(Paths.get(outputFolder));
|
||||
Path filePath = Paths.get(outputFolder, name);
|
||||
Files.deleteIfExists(filePath);
|
||||
|
||||
return Files.createFile(filePath);
|
||||
|
||||
} catch (IOException o_O) {
|
||||
throw new RuntimeException(o_O);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default output directory based on the detected build system.
|
||||
*
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
private static String getDefaultOutputDirectory() {
|
||||
return (new File("pom.xml").exists() ? "target" : "build").concat("/moduliths-docs");
|
||||
}
|
||||
|
||||
@Value
|
||||
private static class Connection {
|
||||
|
||||
Element source, target;
|
||||
|
||||
public static Connection of(Relationship relationship) {
|
||||
return new Connection(relationship.getSource(), relationship.getDestination());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options to tweak the rendering of diagrams.
|
||||
*
|
||||
* @author Oliver Gierke
|
||||
*/
|
||||
@Getter(AccessLevel.PRIVATE)
|
||||
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public static class Options {
|
||||
|
||||
private static Set<DependencyType> ALL_TYPES = Arrays.stream(DependencyType.values()).collect(Collectors.toSet());
|
||||
|
||||
private final Set<DependencyType> dependencyTypes;
|
||||
|
||||
/**
|
||||
* The {@link DependencyDepth} to define which other modules to be included in the diagram to be created.
|
||||
*/
|
||||
private final @With DependencyDepth dependencyDepth;
|
||||
|
||||
/**
|
||||
* A {@link Predicate} to define the which modules to exclude from the diagram to be created.
|
||||
*/
|
||||
private final @With Predicate<Module> exclusions;
|
||||
|
||||
/**
|
||||
* A {@link Predicate} to define which Structurizr {@link Component}s to be included in the diagram to be created.
|
||||
*/
|
||||
private final @With Predicate<Component> componentFilter;
|
||||
|
||||
/**
|
||||
* A {@link Predicate} to define which of the modules shall only be considered targets, i.e. all efferent
|
||||
* relationships are going to be hidden from the rendered view. Modules that have no incoming relationships will
|
||||
* entirely be removed from the view.
|
||||
*/
|
||||
private final @With Predicate<Module> targetOnly;
|
||||
|
||||
/**
|
||||
* The target file name to be used for the diagram to be created. For individual module diagrams this needs to
|
||||
* include a {@code %s} placeholder for the module names.
|
||||
*/
|
||||
private final @With @Nullable String targetFileName;
|
||||
|
||||
/**
|
||||
* A callback to return a hex-encoded color per {@link Module}.
|
||||
*/
|
||||
private final @With Function<Module, Optional<String>> colorSelector;
|
||||
|
||||
/**
|
||||
* A callback to return a default display names for a given {@link Module}. Default implementation just forwards to
|
||||
* {@link Module#getDisplayName()}.
|
||||
*/
|
||||
private final @With Function<Module, String> defaultDisplayName;
|
||||
|
||||
/**
|
||||
* Which style to render the diagram in. Defaults to {@value DiagramStyle#UML}.
|
||||
*/
|
||||
private final @With DiagramStyle style;
|
||||
|
||||
/**
|
||||
* Configuration setting to define whether modules that do not have a relationship to any other module shall be
|
||||
* retained in the diagrams created. The default is {@value ElementsWithoutRelationships#HIDDEN}. See
|
||||
* {@link Options#withExclusions(Predicate)} for a more fine-grained way of defining which modules to exclude in
|
||||
* case you flip this to {@link ElementsWithoutRelationships#VISIBLE}.
|
||||
*
|
||||
* @see #withExclusions(Predicate)
|
||||
*/
|
||||
private final @With ElementsWithoutRelationships elementsWithoutRelationships;
|
||||
|
||||
/**
|
||||
* Creates a new default {@link Options} instance configured to use all dependency types, list immediate
|
||||
* dependencies for individual module instances, not applying any kind of {@link Module} or {@link Component}
|
||||
* filters and default file names.
|
||||
*
|
||||
* @return will never be {@literal null}.
|
||||
*/
|
||||
public static Options defaults() {
|
||||
return new Options(ALL_TYPES, DependencyDepth.IMMEDIATE, it -> false, it -> true, it -> false, null,
|
||||
__ -> Optional.empty(), it -> it.getDisplayName(), DiagramStyle.UML, ElementsWithoutRelationships.HIDDEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the dependency types that are supposed to be included in the diagram to be created.
|
||||
*
|
||||
* @param types must not be {@literal null}.
|
||||
* @return
|
||||
*/
|
||||
public Options withDependencyTypes(DependencyType... types) {
|
||||
|
||||
Assert.notNull(types, "Dependency types must not be null!");
|
||||
|
||||
Set<DependencyType> dependencyTypes = Arrays.stream(types).collect(Collectors.toSet());
|
||||
|
||||
return new Options(dependencyTypes, dependencyDepth, exclusions, componentFilter, targetOnly, targetFileName,
|
||||
colorSelector, defaultDisplayName, style, elementsWithoutRelationships);
|
||||
}
|
||||
|
||||
private Optional<String> getTargetFileName() {
|
||||
return Optional.ofNullable(targetFileName);
|
||||
}
|
||||
|
||||
private Stream<DependencyType> getDependencyTypes() {
|
||||
return dependencyTypes.stream();
|
||||
}
|
||||
|
||||
private boolean hideElementsWithoutRelationships() {
|
||||
return elementsWithoutRelationships.equals(ElementsWithoutRelationships.HIDDEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Different diagram styles.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
public enum DiagramStyle {
|
||||
|
||||
/**
|
||||
* A plain UML component diagram.
|
||||
*/
|
||||
UML,
|
||||
|
||||
/**
|
||||
* A C4 model component diagram.
|
||||
*
|
||||
* @see https://c4model.com/#ComponentDiagram
|
||||
*/
|
||||
C4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration setting to define whether modules that do not have a relationship to any other module shall be
|
||||
* retained in the diagrams created. The default is {@value ElementsWithoutRelationships#HIDDEN}. See
|
||||
* {@link Options#withExclusions(Predicate)} for a more fine-grained way of defining which modules to exclude in
|
||||
* case you flip this to {@link ElementsWithoutRelationships#VISIBLE}.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
* @see Options#withExclusions(Predicate)
|
||||
*/
|
||||
public enum ElementsWithoutRelationships {
|
||||
HIDDEN, VISIBLE;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix required for javac 🤔
|
||||
@lombok.RequiredArgsConstructor(access = AccessLevel.PACKAGE)
|
||||
public static class CanvasOptions {
|
||||
|
||||
static final Grouping FALLBACK_GROUP = Grouping.of("Others", null, __ -> true);
|
||||
|
||||
private final List<Grouping> groupers;
|
||||
private final @With @Getter @Nullable String apiBase;
|
||||
private final @With @Nullable String targetFileName;
|
||||
|
||||
public static CanvasOptions defaults() {
|
||||
|
||||
return withoutDefaultGroupings()
|
||||
.groupingBy("Controllers", bean -> bean.toArchitecturallyEvidentType().isController()) //
|
||||
.groupingBy("Services", bean -> bean.toArchitecturallyEvidentType().isService()) //
|
||||
.groupingBy("Repositories", bean -> bean.toArchitecturallyEvidentType().isRepository()) //
|
||||
.groupingBy("Event listeners", bean -> bean.toArchitecturallyEvidentType().isEventListener()) //
|
||||
.groupingBy("Configuration properties",
|
||||
bean -> bean.toArchitecturallyEvidentType().isConfigurationProperties());
|
||||
}
|
||||
|
||||
public static CanvasOptions withoutDefaultGroupings() {
|
||||
return new CanvasOptions(new ArrayList<>(), null, null);
|
||||
}
|
||||
|
||||
public CanvasOptions groupingBy(Grouping... groupings) {
|
||||
|
||||
List<Grouping> result = new ArrayList<>(groupers);
|
||||
result.addAll(Arrays.asList(groupings));
|
||||
|
||||
return new CanvasOptions(result, apiBase, targetFileName);
|
||||
}
|
||||
|
||||
public CanvasOptions groupingBy(String name, Predicate<SpringBean> filter) {
|
||||
return groupingBy(Grouping.of(name, null, filter));
|
||||
}
|
||||
|
||||
Groupings groupBeans(Module module) {
|
||||
|
||||
List<Grouping> sources = new ArrayList<>(groupers);
|
||||
sources.add(FALLBACK_GROUP);
|
||||
|
||||
MultiValueMap<Grouping, SpringBean> result = new LinkedMultiValueMap<>();
|
||||
List<SpringBean> alreadyMapped = new ArrayList<>();
|
||||
|
||||
sources.forEach(it -> {
|
||||
|
||||
List<SpringBean> matchingBeans = getMatchingBeans(module, it, alreadyMapped);
|
||||
|
||||
result.addAll(it, matchingBeans);
|
||||
alreadyMapped.addAll(matchingBeans);
|
||||
});
|
||||
|
||||
// Wipe entries without any beans
|
||||
new HashSet<>(result.keySet()).forEach(key -> {
|
||||
if (result.get(key).isEmpty()) {
|
||||
result.remove(key);
|
||||
}
|
||||
});
|
||||
|
||||
return Groupings.of(result);
|
||||
}
|
||||
|
||||
private Optional<String> getTargetFileName() {
|
||||
return Optional.ofNullable(targetFileName);
|
||||
}
|
||||
|
||||
private static List<SpringBean> getMatchingBeans(Module module, Grouping filter, List<SpringBean> alreadyMapped) {
|
||||
|
||||
return module.getSpringBeans().stream()
|
||||
.filter(it -> !alreadyMapped.contains(it))
|
||||
.filter(filter::matches)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Value(staticConstructor = "of")
|
||||
@Getter(AccessLevel.PACKAGE)
|
||||
public static class Grouping {
|
||||
|
||||
String name;
|
||||
@Nullable String description;
|
||||
Predicate<SpringBean> predicate;
|
||||
|
||||
public static Grouping of(String name) {
|
||||
return new Grouping(name, null, __ -> false);
|
||||
}
|
||||
|
||||
public static Grouping of(String name, Predicate<SpringBean> predicate) {
|
||||
return new Grouping(name, null, predicate);
|
||||
}
|
||||
|
||||
public boolean matches(SpringBean candidate) {
|
||||
return predicate.test(candidate);
|
||||
}
|
||||
|
||||
public static Predicate<SpringBean> nameMatching(String pattern) {
|
||||
return bean -> bean.getFullyQualifiedTypeName().matches(pattern);
|
||||
}
|
||||
|
||||
public static Predicate<SpringBean> implementing(Class<?> type) {
|
||||
return bean -> bean.getType().isAssignableTo(type);
|
||||
}
|
||||
|
||||
public static Predicate<SpringBean> subtypeOf(Class<?> type) {
|
||||
return implementing(type) //
|
||||
.and(bean -> !bean.getType().isEquivalentTo(type));
|
||||
}
|
||||
}
|
||||
|
||||
@RequiredArgsConstructor(access = AccessLevel.PACKAGE, staticName = "of")
|
||||
static class Groupings {
|
||||
|
||||
private final MultiValueMap<Grouping, SpringBean> groupings;
|
||||
|
||||
Set<Grouping> keySet() {
|
||||
return groupings.keySet();
|
||||
}
|
||||
|
||||
List<SpringBean> byGrouping(Grouping grouping) {
|
||||
return byFilter(grouping::equals);
|
||||
}
|
||||
|
||||
List<SpringBean> byGroupName(String name) {
|
||||
return byFilter(it -> it.getName().equals(name));
|
||||
}
|
||||
|
||||
void forEach(BiConsumer<Grouping, List<SpringBean>> consumer) {
|
||||
groupings.forEach(consumer);
|
||||
}
|
||||
|
||||
private List<SpringBean> byFilter(Predicate<Grouping> filter) {
|
||||
|
||||
return groupings.entrySet().stream()
|
||||
.filter(it -> filter.test(it.getKey()))
|
||||
.findFirst()
|
||||
.map(Entry::getValue)
|
||||
.orElseGet(Collections::emptyList);
|
||||
}
|
||||
|
||||
boolean hasOnlyFallbackGroup() {
|
||||
return groupings.size() == 1 && groupings.get(FALLBACK_GROUP) != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2020 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.moduliths.docs;
|
||||
|
||||
import capital.scalable.restdocs.javadoc.JavadocReader;
|
||||
import capital.scalable.restdocs.javadoc.JavadocReaderImpl;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaMethod;
|
||||
|
||||
/**
|
||||
* A {@link DocumentationSource} that uses metadata generated by Spring Auto REST Docs' Javadoc Doclet.
|
||||
*
|
||||
* @author Oliver Drotbohm
|
||||
* @since 1.1
|
||||
*/
|
||||
class SpringAutoRestDocsDocumentationSource implements DocumentationSource {
|
||||
|
||||
private final JavadocReader reader = JavadocReaderImpl.createWithSystemProperty();
|
||||
|
||||
/*
|
||||
* (non-Javadoc)
|
||||
* @see org.moduliths.docs.JavadocSource#getDocumentation(com.tngtech.archunit.core.domain.JavaMethod)
|
||||
*/
|
||||
@Override
|
||||
public Optional<String> getDocumentation(JavaMethod method) {
|
||||
return Optional.of(reader.resolveMethodComment(method.getOwner().reflect(), method.getName()))
|
||||
.filter(it -> !it.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@org.springframework.lang.NonNullApi
|
||||
package org.moduliths.docs;
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2020 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.moduliths.docs;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.moduliths.model.Modules;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
||||
import com.tngtech.archunit.core.domain.JavaClass;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
|
||||
/**
|
||||
* @author Oliver Drotbohm
|
||||
*/
|
||||
class AsciidoctorUnitTests {
|
||||
|
||||
Asciidoctor asciidoctor = Asciidoctor.withJavadocBase(Modules.of("org.moduliths"), "{javadoc}");
|
||||
|
||||
@Test
|
||||
void formatsInlineCode() {
|
||||
assertThat(asciidoctor.toInlineCode("Foo")).isEqualTo("`Foo`");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersLinkToMethodReference() {
|
||||
|
||||
assertThat(asciidoctor.toInlineCode("Documenter#toModuleCanvas(Module, CanvasOptions)"))
|
||||
.isEqualTo("link:{javadoc}/org/moduliths/docs/Documenter.html"
|
||||
+ "[`o.m.d.Documenter#toModuleCanvas(Module, CanvasOptions)`]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotRenderLinkToMethodReferenceForNonPublicType() {
|
||||
|
||||
assertThat(asciidoctor.toInlineCode("DocumentationSource#getDocumentation(JavaMethod)"))
|
||||
.isEqualTo("`o.m.d.DocumentationSource#getDocumentation(JavaMethod)`");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersInlineCodeForNonModuleTypeCorrectly() {
|
||||
|
||||
JavaClass type = new ClassFileImporter().importClass(ApplicationContext.class);
|
||||
|
||||
assertThatCode(() -> asciidoctor.toInlineCode(type)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleansUpJavadocForConfigurationProperties() {
|
||||
|
||||
ConfigurationProperties metadata = new ConfigurationProperties();
|
||||
|
||||
assertThat(metadata).containsExactly(new ConfigurationProperties.ConfigurationProperty("org.moduliths.sample.test",
|
||||
"Some test property of type {@link java.lang.Boolean}.", "java.lang.Boolean",
|
||||
"com.acme.myproject.stereotypes.Stereotypes$SomeConfigurationProperties", "false"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright 2018 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
|
||||
*
|
||||
* http://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.moduliths.docs;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Comparator;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.moduliths.docs.Documenter.Options;
|
||||
import org.moduliths.model.Module;
|
||||
import org.moduliths.model.Module.DependencyType;
|
||||
|
||||
import com.acme.myproject.Application;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link Documenter}.
|
||||
*
|
||||
* @author Oliver Gierke
|
||||
*/
|
||||
class DocumenterTest {
|
||||
|
||||
Documenter documenter = new Documenter(Application.class);
|
||||
|
||||
@Test
|
||||
void writesComponentStructureAsPlantUml() throws IOException {
|
||||
documenter.toPlantUml();
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesSingleModuleDocumentation() throws IOException {
|
||||
|
||||
Module module = documenter.getModules().getModuleByName("moduleB") //
|
||||
.orElseThrow(() -> new IllegalArgumentException());
|
||||
|
||||
documenter.writeModuleAsPlantUml(module, Options.defaults() //
|
||||
.withColorSelector(it -> Optional.of("#ff0000")) //
|
||||
.withDefaultDisplayName(it -> it.getDisplayName().toUpperCase()));
|
||||
|
||||
Options options = Options.defaults() //
|
||||
.withComponentFilter(component -> component.getRelationships().stream()
|
||||
.anyMatch(it -> it.getTagsAsSet().contains(DependencyType.EVENT_LISTENER.toString())));
|
||||
|
||||
documenter.writeModulesAsPlantUml(options);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testName() {
|
||||
|
||||
documenter.getModules().stream() //
|
||||
.map(it -> documenter.toModuleCanvas(it));
|
||||
}
|
||||
|
||||
@Test
|
||||
void customizesOutputLocation() throws IOException {
|
||||
|
||||
String customOutputFolder = "build/moduliths";
|
||||
Path path = Paths.get(customOutputFolder);
|
||||
|
||||
try {
|
||||
|
||||
documenter.withOutputFolder(customOutputFolder).writeModuleCanvases();
|
||||
|
||||
assertThat(Files.list(path)).isNotEmpty();
|
||||
assertThat(path).exists();
|
||||
|
||||
} finally {
|
||||
|
||||
Files.walk(path)
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.map(Path::toFile)
|
||||
.forEach(File::delete);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"name": "org.moduliths.sample",
|
||||
"type": "com.acme.myproject.stereotypes.Stereotypes$SomeConfigurationProperties",
|
||||
"sourceType": "com.acme.myproject.stereotypes.Stereotypes$SomeConfigurationProperties"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "org.moduliths.sample.test",
|
||||
"type": "java.lang.Boolean",
|
||||
"description": "Some test property of type {@link java.lang.Boolean}.",
|
||||
"sourceType": "com.acme.myproject.stereotypes.Stereotypes$SomeConfigurationProperties",
|
||||
"defaultValue": false
|
||||
}
|
||||
],
|
||||
"hints": []
|
||||
}
|
||||
14
moduliths-docs/src/test/resources/logback.xml
Normal file
14
moduliths-docs/src/test/resources/logback.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
|
||||
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d %5p %40.40c:%4L - %m%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="error">
|
||||
<appender-ref ref="console" />
|
||||
</root>
|
||||
|
||||
</configuration>
|
||||
Reference in New Issue
Block a user