IndexedListSettings.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* 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.entrystore.rest.springboot.configuration;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.logging.Log;
import org.entrystore.repository.config.Settings;
import org.entrystore.rest.springboot.util.CaseFolding;
import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.properties.source.ConfigurationProperty;
import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
import org.springframework.boot.context.properties.source.ConfigurationPropertyName.Form;
import org.springframework.boot.context.properties.source.ConfigurationPropertySource;
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
import org.springframework.boot.context.properties.source.IterableConfigurationPropertySource;
import org.springframework.boot.env.OriginTrackedMapPropertySource;
import org.springframework.boot.logging.DeferredLogFactory;
import org.springframework.boot.origin.PropertySourceOrigin;
import org.springframework.core.Ordered;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.Environment;
import org.springframework.core.env.PropertySource;
import org.springframework.core.env.SystemEnvironmentPropertySource;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
/**
* Reads the indexed list settings ({@code key.1}, {@code key.2}, …) the way 5.x's {@code Config.getStringList}
* did, and reports at startup how a list in a 5.x shape is read. Every consumer reads these settings through
* {@link #read} or {@link #readHosts}. No property source is modified.
*
* <p>Each property source contributes a bare value and indexed entries:
* <ul>
* <li>A config file ({@code entrystore.properties}, a YAML file) is read by exact names, as 5.x read its file:
* {@code key}, {@code key.N} and the YAML-list form {@code key[N]}. Any other name Boot would relate to the key
* ({@code WHITELIST.1}, {@code white-list.1}, {@code .1-}, {@code .-1}, {@code ..1}, {@code .[1]}, {@code .01},
* {@code .0}) is ignored.</li>
* <li>Environment variables and non-enumerable sources are matched by Boot, as for the binder; another source
* (the command line, SPRING_APPLICATION_JSON) accepts Boot's relaxed spelling of the key but only an exact
* {@code .N} or {@code [N]} index. In every source the index is plain ASCII digits without a leading zero,
* {@code 0} only in the Spring-native forms ({@code [0]}, a YAML or JSON array, {@code KEY_0}).</li>
* </ul>
* A null value (e.g. a JSON null) sets nothing, and an unresolvable placeholder is kept literally, both as for
* the binder. Across sources:
* <ol>
* <li>Valid entries are merged by index, the highest-precedence source winning per index, and applied from
* index 1 up to the first missing one, as in 5.x. Index 0 is zero-based list syntax and is applied as the first
* element.</li>
* <li>A bare value ({@code key=value}, an empty one included) is the list {@code [value]}, as in 5.x, and hides
* the indexed entries of its own and lower-precedence sources. Only a non-empty list applied from
* higher-precedence sources replaces it, so a command-line or environment override wins over a bare value in
* {@code entrystore.properties}, and an ignored or unapplied entry never displaces it.</li>
* </ol>
*
* <p>An {@link EnvironmentPostProcessor} so the report is logged once, before any bean reads a list: a WARN when
* an entry is ignored or shadowed or the bare value is empty, otherwise an INFO for a bare value. Both recommend
* the contiguous {@code .1}, {@code .2}, … form.
*/
@Slf4j
public final class IndexedListSettings implements EnvironmentPostProcessor, Ordered {
private static final List<String> KEYS = List.of(
Settings.AUTH_PASSWORD_WHITELIST,
Settings.AUTH_PASSWORD_BLACKLIST,
Settings.AUTH_PASSWORD_RULE_CUSTOM,
Settings.AUTH_PERMITTED_REDIRECTS,
Settings.PROXY_WHITELIST_LOCAL,
Settings.PROXY_WHITELIST_ANONYMOUS,
Settings.PROXY_REMOTE_RESOURCE_DELETE_WHITELIST,
Settings.SIGNUP_WHITELIST);
private static final Pattern INDEX = Pattern.compile("0|[1-9][0-9]{0,8}");
/** A name that ends in .N or [N] after a non-empty prefix that does not itself end in a dot. */
private static final Pattern EXACT_INDEX =
Pattern.compile("(.*[^.])(?:\\.(0|[1-9][0-9]{0,8})|\\[(0|[1-9][0-9]{0,8})])");
private static final int BARE = -1;
private static final int INVALID = -2;
private final Log startupLog;
public IndexedListSettings(DeferredLogFactory logFactory) {
this.startupLog = logFactory.getLog(IndexedListSettings.class);
}
/** The list configured under {@code key}, read by the rules in the class Javadoc. */
public static List<String> read(Environment environment, String key) {
if (!(environment instanceof ConfigurableEnvironment configurable)) {
throw new IllegalArgumentException("Indexed list settings need a ConfigurableEnvironment");
}
return reading(configurable, key).values();
}
/** The host list under {@code key}, case-folded, with blank entries skipped rather than matching "". */
public static Set<String> readHosts(Environment environment, String key) {
Set<String> hosts = new HashSet<>();
for (String entry : read(environment, key)) {
if (entry.isBlank()) {
log.warn("Skipping blank entry in {}", key);
continue;
}
hosts.add(CaseFolding.toLowerCase(entry));
}
return Set.copyOf(hosts);
}
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
for (String key : KEYS) {
Reading reading;
try {
reading = reading(environment, key);
} catch (IllegalArgumentException e) {
continue; // a circular placeholder reference; the consumer's read fails with it later
}
if (reading.warning() != null) {
startupLog.warn(reading.warning());
} else if (reading.info() != null) {
startupLog.info(reading.info());
}
}
}
// Must run after ConfigDataEnvironmentPostProcessor so entrystore.properties (imported via
// spring.config.import) is part of the Environment.
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE - 1;
}
private record Reading(List<String> values, String warning, String info) {}
/** A valid entry of one source; {@code label} names it in the report, {@code index} is {@link #BARE} or N. */
private record Found(int rank, String label, Object raw, int index) {}
/** What one source sets for the key: a bare value or null, valid entries by index, and ignored names. */
private record Contribution(Found bare, Map<Integer, Found> indexed, List<String> ignored) {
Contribution() {
this(null, new TreeMap<>(), new ArrayList<>());
}
Contribution withBare(Found found) {
return bare == null ? new Contribution(found, indexed, ignored) : this;
}
}
private static Reading reading(ConfigurableEnvironment environment, String key) {
ConfigurationPropertyName keyName = ConfigurationPropertyName.of(key);
List<ConfigurationPropertySource> sources = new ArrayList<>();
ConfigurationPropertySources.get(environment).forEach(sources::add);
List<Contribution> contributions = new ArrayList<>();
Set<Integer> seenIndices = new TreeSet<>();
for (int rank = 0; rank < sources.size(); rank++) {
Contribution contribution = contribution(sources.get(rank), rank, key, keyName, null);
contributions.add(contribution);
if (contribution != null) {
seenIndices.addAll(contribution.indexed().keySet());
}
}
// A non-enumerable source cannot list its names, so it is asked for the ones the others set.
for (int rank = 0; rank < sources.size(); rank++) {
if (contributions.get(rank) == null) {
contributions.set(rank, contribution(sources.get(rank), rank, key, keyName, seenIndices));
}
}
Set<String> ignored = new TreeSet<>();
Found bare = null;
Map<Integer, Found> byIndex = new LinkedHashMap<>();
Map<Integer, Found> topByIndex = new LinkedHashMap<>();
for (Contribution contribution : contributions) {
ignored.addAll(contribution.ignored());
contribution.indexed().forEach(topByIndex::putIfAbsent);
if (contribution.bare() != null) {
if (bare == null) {
bare = contribution.bare();
} else {
ignored.add(contribution.bare().label()); // shadowed by a higher-precedence bare value
}
}
// A bare value hides the entries of its own and lower-precedence sources.
if (bare == null) {
contribution.indexed().forEach(byIndex::putIfAbsent);
}
}
List<Found> applied = new ArrayList<>();
for (int index = byIndex.containsKey(0) ? 0 : 1; byIndex.containsKey(index); index++) {
applied.add(byIndex.get(index));
}
List<Found> used = bare != null && applied.isEmpty() ? List.of(bare) : applied;
if (bare != null && !used.contains(bare)) {
ignored.add(bare.label());
}
topByIndex.values().stream()
.filter(entry -> !used.contains(entry))
.forEach(entry -> ignored.add(entry.label()));
List<String> values = used.stream().map(entry -> environment.resolvePlaceholders(entry.raw().toString()))
.toList();
return report(key, values, used.contains(bare) ? bare : null, ignored);
}
/**
* What {@code source} sets for the key. Null for a non-enumerable source while {@code indices} is null, since
* it can only be asked for names, which are then {@code key} and the indices the other sources set.
*/
private static Contribution contribution(ConfigurationPropertySource source, int rank, String key,
ConfigurationPropertyName keyName, Set<Integer> indices) {
PropertySource<?> underlying = source.getUnderlyingSource() instanceof PropertySource<?> propertySource
? propertySource : null;
if (underlying instanceof SystemEnvironmentPropertySource
&& source instanceof IterableConfigurationPropertySource iterable) {
return byBootNames(source, rank, keyName, iterable.stream().toList());
}
if (underlying instanceof EnumerablePropertySource<?> enumerable) {
return byRawNames(source, enumerable, rank, key, keyName,
underlying instanceof OriginTrackedMapPropertySource);
}
if (indices == null) {
return null;
}
List<ConfigurationPropertyName> names = new ArrayList<>(List.of(keyName));
for (int index : indices) {
names.add(ConfigurationPropertyName.of(key + "." + index));
names.add(ConfigurationPropertyName.of(key + "[" + index + "]"));
}
return byBootNames(source, rank, keyName, names);
}
/**
* Reads an enumerable source by its raw names. A config file is read literally, as 5.x read its file;
* elsewhere the key may be spelled as Boot relaxes it, but the index must still be written exactly, because
* Boot's names drop characters such as the dash of {@code .-1} or the empty element of {@code ..1}.
*/
private static Contribution byRawNames(ConfigurationPropertySource source, EnumerablePropertySource<?> raw,
int rank, String key, ConfigurationPropertyName keyName, boolean configFile) {
Contribution contribution = new Contribution();
Map<Integer, List<String>> spellings = new TreeMap<>();
List<String> bareSpellings = new ArrayList<>();
for (String name : raw.getPropertyNames()) {
if (raw.getProperty(name) == null) {
continue;
}
ConfigurationPropertyName adapted = adapt(name);
if (adapted == null || !(keyName.equals(adapted) || keyName.isAncestorOf(adapted))) {
// Boot cannot relate e.g. key.٢ to the key, but a file reader sees it under the key.
if (configFile && (name.startsWith(key + ".") || name.startsWith(key + "["))) {
contribution.ignored().add(name);
}
continue;
}
int index = configFile ? literalIndex(name, key) : relaxedIndex(name, keyName);
if (index == BARE) {
bareSpellings.add(name);
} else if (index == INVALID) {
contribution.ignored().add(name);
} else {
spellings.computeIfAbsent(index, _ -> new ArrayList<>()).add(name);
}
}
String bare = preferred(source, keyName, bareSpellings, List.of(key), contribution.ignored());
if (bare != null) {
contribution = contribution.withBare(new Found(rank, bare, raw.getProperty(bare), BARE));
}
for (Map.Entry<Integer, List<String>> entry : spellings.entrySet()) {
String name = preferred(source, adapt(entry.getValue().getFirst()), entry.getValue(),
List.of(key + "." + entry.getKey(), key + "[" + entry.getKey() + "]"), contribution.ignored());
contribution.indexed().put(entry.getKey(), new Found(rank, name, raw.getProperty(name), entry.getKey()));
}
return contribution;
}
/**
* Of several valid spellings of one name in one source, the first of {@code exact} that is present, so the key
* spelled exactly wins and {@code .N} beats {@code [N]} as in 5.x; else the one Boot picks, else the first.
* The others are added to {@code ignored}.
*/
private static String preferred(ConfigurationPropertySource source, ConfigurationPropertyName name,
List<String> spellings, List<String> exact, List<String> ignored) {
if (spellings.isEmpty()) {
return null;
}
ConfigurationProperty property = source.getConfigurationProperty(name);
String bootsChoice = property != null && property.getOrigin() instanceof PropertySourceOrigin origin
&& spellings.contains(origin.getPropertyName()) ? origin.getPropertyName() : spellings.getFirst();
String chosen = exact.stream().filter(spellings::contains).findFirst().orElse(bootsChoice);
spellings.stream().filter(spelling -> !spelling.equals(chosen)).forEach(ignored::add);
return chosen;
}
/** The index {@code name} sets in a config file, read literally: {@code key}, {@code key.N}, {@code key[N]}. */
private static int literalIndex(String name, String key) {
if (name.equals(key)) {
return BARE;
}
Matcher exact = EXACT_INDEX.matcher(name);
return exact.matches() && exact.group(1).equals(key) ? index(exact) : INVALID;
}
/** The index {@code name} sets with the key spelled as Boot relaxes it and the index written exactly. */
private static int relaxedIndex(String name, ConfigurationPropertyName keyName) {
if (keyName.equals(adapt(name))) {
return BARE;
}
Matcher exact = EXACT_INDEX.matcher(name);
return exact.matches() && keyName.equals(adapt(exact.group(1))) ? index(exact) : INVALID;
}
/** 5.x counted from the literal .1, so a dotted .0 was never read; [0] is Spring-native list syntax. */
private static int index(Matcher exact) {
if (exact.group(2) != null) {
return "0".equals(exact.group(2)) ? INVALID : Integer.parseInt(exact.group(2));
}
return Integer.parseInt(exact.group(3));
}
/** Reads a source by Boot's names, as the binder does: environment variables and non-enumerable sources. */
private static Contribution byBootNames(ConfigurationPropertySource source, int rank,
ConfigurationPropertyName keyName, List<ConfigurationPropertyName> names) {
Contribution contribution = new Contribution();
boolean variables = source.getUnderlyingSource() instanceof SystemEnvironmentPropertySource;
for (ConfigurationPropertyName name : names) {
if (!keyName.equals(name) && !keyName.isAncestorOf(name)) {
continue;
}
ConfigurationProperty property = source.getConfigurationProperty(name);
if (property == null) {
continue;
}
String label = property.getOrigin() instanceof PropertySourceOrigin origin
? origin.getPropertyName() : name.toString();
if (keyName.equals(name)) {
contribution = contribution.withBare(new Found(rank, label, property.getValue(), BARE));
continue;
}
String index = name.getElement(keyName.getNumberOfElements(), Form.ORIGINAL);
boolean valid = name.getNumberOfElements() == keyName.getNumberOfElements() + 1
&& INDEX.matcher(index).matches()
&& (!"0".equals(index) || variables || name.isLastElementIndexed());
if (!valid) {
contribution.ignored().add(label);
} else {
contribution.indexed().putIfAbsent(Integer.parseInt(index),
new Found(rank, label, property.getValue(), Integer.parseInt(index)));
}
}
return contribution;
}
private static ConfigurationPropertyName adapt(String name) {
try {
return ConfigurationPropertyName.adapt(name, '.');
} catch (RuntimeException e) {
return null; // not a name the binder could use either
}
}
/** A WARN when an entry is ignored or the bare value is empty, an INFO for a bare value, otherwise nothing. */
private static Reading report(String key, List<String> values, Found bare, Set<String> ignored) {
String recommended = values.isEmpty()
? key + ".1, " + key + ".2, ..."
: IntStream.range(0, values.size())
.mapToObj(i -> key + "." + (i + 1) + "=" + values.get(i))
.collect(Collectors.joining(", "));
String applies = "EntryStore list property '" + key + "' applies "
+ values.stream().map(value -> "\"" + value + "\"").collect(Collectors.joining(", ", "[", "]"));
boolean emptyBare = bare != null && values.getFirst().isEmpty();
if (ignored.isEmpty() && !emptyBare) {
return new Reading(values, null, bare == null ? null
: applies + " from its bare value; the indexed form " + recommended + " is recommended");
}
if (ignored.isEmpty()) {
return new Reading(values, applies + " from its bare value '" + bare.label() + "'. The empty value is a "
+ "list of one empty entry; remove the key to configure no list", null);
}
return new Reading(values, applies
+ (bare == null ? "" : " from its bare value '" + bare.label() + "', which wins over indexed entries "
+ "in its own and lower-precedence sources")
+ "; ignored: " + ignored
+ ". Number its entries contiguously from .1 instead: " + recommended
+ (emptyBare ? ". The empty value is a list of one empty entry; remove the key to configure no list"
: ""), null);
}
}