TraversalProperties.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.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.ApplicationConversionService;
import org.springframework.core.convert.ConversionFailedException;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Binding for the metadata traversal profiles ({@code entrystore.traversal.<profile>.*}), consumed
* by {@code MetadataService}. Profile names are chosen freely by the operator and supplied by the
* client at request time, and a profile node is simultaneously an indexed predicate list
* ({@code entrystore.traversal.foaf.1=...}, {@code .2=...}) and an object with named sub-keys
* ({@code .max-depth}, {@code .limit}, {@code .repository-scope}, {@code .blacklist.N}) — a layout
* no typed record structure can express. The binder therefore collects everything below
* {@code entrystore.traversal} into a flat {@link Map}: when the target value type is scalar,
* Spring's binder uses the entire remaining dotted path as the map key (the same mechanism that
* binds {@code logging.level.org.springframework=DEBUG}), so
* {@code entrystore.traversal.foaf.max-depth=5} binds under the key {@code "foaf.max-depth"}.
* The typed lookup methods below reconstruct per-profile views from those dotted keys. Other
* {@code entrystore.*} keys are ignored as unknown fields.
*
* <p>Map keys are matched verbatim by the lookup methods below, and are <em>not</em> subject to the
* relaxed binding a typed component gets. {@link #maxDepth(String)} looks up {@code "<profile>.max-depth"},
* so write the hyphenated spelling — {@code entrystore.traversal.foaf.maxDepth=5} is not found and the
* default depth cap silently applies instead. A key containing anything beyond lowercase letters,
* digits and {@code -} may not survive the binder's key canonicalisation as written; bracket it in full
* to keep it verbatim, {@code entrystore.traversal[my_profile.1]=...}, since the whole remaining path is
* the map key. {@code TraversalPropertiesTest} pins what the binder actually does with each form.
*
* <p>Only the indexed form binds for the predicate and blacklist lists, and it diverges from the legacy
* reader in two ways. A bare, un-indexed value ({@code entrystore.traversal.foaf=...}) still binds into
* the map (under the key {@code "foaf"}) but is not returned by {@link #predicates(String)}, so the
* profile silently disappears — use the indexed form. And a gap is kept: {@code .1} + {@code .3} yields
* both predicates, where {@code Config.getStringList} stopped counting at the first missing index and
* dropped {@code .3}. Pinned by {@code TraversalPropertiesTest}.
*
* <p>The one exception is a bare {@code entrystore.traversal.<profile>.blacklist=<tuple>}: the legacy
* reader accepted it as a one-element denylist, and dropping it would make the traversal denylist fail
* open. It is honoured as a single tuple (with a WARN at bind time recommending the indexed form),
* ordered after the indexed entries: with both forms set the legacy reader returned <em>only</em> the
* bare tuple, so it must win the last-wins keying in {@code MetadataService} — the indexed entries can
* only ever add denials, never relax the one the bare tuple carries.
*/
@ConfigurationProperties(prefix = "entrystore")
@Slf4j
public record TraversalProperties(Map<String, String> traversal) {
public TraversalProperties {
// Copy so the singleton never hands out the binder's mutable LinkedHashMap by reference.
traversal = (traversal == null) ? Map.of() : Map.copyOf(traversal);
// Checked once at bind time: blacklistTuples and repositoryScope run per traversal request.
traversal.forEach((key, value) -> {
if (key.endsWith(".blacklist") && !value.isBlank()) {
log.warn("Configuration key 'entrystore.traversal.{}' has a bare, un-indexed value; it is "
+ "honoured as a single blacklist tuple so the denylist cannot fail open, but write "
+ "it as 'entrystore.traversal.{}.1={}'.", key, key, value);
}
if (key.endsWith(".repository-scope") && !value.isBlank() && parseBoolean(value) == null) {
throw new IllegalArgumentException("Invalid boolean value '" + value + "' for entrystore.traversal."
+ key + ": expected true/on/yes/1 or false/off/no/0");
}
});
}
/**
* The predicate URIs of the profile ({@code entrystore.traversal.<profile>.N} keys). Empty when
* the profile is not configured — which is also how {@code MetadataService} detects that a
* request-supplied name is not a profile.
*/
public Set<String> predicates(String profile) {
return entriesWithNumericSuffix(profile + ".")
.map(Map.Entry::getValue)
.collect(Collectors.toUnmodifiableSet());
}
/**
* The profile's {@code max-depth} setting, empty when unset. A non-numeric value throws
* {@link NumberFormatException}, matching the legacy {@code Config.getInt} behaviour.
*/
public OptionalInt maxDepth(String profile) {
return intSetting(profile, "max-depth");
}
/**
* The profile's {@code limit} setting, empty when unset. A non-numeric value throws
* {@link NumberFormatException}, matching the legacy {@code Config.getInt} behaviour.
*/
public OptionalInt limit(String profile) {
return intSetting(profile, "limit");
}
/**
* The profile's {@code repository-scope} setting, using relaxed boolean spellings. Empty when unset or
* blank, so the caller's default applies; an unrecognised value fails binding.
*/
public Optional<Boolean> repositoryScope(String profile) {
return Optional.ofNullable(traversal.get(profile + ".repository-scope"))
.map(TraversalProperties::parseBoolean);
}
/**
* The profile's blacklist tuples ({@code entrystore.traversal.<profile>.blacklist.N} keys),
* each a comma-separated predicate/object pair. Empty when the profile has no blacklist. A bare
* {@code <profile>.blacklist=<tuple>} value is honoured as the last tuple (see the class javadoc).
*/
public List<String> blacklistTuples(String profile) {
String keyPrefix = profile + ".blacklist.";
String bare = traversal.get(profile + ".blacklist");
// Sorted by index, not by map iteration order: Map.copyOf hands back an immutable map whose order is
// randomised per JVM, and MetadataService.loadTraversalBlacklistForProfile puts these into a map keyed
// by predicate, so the last tuple for a predicate wins. Only this accessor has that contract —
// predicates() collects into a Set, where a sort would be discarded work on a per-request path.
// The bare tuple goes LAST for the same reason: it was the only tuple the legacy reader returned
// when both forms were set, so it must win the last-wins keying — indexed entries can add
// predicates but never relax a denial the bare tuple carried.
return Stream.concat(
entriesWithNumericSuffix(keyPrefix)
.map(entry -> Map.entry(
new BigInteger(entry.getKey().substring(keyPrefix.length())),
entry.getValue()))
.sorted(Map.Entry.comparingByKey())
.map(Map.Entry::getValue),
(bare == null || bare.isBlank()) ? Stream.empty() : Stream.of(bare))
.toList();
}
private Stream<Map.Entry<String, String>> entriesWithNumericSuffix(String keyPrefix) {
return traversal.entrySet().stream()
.filter(entry -> entry.getKey().startsWith(keyPrefix)
&& isAsciiDigits(entry.getKey().substring(keyPrefix.length())));
}
/**
* Not {@code StringUtils.isNumeric}, which accepts any Unicode digit: {@code blacklist.٢} would pass
* that and then throw from the {@link BigInteger} sort above, turning a config typo into a 500 on
* every traversal request for the profile.
*/
private static boolean isAsciiDigits(String value) {
return !value.isEmpty() && value.chars().allMatch(c -> c >= '0' && c <= '9');
}
/** Null for a blank or unrecognised value. */
private static Boolean parseBoolean(String value) {
try {
return ApplicationConversionService.getSharedInstance().convert(value, Boolean.class);
} catch (ConversionFailedException e) {
return null;
}
}
private OptionalInt intSetting(String profile, String setting) {
String value = traversal.get(profile + "." + setting);
return value == null ? OptionalInt.empty() : OptionalInt.of(Integer.parseInt(value));
}
}