ConfigMetadata.java
package li.selman.modulithdocsmarkdown;
import static java.util.stream.Collectors.toCollection;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.SortedSet;
import java.util.TreeSet;
import org.jspecify.annotations.Nullable;
import org.springframework.modulith.core.ApplicationModule;
/**
* Reproduces Spring Modulith's {@code ConfigurationProperties} logic: reads every
* {@code META-INF/spring-configuration-metadata.json} on the classpath (as produced by the Spring Boot
* configuration-metadata annotation processor) and keeps the properties whose declaring type belongs to a given
* module. Depends only on the JDK — no JSON library is part of the main artifact's dependency set.
*/
final class ConfigMetadata {
record ConfigProperty(
String name,
@Nullable String type,
@Nullable String description,
String sourceType,
@Nullable String defaultValue) {}
private static final String METADATA_RESOURCE = "META-INF/spring-configuration-metadata.json";
private static final List<ConfigProperty> PROPERTIES = load();
private ConfigMetadata() {}
static SortedSet<String> propertyNamesFor(ApplicationModule module) {
return PROPERTIES.stream()
.filter(property -> module.getType(property.sourceType()).isPresent())
.map(ConfigProperty::name)
.collect(toCollection(TreeSet::new));
}
static List<ConfigProperty> propertiesFor(ApplicationModule module) {
return PROPERTIES.stream()
.filter(property -> module.getType(property.sourceType()).isPresent())
.toList();
}
// Package-private (not private) so ConfigMetadataTest can drive a classloader that fails to enumerate
// resources, without reaching into a private static initializer.
static List<ConfigProperty> load() {
try {
var resources = Collections.list(
Thread.currentThread().getContextClassLoader().getResources(METADATA_RESOURCE));
var result = new ArrayList<ConfigProperty>();
for (URL resource : resources) {
result.addAll(parseResource(resource));
}
return result;
} catch (IOException o_O) {
throw new UncheckedIOException(o_O);
}
}
private static List<ConfigProperty> parseResource(URL resource) throws IOException {
String content;
try (var in = resource.openStream()) {
content = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
return parseProperties(content);
}
/**
* Exposes the underlying JSON reader directly, for edge cases ({@code ConfigMetadataTest}'s scientific-notation
* and end-of-input number parsing) that can't be driven through {@link #parseProperties(String)}, since that
* method requires the top-level value to be a JSON object.
*/
static @Nullable Object parseJson(String content) {
return Json.parse(content);
}
/**
* Parses a {@code spring-configuration-metadata.json} document and extracts its {@code properties} array.
* Package-private (not private) so {@code ConfigMetadataTest} can exercise the underlying JSON reader's edge
* cases (escapes, numbers, literals, malformed input) directly and deterministically, rather than depending
* on exactly what Spring Boot's config-metadata processor happens to emit for a given input.
*/
@SuppressWarnings("unchecked")
static List<ConfigProperty> parseProperties(String content) {
var root = (Map<String, @Nullable Object>) Objects.requireNonNull(Json.parse(content), "top-level JSON value");
var properties = (List<@Nullable Object>) root.getOrDefault("properties", List.of());
var result = new ArrayList<ConfigProperty>();
for (Object raw : properties) {
var property = (Map<String, @Nullable Object>) raw;
var sourceType = asString(property.get("sourceType"));
if (sourceType == null || sourceType.isBlank()) {
continue;
}
result.add(new ConfigProperty(
Objects.requireNonNull(asString(property.get("name")), "name"),
asString(property.get("type")),
asString(property.get("description")),
sourceType,
asString(property.get("defaultValue"))));
}
return result;
}
private static @Nullable String asString(@Nullable Object value) {
return value == null ? null : value.toString();
}
/**
* Minimal recursive-descent JSON reader. Only supports what {@code spring-configuration-metadata.json} actually
* contains: objects, arrays, strings (with standard escapes), numbers, booleans and {@code null}.
*/
private static final class Json {
private final String source;
private int index;
private Json(String source) {
this.source = source;
}
static @Nullable Object parse(String source) {
var parser = new Json(source);
parser.skipWhitespace();
var value = parser.readValue();
parser.skipWhitespace();
return value;
}
private @Nullable Object readValue() {
return switch (peek()) {
case '{' -> readObject();
case '[' -> readArray();
case '"' -> readString();
case 't', 'f' -> readBoolean();
case 'n' -> readNull();
default -> readNumber();
};
}
private Map<String, @Nullable Object> readObject() {
var result = new java.util.LinkedHashMap<String, @Nullable Object>();
expect('{');
skipWhitespace();
if (peek() == '}') {
index++;
return result;
}
while (true) {
skipWhitespace();
var key = readString();
skipWhitespace();
expect(':');
skipWhitespace();
result.put(key, readValue());
skipWhitespace();
if (peek() == ',') {
index++;
continue;
}
expect('}');
break;
}
return result;
}
private List<@Nullable Object> readArray() {
var result = new ArrayList<@Nullable Object>();
expect('[');
skipWhitespace();
if (peek() == ']') {
index++;
return result;
}
while (true) {
skipWhitespace();
result.add(readValue());
skipWhitespace();
if (peek() == ',') {
index++;
continue;
}
expect(']');
break;
}
return result;
}
private String readString() {
expect('"');
var builder = new StringBuilder();
while (peek() != '"') {
char c = source.charAt(index++);
if (c == '\\') {
char escaped = source.charAt(index++);
builder.append(
switch (escaped) {
case '"' -> '"';
case '\\' -> '\\';
case '/' -> '/';
case 'b' -> '\b';
case 'f' -> '\f';
case 'n' -> '\n';
case 'r' -> '\r';
case 't' -> '\t';
case 'u' -> (char) Integer.parseInt(source.substring(index, index += 4), 16);
default -> throw new IllegalStateException("Unsupported escape sequence \\" + escaped);
});
} else {
builder.append(c);
}
}
index++;
return builder.toString();
}
private Boolean readBoolean() {
if (source.startsWith("true", index)) {
index += 4;
return Boolean.TRUE;
}
if (source.startsWith("false", index)) {
index += 5;
return Boolean.FALSE;
}
throw new IllegalStateException("Invalid literal at " + index);
}
private @Nullable Object readNull() {
if (source.startsWith("null", index)) {
index += 4;
return null;
}
throw new IllegalStateException("Invalid literal at " + index);
}
private Number readNumber() {
int start = index;
while (index < source.length() && "-+.eE0123456789".indexOf(source.charAt(index)) >= 0) {
index++;
}
var literal = source.substring(start, index);
// Not a ternary: `cond ? Double.parseDouble(x) : Long.parseLong(x)` would binary-numeric-promote the
// long branch to double (JLS 15.25), silently turning e.g. 10L into 10.0d.
if (literal.contains(".") || literal.contains("e") || literal.contains("E")) {
return Double.parseDouble(literal);
}
return Long.parseLong(literal);
}
private void expect(char expected) {
if (source.charAt(index) != expected) {
throw new IllegalStateException(
"Expected '%s' at %d but found '%s'".formatted(expected, index, peek()));
}
index++;
}
private char peek() {
return source.charAt(index);
}
private void skipWhitespace() {
while (index < source.length() && Character.isWhitespace(source.charAt(index))) {
index++;
}
}
}
}