Skip to content

Commit 0c232eb

Browse files
janheisetodvora
andauthored
Adding Documentation Annotations (#137)
* fixing tests * adding Documentation annotations * update to junit5 to accomodate new tests * going to Java21 * fixing pom and main class * junit updated to 6.0.0 * code cleanup * removed not needed java 21 reference --------- Co-authored-by: Tomas Dvorak <tomas.dvorak@graylog.com>
1 parent 60ce2de commit 0c232eb

File tree

113 files changed

+2960
-1736
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

113 files changed

+2960
-1736
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313

1414
strategy:
1515
matrix:
16-
java-version: ["17", "11", "8"]
16+
java-version: ["21"]
1717

1818
steps:
1919
- uses: "actions/checkout@v3"

pom.xml

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,16 @@
7373
<properties>
7474
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
7575
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
76-
<maven.compiler.source>1.8</maven.compiler.source>
77-
<maven.compiler.target>1.8</maven.compiler.target>
76+
<maven.compiler.source>21</maven.compiler.source>
77+
<maven.compiler.target>21</maven.compiler.target>
7878
<maven.site.deploy.skip>true</maven.site.deploy.skip>
7979
<slf4j.version>2.0.17</slf4j.version>
80+
<jakarta.annotation-api.version>3.0.0</jakarta.annotation-api.version>
81+
<commons-text.version>1.14.0</commons-text.version>
82+
<commons-lang3.version>3.19.0</commons-lang3.version>
83+
<commons-csv.version>1.14.1</commons-csv.version>
84+
<junit-jupiter.version>6.0.0</junit-jupiter.version>
85+
<assertj-core.version>3.27.6</assertj-core.version>
8086
</properties>
8187

8288
<dependencies>
@@ -91,6 +97,26 @@
9197
<version>2.14.0</version>
9298
<optional>true</optional>
9399
</dependency>
100+
<dependency>
101+
<groupId>jakarta.annotation</groupId>
102+
<artifactId>jakarta.annotation-api</artifactId>
103+
<version>${jakarta.annotation-api.version}</version>
104+
</dependency>
105+
<dependency>
106+
<groupId>org.apache.commons</groupId>
107+
<artifactId>commons-lang3</artifactId>
108+
<version>${commons-lang3.version}</version>
109+
</dependency>
110+
<dependency>
111+
<groupId>org.apache.commons</groupId>
112+
<artifactId>commons-text</artifactId>
113+
<version>${commons-text.version}</version>
114+
</dependency>
115+
<dependency>
116+
<groupId>org.apache.commons</groupId>
117+
<artifactId>commons-csv</artifactId>
118+
<version>${commons-csv.version}</version>
119+
</dependency>
94120
<dependency>
95121
<groupId>com.google.inject</groupId>
96122
<artifactId>guice</artifactId>
@@ -110,9 +136,15 @@
110136
<scope>test</scope>
111137
</dependency>
112138
<dependency>
113-
<groupId>junit</groupId>
114-
<artifactId>junit</artifactId>
115-
<version>4.13.2</version>
139+
<groupId>org.junit.jupiter</groupId>
140+
<artifactId>junit-jupiter</artifactId>
141+
<version>${junit-jupiter.version}</version>
142+
<scope>test</scope>
143+
</dependency>
144+
<dependency>
145+
<groupId>org.assertj</groupId>
146+
<artifactId>assertj-core</artifactId>
147+
<version>${assertj-core.version}</version>
116148
<scope>test</scope>
117149
</dependency>
118150
<dependency>
@@ -293,6 +325,10 @@
293325
<groupId>org.eluder.coveralls</groupId>
294326
<artifactId>coveralls-maven-plugin</artifactId>
295327
</plugin>
328+
<plugin>
329+
<groupId>org.apache.maven.plugins</groupId>
330+
<artifactId>maven-compiler-plugin</artifactId>
331+
</plugin>
296332
</plugins>
297333
</build>
298334

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.github.joschi.jadconfig.documentation;
2+
3+
import java.util.Collection;
4+
import java.util.List;
5+
import java.util.ServiceLoader;
6+
7+
public class ConfigurationBeansSPI {
8+
public static List<Object> loadConfigurationBeans() {
9+
return ServiceLoader.load(DocumentedBeansService.class).stream()
10+
.map(ServiceLoader.Provider::get)
11+
.map(DocumentedBeansService::getDocumentedConfigurationBeans)
12+
.flatMap(Collection::stream)
13+
.distinct()
14+
.toList();
15+
}
16+
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package com.github.joschi.jadconfig.documentation;
2+
3+
import com.github.joschi.jadconfig.Parameter;
4+
import com.github.joschi.jadconfig.ReflectionUtils;
5+
import com.github.joschi.jadconfig.documentation.printers.ConfigFileDocsPrinter;
6+
import com.github.joschi.jadconfig.documentation.printers.ConfigurationSection;
7+
import com.github.joschi.jadconfig.documentation.printers.CsvDocsPrinter;
8+
import com.github.joschi.jadconfig.documentation.printers.DocsPrinter;
9+
import jakarta.annotation.Nonnull;
10+
import org.apache.commons.lang3.ClassUtils;
11+
12+
import java.io.FileWriter;
13+
import java.io.IOException;
14+
import java.lang.reflect.Field;
15+
import java.nio.charset.StandardCharsets;
16+
import java.util.Arrays;
17+
import java.util.Collections;
18+
import java.util.Comparator;
19+
import java.util.List;
20+
import java.util.Locale;
21+
import java.util.Map;
22+
import java.util.Optional;
23+
import java.util.function.Supplier;
24+
import java.util.stream.Collector;
25+
import java.util.stream.Collectors;
26+
27+
public class ConfigurationDocsGenerator {
28+
29+
public static void main(String[] args) throws IOException {
30+
final ConfigurationDocsGenerator generator = new ConfigurationDocsGenerator();
31+
generator.generateDocumentation(parseDocumentationFormat(args), ConfigurationBeansSPI::loadConfigurationBeans);
32+
}
33+
34+
@Nonnull
35+
private static DocumentationFormat parseDocumentationFormat(String[] args) {
36+
if (args.length != 2) {
37+
throw new IllegalArgumentException("This command needs two arguments - a format and file path. For example" + "csv ${project.build.directory}/configuration-documentation.csv");
38+
}
39+
final String format = args[0].toLowerCase(Locale.ROOT).trim();
40+
final String file = args[1];
41+
return new DocumentationFormat(format, file);
42+
}
43+
44+
public void generateDocumentation(DocumentationFormat format, Supplier<List<Object>> configurationBeans) throws IOException {
45+
final List<ConfigurationSection> sections = detectConfigurationSections(configurationBeans);
46+
try (final DocsPrinter writer = createWriter(format)) {
47+
writer.write(sections);
48+
}
49+
}
50+
51+
private DocsPrinter createWriter(DocumentationFormat format) throws IOException {
52+
final FileWriter fileWriter = new FileWriter(format.outputFile(), StandardCharsets.UTF_8);
53+
// TODO: if the format list expands, introduce DI and factories for printers
54+
return switch (format.format()) {
55+
case "csv" -> new CsvDocsPrinter(fileWriter);
56+
case "conf" -> new ConfigFileDocsPrinter(fileWriter);
57+
default -> throw new IllegalArgumentException("Unsupported format " + format.format());
58+
};
59+
}
60+
61+
/**
62+
* Collects all configuration options from all available configuration beans.
63+
*/
64+
private List<ConfigurationSection> detectConfigurationSections(Supplier<List<Object>> configurationBeans) {
65+
return configurationBeans.get()
66+
.stream()
67+
.map(ConfigurationDocsGenerator::beanToConfigSections)
68+
.sorted(Comparator.comparing(ConfigurationSection::hasPriority).reversed())
69+
.toList();
70+
}
71+
72+
private static ConfigurationSection beanToConfigSections(Object configurationBean) {
73+
74+
String sectionHeading = null;
75+
String sectionDescription = null;
76+
if (configurationBean.getClass().isAnnotationPresent(DocumentationSection.class)) {
77+
final DocumentationSection documentationSection = configurationBean.getClass().getAnnotation(DocumentationSection.class);
78+
sectionHeading = documentationSection.heading();
79+
sectionDescription = documentationSection.description();
80+
}
81+
82+
final List<ConfigurationEntryWithSection> entries = Arrays.stream(configurationBean.getClass().getDeclaredFields())
83+
.filter(f -> f.isAnnotationPresent(Parameter.class))
84+
.filter(ConfigurationDocsGenerator::isPublicFacing)
85+
.map(f -> toConfigurationEntry(f, configurationBean))
86+
.toList();
87+
88+
final List<ConfigurationEntry> entriesWithoutSection = getEntriesWithoutSection(entries);
89+
final List<ConfigurationSection> sortedSections = sectionsFromEntries(entries);
90+
91+
return new ConfigurationSection(sectionHeading, sectionDescription, sortedSections, entriesWithoutSection);
92+
}
93+
94+
@Nonnull
95+
private static List<ConfigurationSection> sectionsFromEntries(List<ConfigurationEntryWithSection> entries) {
96+
return entries.stream()
97+
.filter(ConfigurationEntryWithSection::hasSection)
98+
.collect(groupBySection())
99+
.values()
100+
.stream()
101+
.sorted(sortByPriority())
102+
.toList();
103+
}
104+
105+
private static Comparator<ConfigurationSection> sortByPriority() {
106+
return Comparator.comparing(ConfigurationSection::hasPriority, Comparator.reverseOrder());
107+
}
108+
109+
private static Collector<ConfigurationEntryWithSection, ?, Map<String, ConfigurationSection>> groupBySection() {
110+
return Collectors.groupingBy(ConfigurationEntryWithSection::sectionHeading, Collectors.collectingAndThen(Collectors.toList(), ConfigurationDocsGenerator::toConfigurationSection));
111+
}
112+
113+
private static ConfigurationSection toConfigurationSection(List<ConfigurationEntryWithSection> list) {
114+
final ConfigurationEntryWithSection section = list.stream().findFirst().orElseThrow(() -> new IllegalArgumentException("No configuration section found but expected!"));
115+
final List<ConfigurationEntry> entries = list.stream().map(ConfigurationEntryWithSection::entry).collect(Collectors.toList());
116+
return new ConfigurationSection(section.sectionHeading(), section.sectionDescription(), Collections.emptyList(), entries);
117+
}
118+
119+
@Nonnull
120+
private static List<ConfigurationEntry> getEntriesWithoutSection(List<ConfigurationEntryWithSection> entries) {
121+
return entries.stream()
122+
.filter(e -> !e.hasSection())
123+
.map(ConfigurationEntryWithSection::entry)
124+
.sorted(Comparator.comparing(ConfigurationEntry::hasPriority, Comparator.reverseOrder()))
125+
.collect(Collectors.toList());
126+
}
127+
128+
/**
129+
* There are some configuration options not intended for general usage, mainly just for system packages configuration.
130+
*
131+
* @see Documentation#visible()
132+
*/
133+
private static boolean isPublicFacing(Field f) {
134+
return !f.isAnnotationPresent(Documentation.class) || f.getAnnotation(Documentation.class).visible();
135+
}
136+
137+
private static ConfigurationEntryWithSection toConfigurationEntry(Field f, Object instance) {
138+
final String documentation = Optional.ofNullable(f.getAnnotation(Documentation.class)).map(Documentation::value).orElse(null);
139+
final Parameter parameter = f.getAnnotation(Parameter.class);
140+
final String propertyName = parameter.value();
141+
final Object defaultValue = getDefaultValue(f, instance);
142+
final String type = getType(f);
143+
final boolean required = parameter.required();
144+
145+
final DocumentationSection documentationSection = f.getAnnotation(DocumentationSection.class);
146+
String sectionHeading = null;
147+
String sectionDescription = null;
148+
if (documentationSection != null) {
149+
sectionHeading = documentationSection.heading();
150+
sectionDescription = documentationSection.description();
151+
}
152+
final ConfigurationEntry entry = new ConfigurationEntry(instance.getClass(), f.getName(), type, propertyName, defaultValue, required, documentation);
153+
return new ConfigurationEntryWithSection(entry, sectionHeading, sectionDescription);
154+
}
155+
156+
private static Object getDefaultValue(Field f, Object instance) {
157+
try {
158+
return ReflectionUtils.getFieldValue(instance, f);
159+
} catch (IllegalAccessException e) {
160+
throw new RuntimeException(e);
161+
}
162+
}
163+
164+
private static String getType(Field f) {
165+
if (f.getType().isPrimitive()) { // unify primitive types and wrappers, e.g. int -> Integer
166+
return ClassUtils.primitiveToWrapper(f.getType()).getSimpleName();
167+
} else {
168+
return f.getType().getSimpleName();
169+
}
170+
}
171+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.github.joschi.jadconfig.documentation;
2+
3+
import jakarta.annotation.Nullable;
4+
5+
/**
6+
* @param configurationBean Class that defines this configuration entry
7+
* @param fieldName java field name
8+
* @param type Java type name, e.g. String or Integer.
9+
* @param configName configuration property name, as written in the config file
10+
* @param defaultValue default value declared in the java field, null if not defined
11+
* @param required if the configuration property is mandatory (needs default or entry in the config file)
12+
* @param documentation textual documentation of this configuration propery
13+
*/
14+
public record ConfigurationEntry(
15+
Class<?> configurationBean,
16+
String fieldName,
17+
String type,
18+
String configName,
19+
@Nullable Object defaultValue,
20+
boolean required,
21+
String documentation
22+
) {
23+
24+
public boolean hasPriority() {
25+
return required && defaultValue == null;
26+
}
27+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.github.joschi.jadconfig.documentation;
2+
3+
public record ConfigurationEntryWithSection(ConfigurationEntry entry, String sectionHeading, String sectionDescription) {
4+
public boolean hasSection() {
5+
return sectionHeading != null && !sectionHeading.isBlank();
6+
}
7+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.github.joschi.jadconfig.documentation;
2+
3+
import java.lang.annotation.Documented;
4+
import java.lang.annotation.ElementType;
5+
import java.lang.annotation.Retention;
6+
import java.lang.annotation.RetentionPolicy;
7+
import java.lang.annotation.Target;
8+
9+
/**
10+
* This annotation is used to document configuration files. It allows autogeneration of config documentation.
11+
*/
12+
@Documented
13+
@Retention(RetentionPolicy.RUNTIME)
14+
@Target(ElementType.FIELD)
15+
public @interface Documentation {
16+
/**
17+
* We don't want to expose some configuration fields to users. They are internal, required for system packages functionality
18+
* or deprecated. Set to false if you want to hide this field from documentation.
19+
*/
20+
boolean visible() default true;
21+
22+
/**
23+
* Description of this configuration property.
24+
*/
25+
String value() default "";
26+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
package com.github.joschi.jadconfig.documentation;
2+
3+
public record DocumentationFormat(String format, String outputFile) {
4+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.github.joschi.jadconfig.documentation;
2+
3+
import java.lang.annotation.Documented;
4+
import java.lang.annotation.ElementType;
5+
import java.lang.annotation.Retention;
6+
import java.lang.annotation.RetentionPolicy;
7+
import java.lang.annotation.Target;
8+
9+
@Documented
10+
@Retention(RetentionPolicy.RUNTIME)
11+
@Target({ElementType.TYPE, ElementType.FIELD})
12+
public @interface DocumentationSection {
13+
String heading();
14+
String description();
15+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.github.joschi.jadconfig.documentation;
2+
3+
import java.util.List;
4+
5+
public interface DocumentedBeansService {
6+
List<Object> getDocumentedConfigurationBeans();
7+
}

0 commit comments

Comments
 (0)