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));
	}
}