io.quarkus
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java
new file mode 100644
index 0000000..9c93f0d
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config;
+
+import de.cuioss.tools.logging.LogRecord;
+import de.cuioss.tools.logging.LogRecordModel;
+import lombok.experimental.UtilityClass;
+
+/**
+ * DSL-style {@link LogRecord} catalogue for the configuration subsystem.
+ *
+ * Structured {@code INFO} / {@code ERROR} messages carry the {@code ApiSheriff}
+ * prefix and a stable numeric identifier, continuing the shared identifier space
+ * used by the proxy edge, so they are greppable and assertable. {@code DEBUG} /
+ * {@code TRACE} diagnostics use the logger directly and are not catalogued here.
+ *
+ * The catalogue lives in the framework-agnostic {@code ...config} package because
+ * the cui-tools {@link LogRecord} abstraction carries no framework dependency
+ * (ADR-0005).
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@UtilityClass
+public final class ConfigLogMessages {
+
+ private static final String PREFIX = "ApiSheriff";
+
+ /**
+ * Info-level messages (INFO range 1-99).
+ */
+ @UtilityClass
+ public static final class INFO {
+
+ /** The configuration was loaded, validated, and assembled successfully. */
+ public static final LogRecord CONFIG_LOADED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(2)
+ .template("Configuration loaded successfully (config_version='%s')")
+ .build();
+ }
+
+ /**
+ * Error-level messages (ERROR range 200-299).
+ */
+ @UtilityClass
+ public static final class ERROR {
+
+ /** A single collected configuration violation, one per problem found. */
+ public static final LogRecord CONFIG_VALIDATION_FAILED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(200)
+ .template("Invalid configuration in '%s' at '%s': %s")
+ .build();
+
+ /** Startup is aborted because the configuration is invalid. */
+ public static final LogRecord CONFIG_STARTUP_ABORTED = LogRecordModel.builder()
+ .prefix(PREFIX)
+ .identifier(201)
+ .template("Refusing to start — configuration is invalid: %s")
+ .build();
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java
new file mode 100644
index 0000000..cc255d6
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config;
+
+import java.io.Serial;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.MatchConfig;
+import de.cuioss.sheriff.api.config.model.MatchConfig.HeaderMatcher;
+import de.cuioss.sheriff.api.config.model.Protocol;
+import de.cuioss.sheriff.api.config.model.ResolvedRoute;
+import de.cuioss.sheriff.api.config.model.ResolvedTopology;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.config.model.RouteConfig;
+import de.cuioss.sheriff.api.config.model.RouteTable;
+import de.cuioss.sheriff.api.config.model.UpstreamConfig;
+import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig;
+
+/**
+ * Assembles the immutable {@link RouteTable} from the validated configuration
+ * (pipeline step 8).
+ *
+ * The builder merges the routes of the enabled endpoints only — disabled
+ * endpoints contribute no rows — orders them by descending {@code path_prefix}
+ * length (most specific first), enforces same-prefix disjointness, and
+ * materializes each route's effective auth, effective {@code allowed_methods}, and
+ * effective retry / not-modified toggles into a {@link ResolvedRoute}. The three
+ * inheritance chains are resolved here, once, so the request pipeline never
+ * re-implements them.
+ *
+ * Framework-agnostic (ADR-0005): the collaborators are supplied as method
+ * arguments and the builder carries no framework imports.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class RouteTableBuilder {
+
+ private static final List STANDARD_ALLOWED_METHODS = List.copyOf(EnumSet.allOf(HttpMethod.class));
+
+ /**
+ * Builds the route table from the enabled endpoints and the resolved topology.
+ *
+ * @param gateway the bound gateway document
+ * @param endpoints the endpoints to merge; disabled entries are skipped
+ * @param topology the resolved topology providing each endpoint's upstream
+ * @return the immutable, longest-prefix-ordered route table
+ * @throws RouteTableException when an enabled endpoint's alias does not resolve
+ * or two same-prefix routes are not disjoint
+ */
+ public RouteTable build(GatewayConfig gateway, List endpoints, ResolvedTopology topology) {
+ Objects.requireNonNull(gateway, "gateway");
+ Objects.requireNonNull(endpoints, "endpoints");
+ Objects.requireNonNull(topology, "topology");
+
+ List resolved = new ArrayList<>();
+ for (EndpointConfig endpoint : endpoints) {
+ if (!endpoint.enabled()) {
+ continue;
+ }
+ ResolvedUpstream upstream = topology.lookup(endpoint.baseUrl()).orElseThrow(() -> new RouteTableException(
+ "unresolved topology alias for enabled endpoint '%s': %s".formatted(endpoint.id(),
+ endpoint.baseUrl())));
+ UpstreamDefaultsConfig defaults = resolveDefaults(gateway, endpoint);
+ List allowedMethods = effectiveAllowedMethods(gateway, endpoint);
+ for (RouteConfig route : endpoint.routes()) {
+ resolved.add(resolveRoute(route, endpoint, upstream, defaults, allowedMethods));
+ }
+ }
+
+ resolved.sort(Comparator.comparingInt((ResolvedRoute route) -> route.pathPrefix().length()).reversed()
+ .thenComparing(ResolvedRoute::pathPrefix));
+ enforceDisjointness(resolved);
+ return new RouteTable(resolved);
+ }
+
+ private static ResolvedRoute resolveRoute(RouteConfig route, EndpointConfig endpoint, ResolvedUpstream upstream,
+ UpstreamDefaultsConfig defaults, List allowedMethods) {
+ AuthConfig auth = route.auth().orElse(endpoint.auth());
+ boolean retryEnabled = route.upstream()
+ .flatMap(UpstreamConfig::retry)
+ .flatMap(UpstreamConfig.Retry::enabled)
+ .orElse(defaults.retryEnabled());
+ boolean notModifiedEnabled = route.upstream()
+ .flatMap(UpstreamConfig::notModified)
+ .flatMap(UpstreamConfig.NotModified::enabled)
+ .orElse(defaults.notModifiedEnabled());
+ return ResolvedRoute.builder()
+ .id(route.id())
+ .protocol(route.protocol().orElse(Protocol.HTTP))
+ .match(route.match())
+ .effectiveAuth(auth)
+ .effectiveAllowedMethods(allowedMethods)
+ .retryEnabled(retryEnabled)
+ .notModifiedEnabled(notModifiedEnabled)
+ .upstream(upstream)
+ .build();
+ }
+
+ private static UpstreamDefaultsConfig resolveDefaults(GatewayConfig gateway, EndpointConfig endpoint) {
+ UpstreamDefaultsConfig global = gateway.upstreamDefaults().orElseGet(UpstreamDefaultsConfig::defaults);
+ return endpoint.upstreamDefaults().orElse(global);
+ }
+
+ private static List effectiveAllowedMethods(GatewayConfig gateway, EndpointConfig endpoint) {
+ if (!endpoint.allowedMethods().isEmpty()) {
+ return List.copyOf(endpoint.allowedMethods());
+ }
+ if (!gateway.allowedMethods().isEmpty()) {
+ return List.copyOf(gateway.allowedMethods());
+ }
+ return STANDARD_ALLOWED_METHODS;
+ }
+
+ private static void enforceDisjointness(List routes) {
+ for (int i = 0; i < routes.size(); i++) {
+ for (int j = i + 1; j < routes.size(); j++) {
+ ResolvedRoute first = routes.get(i);
+ ResolvedRoute second = routes.get(j);
+ if (first.pathPrefix().equals(second.pathPrefix()) && overlaps(first.match(), second.match())) {
+ throw new RouteTableException(
+ "routes '%s' and '%s' share prefix '%s' and are not disjoint".formatted(first.id(),
+ second.id(), first.pathPrefix()));
+ }
+ }
+ }
+ }
+
+ private static boolean overlaps(MatchConfig first, MatchConfig second) {
+ return hostsOverlap(first, second) && methodsOverlap(first, second) && !headersDistinguish(first, second);
+ }
+
+ private static boolean hostsOverlap(MatchConfig first, MatchConfig second) {
+ return first.host().isEmpty() || second.host().isEmpty() || first.host().equals(second.host());
+ }
+
+ private static boolean methodsOverlap(MatchConfig first, MatchConfig second) {
+ return first.methods().isEmpty() || second.methods().isEmpty()
+ || !Collections.disjoint(first.methods(), second.methods());
+ }
+
+ private static boolean headersDistinguish(MatchConfig first, MatchConfig second) {
+ for (HeaderMatcher headerA : first.headers()) {
+ for (HeaderMatcher headerB : second.headers()) {
+ if (headerA.name().equalsIgnoreCase(headerB.name())
+ && (valuesDistinguish(headerA, headerB) || presenceDistinguishes(headerA, headerB))) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean valuesDistinguish(HeaderMatcher headerA, HeaderMatcher headerB) {
+ Optional valueA = headerA.value();
+ Optional valueB = headerB.value();
+ return valueA.isPresent() && valueB.isPresent() && !valueA.get().equals(valueB.get());
+ }
+
+ private static boolean presenceDistinguishes(HeaderMatcher headerA, HeaderMatcher headerB) {
+ Optional presentA = headerA.present();
+ Optional presentB = headerB.present();
+ return presentA.isPresent() && presentB.isPresent() && !presentA.get().equals(presentB.get());
+ }
+
+ /**
+ * Signals a route-table assembly failure: an enabled endpoint whose alias does
+ * not resolve, or two same-prefix routes that are not disjoint. Both are boot
+ * failures for an otherwise structurally valid configuration.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public static final class RouteTableException extends IllegalStateException {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates the exception with the given detail message.
+ *
+ * @param message the human-readable description of the assembly failure
+ */
+ public RouteTableException(String message) {
+ super(message);
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigError.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigError.java
new file mode 100644
index 0000000..67f4d40
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigError.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.load;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Objects;
+
+/**
+ * A single configuration error, carrying the source file, the location within that
+ * file, and a human-readable message.
+ *
+ * The {@link ConfigLoader} collects these across the whole configuration set and
+ * raises them together as a {@link ConfigLoadException}, so an operator sees every
+ * problem in one boot attempt rather than one per restart.
+ *
+ * @param file the configuration file the error originates from (e.g.
+ * {@code gateway.yaml} or {@code endpoints/orders.yaml})
+ * @param pointer the location within the file — a JSON-pointer / JSONPath-style
+ * instance location, empty for whole-file errors
+ * @param message the human-readable description of the problem
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public record ConfigError(String file, String pointer, String message) implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Canonical constructor requiring all components to be non-null.
+ */
+ public ConfigError {
+ Objects.requireNonNull(file, "file");
+ Objects.requireNonNull(pointer, "pointer");
+ Objects.requireNonNull(message, "message");
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoadException.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoadException.java
new file mode 100644
index 0000000..7b9d9af
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoadException.java
@@ -0,0 +1,68 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.load;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * Raised when configuration loading fails, carrying the complete set of
+ * {@link ConfigError}s discovered in a single loading pass.
+ *
+ * The loader aggregates every schema violation, secret-resolution failure, and
+ * binding error rather than failing on the first, so this exception describes all
+ * problems at once. It is a fail-fast boot signal: the application refuses to start
+ * when it is thrown.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public class ConfigLoadException extends Exception implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final List errors;
+
+ /**
+ * Creates the exception from the collected errors.
+ *
+ * @param errors the non-empty, aggregated set of configuration errors; copied
+ * defensively into an unmodifiable list
+ */
+ public ConfigLoadException(List errors) {
+ super(buildMessage(errors));
+ this.errors = List.copyOf(errors);
+ }
+
+ /**
+ * Returns the aggregated configuration errors.
+ *
+ * @return the unmodifiable list of every error discovered during loading
+ */
+ public List errors() {
+ return errors;
+ }
+
+ private static String buildMessage(List errors) {
+ return "Configuration loading failed with %d error(s):%n%s".formatted(errors.size(),
+ errors.stream()
+ .map(e -> " - %s [%s]: %s".formatted(e.file(), e.pointer(), e.message()))
+ .collect(Collectors.joining(System.lineSeparator())));
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java
new file mode 100644
index 0000000..4193ff4
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java
@@ -0,0 +1,326 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.load;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
+import java.io.Serializable;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.DeserializationContext;
+import com.fasterxml.jackson.databind.JsonDeserializer;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.MapperFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
+import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
+
+import com.networknt.schema.JsonSchema;
+import com.networknt.schema.JsonSchemaFactory;
+import com.networknt.schema.SpecVersion;
+import com.networknt.schema.ValidationMessage;
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Reads, validates, and binds the file-based API Sheriff configuration.
+ *
+ * {@link #load()} runs the boot pipeline over the configuration directory: it reads
+ * {@code gateway.yaml} and every {@code endpoints/*.yaml} file (sorted for
+ * deterministic error output), validates each document against the bundled D2 JSON
+ * Schemas, resolves {@code ${ENV_VAR}} secret references, and binds the valid trees
+ * to the immutable {@link de.cuioss.sheriff.api.config.model} records. Every problem
+ * is collected — never fail on the first — and raised together as a
+ * {@link ConfigLoadException}.
+ *
+ * The endpoint-enablement resolution, topology-alias resolution, cross-cutting
+ * semantic validation, and route-table assembly steps of the full boot sequence are
+ * layered on by later deliverables; this loader owns steps 1-4 (read,
+ * schema-validate, secret-resolve, bind) and produces the bound model.
+ *
+ * Framework-agnostic seam (ADR-0005). The configuration directory
+ * and the {@link EnvSecretResolver} are constructor-injected; the loader carries no
+ * framework imports. Instances are stateless between {@link #load()} calls and safe
+ * to reuse.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class ConfigLoader {
+
+ private static final String GATEWAY_FILE = "gateway.yaml";
+ private static final String ENDPOINTS_DIR = "endpoints";
+ private static final String ENDPOINT_ROOT = "endpoint";
+ private static final String ENABLED_FIELD = "enabled";
+
+ private final Path configDir;
+ private final EnvSecretResolver secretResolver;
+ private final ObjectMapper mapper;
+ private final JsonSchema gatewaySchema;
+ private final JsonSchema endpointSchema;
+
+ /**
+ * Creates a loader for the given configuration directory.
+ *
+ * @param configDir the directory holding {@code gateway.yaml} and the
+ * {@code endpoints/} subdirectory
+ * @param secretResolver the resolver for {@code ${ENV_VAR}} secret references
+ */
+ public ConfigLoader(Path configDir, EnvSecretResolver secretResolver) {
+ this.configDir = Objects.requireNonNull(configDir, "configDir");
+ this.secretResolver = Objects.requireNonNull(secretResolver, "secretResolver");
+ this.mapper = buildMapper();
+ JsonSchemaFactory factory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012);
+ this.gatewaySchema = loadSchema(factory, "/schema/gateway.schema.json");
+ this.endpointSchema = loadSchema(factory, "/schema/endpoint.schema.json");
+ }
+
+ /**
+ * Loads and binds the configuration.
+ *
+ * @return the bound gateway document and endpoints
+ * @throws ConfigLoadException aggregating every schema, secret, and binding
+ * error discovered in this pass
+ */
+ public LoadedConfig load() throws ConfigLoadException {
+ List errors = new ArrayList<>();
+
+ GatewayConfig gateway = loadGateway(errors);
+ List endpoints = loadEndpoints(errors);
+
+ if (!errors.isEmpty()) {
+ throw new ConfigLoadException(errors);
+ }
+ return new LoadedConfig(Objects.requireNonNull(gateway, "gateway"), List.copyOf(endpoints));
+ }
+
+ private @Nullable GatewayConfig loadGateway(List errors) {
+ JsonNode node = readYaml(configDir.resolve(GATEWAY_FILE), GATEWAY_FILE, errors);
+ if (node == null) {
+ return null;
+ }
+ validate(gatewaySchema, node, GATEWAY_FILE, errors);
+ resolveSecrets(node, GATEWAY_FILE, "", errors);
+ if (hasErrorsFor(GATEWAY_FILE, errors)) {
+ return null;
+ }
+ return bind(node, GatewayConfig.class, GATEWAY_FILE, errors);
+ }
+
+ private List loadEndpoints(List errors) {
+ List endpoints = new ArrayList<>();
+ for (Path path : listEndpointFiles(errors)) {
+ EndpointConfig endpoint = loadEndpoint(path, errors);
+ if (endpoint != null) {
+ endpoints.add(endpoint);
+ }
+ }
+ return endpoints;
+ }
+
+ private @Nullable EndpointConfig loadEndpoint(Path path, List errors) {
+ String file = ENDPOINTS_DIR + "/" + path.getFileName();
+ JsonNode root = readYaml(path, file, errors);
+ if (root == null) {
+ return null;
+ }
+ validate(endpointSchema, root, file, errors);
+ resolveSecrets(root, file, "", errors);
+ if (hasErrorsFor(file, errors)) {
+ return null;
+ }
+ JsonNode block = root.get(ENDPOINT_ROOT);
+ applyEnabledDefault(block);
+ return bind(block, EndpointConfig.class, file, errors);
+ }
+
+ private List listEndpointFiles(List errors) {
+ Path dir = configDir.resolve(ENDPOINTS_DIR);
+ if (!Files.isDirectory(dir)) {
+ return List.of();
+ }
+ try (Stream entries = Files.list(dir)) {
+ return entries
+ .filter(Files::isRegularFile)
+ .filter(p -> p.getFileName().toString().endsWith(".yaml"))
+ .sorted(Comparator.comparing(p -> p.getFileName().toString()))
+ .toList();
+ } catch (IOException e) {
+ errors.add(new ConfigError(ENDPOINTS_DIR, "", "cannot list endpoint files: " + e.getMessage()));
+ return List.of();
+ }
+ }
+
+ private @Nullable JsonNode readYaml(Path path, String file, List errors) {
+ if (!Files.isRegularFile(path)) {
+ errors.add(new ConfigError(file, "", "configuration file not found"));
+ return null;
+ }
+ try (Reader reader = Files.newBufferedReader(path)) {
+ return mapper.readTree(reader);
+ } catch (IOException e) {
+ errors.add(new ConfigError(file, "", "cannot read configuration file: " + e.getMessage()));
+ return null;
+ }
+ }
+
+ private static void validate(JsonSchema schema, JsonNode node, String file, List errors) {
+ Set messages = schema.validate(node);
+ for (ValidationMessage message : messages) {
+ errors.add(new ConfigError(file, message.getInstanceLocation().toString(), message.getMessage()));
+ }
+ }
+
+ private void resolveSecrets(JsonNode node, String file, String pointer, List errors) {
+ if (node instanceof ObjectNode object) {
+ List names = new ArrayList<>();
+ object.fieldNames().forEachRemaining(names::add);
+ for (String name : names) {
+ resolveChild(object.get(name), file, pointer + "/" + name, errors,
+ resolved -> object.put(name, resolved));
+ }
+ } else if (node instanceof ArrayNode array) {
+ for (int index = 0; index < array.size(); index++) {
+ int position = index;
+ resolveChild(array.get(index), file, pointer + "/" + index, errors,
+ resolved -> array.set(position, TextNode.valueOf(resolved)));
+ }
+ }
+ }
+
+ private void resolveChild(JsonNode child, String file, String pointer, List errors,
+ Consumer replacer) {
+ if (child.isTextual() && secretResolver.hasReference(child.asText())) {
+ try {
+ replacer.accept(secretResolver.resolve(child.asText()));
+ } catch (EnvSecretResolver.MissingVariableException e) {
+ errors.add(new ConfigError(file, pointer, e.getMessage()));
+ }
+ } else {
+ resolveSecrets(child, file, pointer, errors);
+ }
+ }
+
+ private static void applyEnabledDefault(JsonNode endpointBlock) {
+ if (endpointBlock instanceof ObjectNode object && !object.has(ENABLED_FIELD)) {
+ object.put(ENABLED_FIELD, true);
+ }
+ }
+
+ private @Nullable T bind(JsonNode node, Class type, String file, List errors) {
+ try {
+ return mapper.treeToValue(node, type);
+ } catch (IOException | IllegalArgumentException e) {
+ errors.add(new ConfigError(file, "", "binding failed: " + e.getMessage()));
+ return null;
+ }
+ }
+
+ private static boolean hasErrorsFor(String file, List errors) {
+ return errors.stream().anyMatch(error -> error.file().equals(file));
+ }
+
+ private static ObjectMapper buildMapper() {
+ SimpleModule module = new SimpleModule();
+ module.addDeserializer(UpstreamDefaultsConfig.class, new UpstreamDefaultsDeserializer());
+ // Register the Jdk8 module explicitly (Optional support) rather than via
+ // findAndAddModules()'s ServiceLoader discovery, which does not resolve in a
+ // native image and leaves Optional-typed record components unbindable.
+ return YAMLMapper.builder()
+ .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS)
+ .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
+ .addModule(new Jdk8Module())
+ .addModule(module)
+ .build();
+ }
+
+ private static JsonSchema loadSchema(JsonSchemaFactory factory, String resource) {
+ try (InputStream stream = ConfigLoader.class.getResourceAsStream(resource)) {
+ if (stream == null) {
+ throw new IllegalStateException("Missing bundled schema resource: " + resource);
+ }
+ return factory.getSchema(stream);
+ } catch (IOException e) {
+ throw new IllegalStateException("Cannot read bundled schema resource: " + resource, e);
+ }
+ }
+
+ /**
+ * The bound configuration produced by {@link #load()}.
+ *
+ * @param gateway the bound global {@code gateway.yaml} document
+ * @param endpoints the bound endpoint documents in deterministic (file-sorted)
+ * order, empty when none are declared
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record LoadedConfig(GatewayConfig gateway, List endpoints) {
+
+ /**
+ * Canonical constructor requiring {@code gateway} and defensively copying
+ * {@code endpoints}.
+ */
+ public LoadedConfig {
+ Objects.requireNonNull(gateway, "gateway");
+ endpoints = endpoints == null ? List.of() : List.copyOf(endpoints);
+ }
+ }
+
+ /**
+ * Binds the nested {@code upstream_defaults} YAML block
+ * ({@code {retry:{enabled}, not_modified:{enabled}}}) to the flat
+ * {@link UpstreamDefaultsConfig} record, defaulting each toggle to {@code true}
+ * when absent.
+ */
+ private static final class UpstreamDefaultsDeserializer extends JsonDeserializer
+ implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public UpstreamDefaultsConfig deserialize(JsonParser parser, DeserializationContext context)
+ throws IOException {
+ JsonNode node = parser.readValueAsTree();
+ return new UpstreamDefaultsConfig(readEnabled(node, "retry"), readEnabled(node, "not_modified"));
+ }
+
+ private static boolean readEnabled(JsonNode node, String block) {
+ JsonNode blockNode = node.get(block);
+ if (blockNode == null) {
+ return true;
+ }
+ JsonNode enabled = blockNode.get(ENABLED_FIELD);
+ return enabled == null || enabled.asBoolean(true);
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/EnvSecretResolver.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/EnvSecretResolver.java
new file mode 100644
index 0000000..4cf2c74
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/EnvSecretResolver.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.load;
+
+import java.util.Objects;
+import java.util.function.UnaryOperator;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Resolves {@code ${ENV_VAR}} secret references embedded in configuration string
+ * values against a supplied environment lookup.
+ *
+ * A reference names an environment variable using the pattern
+ * {@code ${NAME}} where {@code NAME} starts with a letter or underscore and
+ * continues with letters, digits, or underscores. Every reference in a value is
+ * substituted; a reference whose variable is absent is a hard failure surfaced as a
+ * {@link MissingVariableException} so the loader can record it as a
+ * {@link ConfigError}.
+ *
+ * The environment lookup is constructor-injected (defaulting to
+ * {@link System#getenv(String)}), keeping the resolver framework-agnostic and
+ * deterministically testable.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class EnvSecretResolver {
+
+ private static final Pattern REFERENCE = Pattern.compile("\\$\\{([A-Za-z_]\\w*)}");
+
+ private final UnaryOperator<@Nullable String> lookup;
+
+ /**
+ * Creates a resolver backed by the process environment
+ * ({@link System#getenv(String)}).
+ */
+ public EnvSecretResolver() {
+ this(System::getenv);
+ }
+
+ /**
+ * Creates a resolver backed by the supplied lookup.
+ *
+ * @param lookup maps an environment-variable name to its value, or {@code null}
+ * when the variable is undefined
+ */
+ public EnvSecretResolver(UnaryOperator<@Nullable String> lookup) {
+ this.lookup = Objects.requireNonNull(lookup, "lookup");
+ }
+
+ /**
+ * Reports whether the value contains at least one {@code ${VAR}} reference.
+ *
+ * @param value the raw configuration value
+ * @return {@code true} when a reference is present
+ */
+ public boolean hasReference(String value) {
+ return REFERENCE.matcher(value).find();
+ }
+
+ /**
+ * Substitutes every {@code ${VAR}} reference in the value with the resolved
+ * environment value.
+ *
+ * @param value the raw configuration value
+ * @return the value with all references substituted
+ * @throws MissingVariableException when a referenced variable is undefined
+ */
+ public String resolve(String value) {
+ Matcher matcher = REFERENCE.matcher(value);
+ StringBuilder result = new StringBuilder();
+ while (matcher.find()) {
+ String name = matcher.group(1);
+ String resolved = lookup.apply(name);
+ if (resolved == null) {
+ throw new MissingVariableException(name);
+ }
+ matcher.appendReplacement(result, Matcher.quoteReplacement(resolved));
+ }
+ matcher.appendTail(result);
+ return result.toString();
+ }
+
+ /**
+ * Signals that a referenced environment variable is undefined.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public static final class MissingVariableException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ private final String variableName;
+
+ MissingVariableException(String variableName) {
+ super("Unresolved environment variable: " + variableName);
+ this.variableName = variableName;
+ }
+
+ /**
+ * Returns the name of the undefined environment variable.
+ *
+ * @return the missing variable name
+ */
+ public String variableName() {
+ return variableName;
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/package-info.java
new file mode 100644
index 0000000..abdf29a
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/package-info.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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.
+ */
+/**
+ * Boot-time loader for the API Sheriff file-based configuration.
+ *
+ * {@link de.cuioss.sheriff.api.config.load.ConfigLoader} reads {@code gateway.yaml}
+ * and the {@code endpoints/*.yaml} files from the configuration directory,
+ * validates each document against the bundled D2 JSON Schemas
+ * (draft 2020-12, via {@code com.networknt:json-schema-validator}), resolves
+ * {@code ${ENV_VAR}} secret references through
+ * {@link de.cuioss.sheriff.api.config.load.EnvSecretResolver}, and binds the valid
+ * trees to the immutable {@link de.cuioss.sheriff.api.config.model} records with
+ * Jackson.
+ *
+ * Error aggregation. Loading never fails on the first problem: all
+ * schema violations, secret-resolution failures, and binding errors are collected —
+ * each carrying its source file and a JSON-pointer location — and raised together as
+ * a single {@link de.cuioss.sheriff.api.config.load.ConfigLoadException}.
+ *
+ * Framework-agnostic seam (ADR-0005). The loader carries no CDI,
+ * Quarkus, or framework imports; its configuration directory and secret resolver are
+ * supplied by constructor parameters. The cross-cutting semantic validation,
+ * endpoint-enablement resolution, topology-alias resolution, and route-table
+ * assembly are layered on by later deliverables.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.api.config.load;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/AuthConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/AuthConfig.java
new file mode 100644
index 0000000..50a3727
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/AuthConfig.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.List;
+import java.util.Objects;
+
+import lombok.Builder;
+
+/**
+ * The {@code auth} block, declarable at endpoint level (mandatory default
+ * posture) and per route (wholesale override).
+ *
+ * {@code require} is one of {@code none} / {@code bearer} / {@code session}; the
+ * value set is enforced by the configuration validator. {@code required_scopes}
+ * is valid at either level; because override is wholesale, a route-level block
+ * that omits it drops endpoint-level scope enforcement for that route.
+ *
+ * @param require the authentication requirement (mandatory)
+ * @param requiredScopes the scopes enforced for this posture, empty when none
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record AuthConfig(String require, List requiredScopes) {
+
+ /**
+ * Canonical constructor defensively copying {@code requiredScopes} into an
+ * unmodifiable list and normalizing an absent list to empty.
+ */
+ public AuthConfig {
+ Objects.requireNonNull(require, "require");
+ requiredScopes = requiredScopes == null ? List.of() : List.copyOf(requiredScopes);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/EndpointConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/EndpointConfig.java
new file mode 100644
index 0000000..0c8e9eb
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/EndpointConfig.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import lombok.Builder;
+
+/**
+ * One {@code endpoints/*.yaml} file's {@code endpoint} block.
+ *
+ * {@code id} is mandatory and unique across all endpoint files (a duplicate
+ * fails the boot). {@code enabled} defaults to {@code true}; a disabled endpoint
+ * is inert (its routes are not merged and its alias need not resolve).
+ * {@code allowedMethods}, when non-empty, replaces the global
+ * {@code gateway.allowed_methods} wholesale for this endpoint (no inheritance); an
+ * empty list means the global list applies. The endpoint-level
+ * {@code upstreamDefaults}, when present, replaces the global block wholesale for
+ * this endpoint's routes.
+ *
+ * @param id the unique endpoint id (mandatory)
+ * @param enabled whether the endpoint is active
+ * @param baseUrl the topology alias (mandatory)
+ * @param auth the mandatory default auth posture for the routes
+ * @param allowedMethods the per-endpoint verb allowlist, empty meaning the
+ * global list applies
+ * @param upstreamDefaults the endpoint-level retry/not-modified defaults, empty
+ * when the global block applies
+ * @param routes the routes declared by this endpoint, empty when none
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record EndpointConfig(String id, boolean enabled, String baseUrl, AuthConfig auth,
+ List allowedMethods, Optional upstreamDefaults, List routes) {
+
+ /**
+ * Canonical constructor requiring the mandatory fields, defensively copying the
+ * collections, and normalizing absent components.
+ */
+ public EndpointConfig {
+ Objects.requireNonNull(id, "id");
+ Objects.requireNonNull(baseUrl, "baseUrl");
+ Objects.requireNonNull(auth, "auth");
+ allowedMethods = allowedMethods == null ? List.of() : List.copyOf(allowedMethods);
+ upstreamDefaults = Objects.requireNonNullElse(upstreamDefaults, Optional.empty());
+ routes = routes == null ? List.of() : List.copyOf(routes);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardConfig.java
new file mode 100644
index 0000000..d91210a
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardConfig.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.List;
+import java.util.Map;
+
+import lombok.Builder;
+
+/**
+ * The per-route {@code forward} block: a deny-by-default allowlist of what
+ * crosses to the upstream.
+ *
+ * {@code headers_allow} / {@code query_allow} are allowlists — anything not
+ * listed is not forwarded. {@code set_headers} adds static, gateway-controlled
+ * headers. The mediated {@code Authorization} and the regenerated
+ * forwarding-header set are injected automatically and are never configured here.
+ *
+ * @param headersAllow the forwardable request-header names, empty when none
+ * @param queryAllow the forwardable query-parameter names, empty when none
+ * @param setHeaders static gateway-set headers, empty when none
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record ForwardConfig(List headersAllow, List queryAllow, Map setHeaders) {
+
+ /**
+ * Canonical constructor defensively copying the collections into unmodifiable
+ * copies and normalizing absent collections to empty.
+ */
+ public ForwardConfig {
+ headersAllow = headersAllow == null ? List.of() : List.copyOf(headersAllow);
+ queryAllow = queryAllow == null ? List.of() : List.copyOf(queryAllow);
+ setHeaders = setHeaders == null ? Map.of() : Map.copyOf(setHeaders);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardedConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardedConfig.java
new file mode 100644
index 0000000..cea491c
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardedConfig.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import lombok.Builder;
+
+/**
+ * The global {@code forwarded} block of {@code gateway.yaml}: the
+ * forwarded-header trust policy.
+ *
+ * {@code trusted_proxies} is a mandatory CIDR allowlist (which may be empty,
+ * meaning forwarding headers are never trusted). {@code emit} is one of
+ * {@code x-forwarded} / {@code both}; the value set is enforced by the
+ * configuration validator.
+ *
+ * @param trustedProxies the trusted-proxy CIDRs, empty when forwarding headers
+ * are never trusted
+ * @param trustSchemeHost whether the sanitized forwarded scheme/host/port/prefix
+ * is honored, empty when omitted
+ * @param emit the canonical output mode, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record ForwardedConfig(List trustedProxies, Optional trustSchemeHost, Optional emit) {
+
+ /**
+ * Canonical constructor defensively copying {@code trustedProxies} and
+ * normalizing absent components.
+ */
+ public ForwardedConfig {
+ trustedProxies = trustedProxies == null ? List.of() : List.copyOf(trustedProxies);
+ trustSchemeHost = Objects.requireNonNullElse(trustSchemeHost, Optional.empty());
+ emit = Objects.requireNonNullElse(emit, Optional.empty());
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/GatewayConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/GatewayConfig.java
new file mode 100644
index 0000000..8ff6d0b
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/GatewayConfig.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import lombok.Builder;
+
+/**
+ * The root {@code gateway.yaml} configuration document: the global settings the
+ * gateway applies across all endpoints.
+ *
+ * {@code allowedMethods} is the global positive HTTP-verb allowlist (empty means
+ * the standard set applies, materialized per route by the route-table builder).
+ * {@code upstreamDefaults} carries the global retry/not-modified defaults; an
+ * endpoint block replaces it wholesale for that endpoint's routes.
+ *
+ * @param version the config schema version (unknown values are refused
+ * by the validator)
+ * @param metadata the audit-stamp metadata, empty when omitted
+ * @param tls the TLS settings, empty when omitted
+ * @param securityHeaders the response-header middleware settings, empty when
+ * omitted
+ * @param securityDefaults the global security-filter baseline, empty when omitted
+ * @param allowedMethods the global verb allowlist, empty meaning the standard set
+ * @param upstreamDefaults the global retry/not-modified defaults, empty when omitted
+ * @param forwarded the forwarded-header trust policy, empty when omitted
+ * @param tokenValidation the offline bearer-validation settings, empty when omitted
+ * @param oidc the confidential-client settings, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record GatewayConfig(int version, Optional metadata, Optional tls,
+ Optional securityHeaders, Optional securityDefaults,
+ List allowedMethods, Optional upstreamDefaults,
+ Optional forwarded, Optional tokenValidation, Optional oidc) {
+
+ /**
+ * Canonical constructor defensively copying {@code allowedMethods} and
+ * normalizing absent optional blocks to {@link Optional#empty()}.
+ */
+ public GatewayConfig {
+ metadata = Objects.requireNonNullElse(metadata, Optional.empty());
+ tls = Objects.requireNonNullElse(tls, Optional.empty());
+ securityHeaders = Objects.requireNonNullElse(securityHeaders, Optional.empty());
+ securityDefaults = Objects.requireNonNullElse(securityDefaults, Optional.empty());
+ allowedMethods = allowedMethods == null ? List.of() : List.copyOf(allowedMethods);
+ upstreamDefaults = Objects.requireNonNullElse(upstreamDefaults, Optional.empty());
+ forwarded = Objects.requireNonNullElse(forwarded, Optional.empty());
+ tokenValidation = Objects.requireNonNullElse(tokenValidation, Optional.empty());
+ oidc = Objects.requireNonNullElse(oidc, Optional.empty());
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/HttpMethod.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/HttpMethod.java
new file mode 100644
index 0000000..4c18e01
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/HttpMethod.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+/**
+ * The proxyable HTTP verbs the gateway is permitted to serve.
+ *
+ * {@code TRACE} and {@code CONNECT} are deliberately absent: they are not
+ * proxyable through the data plane and are never permitted. Any configuration
+ * naming either verb is rejected by the configuration validator with a boot
+ * failure — they are simply not representable in the model.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public enum HttpMethod {
+
+ /** HTTP {@code GET}. */
+ GET,
+ /** HTTP {@code HEAD}. */
+ HEAD,
+ /** HTTP {@code POST}. */
+ POST,
+ /** HTTP {@code PUT}. */
+ PUT,
+ /** HTTP {@code PATCH}. */
+ PATCH,
+ /** HTTP {@code DELETE}. */
+ DELETE,
+ /** HTTP {@code OPTIONS}. */
+ OPTIONS
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/IssuerConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/IssuerConfig.java
new file mode 100644
index 0000000..1fd7072
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/IssuerConfig.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import lombok.Builder;
+
+/**
+ * A single {@code token_validation.issuers[]} entry.
+ *
+ * @param name the unique issuer label used in logs and metrics (mandatory)
+ * @param issuer the expected {@code iss} claim (mandatory)
+ * @param audience the expected {@code aud} claim, empty when omitted
+ * @param jwks the key material for signature verification, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record IssuerConfig(String name, String issuer, Optional audience, Optional jwks) {
+
+ /**
+ * Canonical constructor requiring the mandatory fields and normalizing absent
+ * optionals to {@link Optional#empty()}.
+ */
+ public IssuerConfig {
+ Objects.requireNonNull(name, "name");
+ Objects.requireNonNull(issuer, "issuer");
+ audience = Objects.requireNonNullElse(audience, Optional.empty());
+ jwks = Objects.requireNonNullElse(jwks, Optional.empty());
+ }
+
+ /**
+ * Key material for signature verification.
+ *
+ * @param source the key source ({@code http} / {@code file} / {@code inline}),
+ * mandatory
+ * @param url the JWKS URL (for {@code source: http}), empty otherwise
+ * @param file the JWKS file path (for {@code source: file}), empty otherwise
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @Builder
+ public record Jwks(String source, Optional url, Optional file) {
+
+ /**
+ * Canonical constructor requiring {@code source} and normalizing absent
+ * optionals to {@link Optional#empty()}.
+ */
+ public Jwks {
+ Objects.requireNonNull(source, "source");
+ url = Objects.requireNonNullElse(url, Optional.empty());
+ file = Objects.requireNonNullElse(file, Optional.empty());
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/MatchConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/MatchConfig.java
new file mode 100644
index 0000000..c5d887d
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/MatchConfig.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import lombok.Builder;
+
+/**
+ * The per-route {@code match} block. Matchers compose with AND semantics: a
+ * route matches only if every declared matcher holds.
+ *
+ * @param pathPrefix the literal path prefix and precedence key (mandatory)
+ * @param methods the matched HTTP methods, empty meaning all methods
+ * @param host the exact host match, empty when omitted
+ * @param headers the header matchers, empty when none
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record MatchConfig(String pathPrefix, List methods, Optional host,
+ List headers) {
+
+ /**
+ * Canonical constructor requiring {@code pathPrefix}, defensively copying the
+ * collections, and normalizing absent components.
+ */
+ public MatchConfig {
+ Objects.requireNonNull(pathPrefix, "pathPrefix");
+ methods = methods == null ? List.of() : List.copyOf(methods);
+ host = Objects.requireNonNullElse(host, Optional.empty());
+ headers = headers == null ? List.of() : List.copyOf(headers);
+ }
+
+ /**
+ * A single header matcher: presence or exact value.
+ *
+ * @param name the header name (mandatory)
+ * @param present whether the header must merely be present, empty when omitted
+ * @param value the exact required value, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @Builder
+ public record HeaderMatcher(String name, Optional present, Optional value) {
+
+ /**
+ * Canonical constructor requiring {@code name} and normalizing absent
+ * optionals.
+ */
+ public HeaderMatcher {
+ Objects.requireNonNull(name, "name");
+ present = Objects.requireNonNullElse(present, Optional.empty());
+ value = Objects.requireNonNullElse(value, Optional.empty());
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Metadata.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Metadata.java
new file mode 100644
index 0000000..fe366e3
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Metadata.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * The optional {@code metadata} block of {@code gateway.yaml}.
+ *
+ * {@code config_version} is an audit stamp only — logged at boot and exposed for
+ * operational traceability, with no runtime behaviour.
+ *
+ * @param configVersion the audit-stamp config version, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public record Metadata(Optional configVersion) {
+
+ /**
+ * Canonical constructor normalizing an absent {@code configVersion} to
+ * {@link Optional#empty()}.
+ */
+ public Metadata {
+ configVersion = Objects.requireNonNullElse(configVersion, Optional.empty());
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/OidcConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/OidcConfig.java
new file mode 100644
index 0000000..a267c1a
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/OidcConfig.java
@@ -0,0 +1,230 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import lombok.Builder;
+
+/**
+ * The global {@code oidc} block of {@code gateway.yaml}: the confidential-client
+ * configuration used by the BFF variants. Ignored when no route's effective auth
+ * is {@code require: session}.
+ *
+ * @param issuer the OIDC issuer, empty when omitted
+ * @param clientId the client id, empty when omitted
+ * @param clientSecret the client secret ({@code ${ENV_VAR}} reference), empty
+ * when omitted
+ * @param scopes the requested scopes, empty when none
+ * @param redirectUri the gateway callback URI, empty when omitted
+ * @param logout the logout settings, empty when omitted
+ * @param session the session settings, empty when omitted
+ * @param stepUp the step-up authentication settings, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record OidcConfig(Optional issuer, Optional clientId, Optional clientSecret,
+ List scopes, Optional redirectUri, Optional logout, Optional session,
+ Optional stepUp) {
+
+ /**
+ * Canonical constructor defensively copying {@code scopes} and normalizing
+ * absent components.
+ */
+ public OidcConfig {
+ issuer = Objects.requireNonNullElse(issuer, Optional.empty());
+ clientId = Objects.requireNonNullElse(clientId, Optional.empty());
+ clientSecret = Objects.requireNonNullElse(clientSecret, Optional.empty());
+ scopes = scopes == null ? List.of() : List.copyOf(scopes);
+ redirectUri = Objects.requireNonNullElse(redirectUri, Optional.empty());
+ logout = Objects.requireNonNullElse(logout, Optional.empty());
+ session = Objects.requireNonNullElse(session, Optional.empty());
+ stepUp = Objects.requireNonNullElse(stepUp, Optional.empty());
+ }
+
+ /**
+ * Overridden to redact {@link #clientSecret()}. The default record
+ * {@code toString()} would otherwise print the resolved secret value verbatim
+ * — {@link de.cuioss.sheriff.api.config.load.ConfigLoader} substitutes the
+ * {@code ${ENV_VAR}} reference with the real secret before binding, so an
+ * unredacted {@code toString()} would leak it into any log line, exception
+ * message, or debugger view that captures this instance.
+ *
+ * @return a string representation with {@code clientSecret} redacted
+ */
+ @Override
+ public String toString() {
+ return "OidcConfig[issuer=%s, clientId=%s, clientSecret=%s, scopes=%s, redirectUri=%s, logout=%s, session=%s, stepUp=%s]"
+ .formatted(issuer, clientId, redact(clientSecret), scopes, redirectUri, logout, session, stepUp);
+ }
+
+ /**
+ * Redacts a secret-bearing {@link Optional} for display, preserving presence
+ * without exposing the value.
+ *
+ * @param secret the secret-bearing optional to redact
+ * @return {@code "Optional[***REDACTED***]"} when present, {@code "Optional.empty"} otherwise
+ */
+ private static String redact(Optional secret) {
+ return secret.isPresent() ? "Optional[***REDACTED***]" : "Optional.empty";
+ }
+
+ /**
+ * RP-initiated logout settings.
+ *
+ * @param path the gateway-served logout path, empty when omitted
+ * @param postLogoutRedirectUri the gateway-owned return leg, empty when omitted
+ * @param finalRedirect the application landing URL after logout, empty
+ * when omitted
+ * @param backchannelPath the back-channel logout receiver path, empty when
+ * omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @Builder
+ public record Logout(Optional path, Optional postLogoutRedirectUri, Optional finalRedirect,
+ Optional backchannelPath) {
+
+ /**
+ * Canonical constructor normalizing absent components to {@link Optional#empty()}.
+ */
+ public Logout {
+ path = Objects.requireNonNullElse(path, Optional.empty());
+ postLogoutRedirectUri = Objects.requireNonNullElse(postLogoutRedirectUri, Optional.empty());
+ finalRedirect = Objects.requireNonNullElse(finalRedirect, Optional.empty());
+ backchannelPath = Objects.requireNonNullElse(backchannelPath, Optional.empty());
+ }
+ }
+
+ /**
+ * Session settings. The exhibited fields span both modes: {@code store} is
+ * server-mode only, the encryption keys are cookie-mode only.
+ *
+ * @param mode the session mode ({@code cookie} / {@code server}), empty
+ * when omitted
+ * @param store the server-mode store, empty when omitted
+ * @param cookieName the session cookie name, empty when omitted
+ * @param encryptionKey the cookie-mode AES key ({@code ${ENV_VAR}} reference),
+ * empty when omitted
+ * @param previousKey the optional decrypt-only rotation key, empty when omitted
+ * @param ttlSeconds the absolute session lifetime in seconds, empty when
+ * omitted
+ * @param csrf the CSRF settings, empty when omitted
+ * @param refresh the token-refresh settings, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @Builder
+ public record Session(Optional mode, Optional store, Optional cookieName,
+ Optional encryptionKey, Optional previousKey, Optional ttlSeconds,
+ Optional csrf, Optional refresh) {
+
+ /**
+ * Canonical constructor normalizing absent components to {@link Optional#empty()}.
+ */
+ public Session {
+ mode = Objects.requireNonNullElse(mode, Optional.empty());
+ store = Objects.requireNonNullElse(store, Optional.empty());
+ cookieName = Objects.requireNonNullElse(cookieName, Optional.empty());
+ encryptionKey = Objects.requireNonNullElse(encryptionKey, Optional.empty());
+ previousKey = Objects.requireNonNullElse(previousKey, Optional.empty());
+ ttlSeconds = Objects.requireNonNullElse(ttlSeconds, Optional.empty());
+ csrf = Objects.requireNonNullElse(csrf, Optional.empty());
+ refresh = Objects.requireNonNullElse(refresh, Optional.empty());
+ }
+
+ /**
+ * Overridden to redact {@link #encryptionKey()} and {@link #previousKey()}.
+ * The default record {@code toString()} would otherwise print the resolved
+ * cookie-encryption key values verbatim into any log line, exception message,
+ * or debugger view that captures this instance.
+ *
+ * @return a string representation with both key fields redacted
+ */
+ @Override
+ public String toString() {
+ return "Session[mode=%s, store=%s, cookieName=%s, encryptionKey=%s, previousKey=%s, ttlSeconds=%s, csrf=%s, refresh=%s]"
+ .formatted(mode, store, cookieName, redact(encryptionKey), redact(previousKey), ttlSeconds, csrf,
+ refresh);
+ }
+ }
+
+ /**
+ * CSRF settings for {@code require: session} routes.
+ *
+ * @param trustedOrigins the browser origins allowed on unsafe methods, empty
+ * when defaulting to the {@code redirect_uri} origin
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record Csrf(List trustedOrigins) {
+
+ /**
+ * Canonical constructor defensively copying {@code trustedOrigins} and
+ * normalizing an absent list to empty.
+ */
+ public Csrf {
+ trustedOrigins = trustedOrigins == null ? List.of() : List.copyOf(trustedOrigins);
+ }
+ }
+
+ /**
+ * Transparent token-refresh settings.
+ *
+ * @param enabled whether refresh is enabled, empty when omitted
+ * @param leewaySeconds how long before expiry to refresh, empty when omitted
+ * @param onFailure the on-failure behaviour ({@code reauthenticate} /
+ * {@code reject}), empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @Builder
+ public record Refresh(Optional enabled, Optional leewaySeconds, Optional onFailure) {
+
+ /**
+ * Canonical constructor normalizing absent components to {@link Optional#empty()}.
+ */
+ public Refresh {
+ enabled = Objects.requireNonNullElse(enabled, Optional.empty());
+ leewaySeconds = Objects.requireNonNullElse(leewaySeconds, Optional.empty());
+ onFailure = Objects.requireNonNullElse(onFailure, Optional.empty());
+ }
+ }
+
+ /**
+ * RFC 9470 step-up authentication settings.
+ *
+ * @param enabled whether step-up is honored, empty when omitted
+ * @param honorUpstreamChallenge whether upstream challenges are honored, empty
+ * when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @Builder
+ public record StepUp(Optional enabled, Optional honorUpstreamChallenge) {
+
+ /**
+ * Canonical constructor normalizing absent components to {@link Optional#empty()}.
+ */
+ public StepUp {
+ enabled = Objects.requireNonNullElse(enabled, Optional.empty());
+ honorUpstreamChallenge = Objects.requireNonNullElse(honorUpstreamChallenge, Optional.empty());
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Protocol.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Protocol.java
new file mode 100644
index 0000000..a77586e
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Protocol.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+/**
+ * The application protocol a route serves.
+ *
+ * The value selects handshake / streaming behaviour only (WebSocket upgrade,
+ * gRPC streaming); the security semantics of each protocol are defined by the
+ * architecture, not by the model. {@link #HTTP} is the default when a route
+ * omits {@code protocol}.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public enum Protocol {
+
+ /** Plain HTTP (the default). */
+ HTTP,
+ /** gRPC (carried over HTTP/2). */
+ GRPC,
+ /** GraphQL over HTTP. */
+ GRAPHQL,
+ /** WebSocket (HTTP upgrade). */
+ WEBSOCKET
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RateLimitConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RateLimitConfig.java
new file mode 100644
index 0000000..d4df545
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RateLimitConfig.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import lombok.Builder;
+
+/**
+ * The reserved per-route {@code rate_limit} block.
+ *
+ * The rate-limiting feature is deferred; the block is accepted and ignored so
+ * the schema need not change when the feature ships.
+ *
+ * @param requestsPerSecond the sustained request rate, empty when omitted
+ * @param burst the burst allowance, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record RateLimitConfig(Optional requestsPerSecond, Optional burst) {
+
+ /**
+ * Canonical constructor normalizing absent components to {@link Optional#empty()}.
+ */
+ public RateLimitConfig {
+ requestsPerSecond = Objects.requireNonNullElse(requestsPerSecond, Optional.empty());
+ burst = Objects.requireNonNullElse(burst, Optional.empty());
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java
new file mode 100644
index 0000000..ebf4479
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.List;
+import java.util.Objects;
+
+import lombok.Builder;
+
+/**
+ * One route with every inherited setting materialized once, at boot, into its
+ * effective value.
+ *
+ * The route-table builder resolves the three inheritance chains — effective auth
+ * (route override wholesale, else the endpoint default), effective
+ * {@code allowed_methods} (endpoint list if declared, else the global list, else
+ * the standard set), and the effective retry / not-modified toggles (global
+ * {@code upstream_defaults}, wholesale-replaced by an endpoint block, overridden
+ * per route) — so downstream pipeline code consumes the already resolved values
+ * and never re-implements the inheritance.
+ *
+ * @param id the route id (mandatory)
+ * @param protocol the effective protocol (defaults to
+ * {@link Protocol#HTTP})
+ * @param match the matcher set (mandatory)
+ * @param effectiveAuth the materialized auth posture (mandatory)
+ * @param effectiveAllowedMethods the materialized verb allowlist, never empty for
+ * a resolved route
+ * @param retryEnabled the materialized upstream-retry toggle
+ * @param notModifiedEnabled the materialized HTTP-304 not-modified toggle
+ * @param upstream the resolved upstream target for the route's
+ * endpoint (mandatory)
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record ResolvedRoute(String id, Protocol protocol, MatchConfig match, AuthConfig effectiveAuth,
+ List effectiveAllowedMethods, boolean retryEnabled, boolean notModifiedEnabled,
+ ResolvedUpstream upstream) {
+
+ /**
+ * Canonical constructor requiring the mandatory components, defensively copying
+ * {@code effectiveAllowedMethods}, and defaulting an absent {@code protocol} to
+ * {@link Protocol#HTTP}.
+ */
+ public ResolvedRoute {
+ Objects.requireNonNull(id, "id");
+ protocol = protocol == null ? Protocol.HTTP : protocol;
+ Objects.requireNonNull(match, "match");
+ Objects.requireNonNull(effectiveAuth, "effectiveAuth");
+ effectiveAllowedMethods = effectiveAllowedMethods == null ? List.of() : List.copyOf(effectiveAllowedMethods);
+ Objects.requireNonNull(upstream, "upstream");
+ }
+
+ /**
+ * The route's {@code path_prefix} — the precedence and ordering key carried by
+ * the {@link #match()} block.
+ *
+ * @return the literal path prefix of this route
+ */
+ public String pathPrefix() {
+ return match.pathPrefix();
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedTopology.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedTopology.java
new file mode 100644
index 0000000..50107c8
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedTopology.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * The immutable, fully-resolved topology: every topology alias referenced by an
+ * enabled endpoint mapped to its decomposed {@link ResolvedUpstream}.
+ *
+ * Aliases referenced only by disabled endpoints are absent, since disabled
+ * endpoints are exempt from alias resolution.
+ *
+ * @param aliases the alias-to-upstream mapping, empty when nothing is resolved
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public record ResolvedTopology(Map aliases) {
+
+ /**
+ * Canonical constructor defensively copying {@code aliases} into an unmodifiable
+ * map and normalizing an absent map to empty.
+ */
+ public ResolvedTopology {
+ aliases = aliases == null ? Map.of() : Map.copyOf(aliases);
+ }
+
+ /**
+ * Looks up the resolved upstream for a topology alias.
+ *
+ * @param alias the topology alias
+ * @return the resolved upstream, or empty when the alias is not resolved
+ */
+ public Optional lookup(String alias) {
+ return Optional.ofNullable(aliases.get(alias));
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedUpstream.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedUpstream.java
new file mode 100644
index 0000000..9dbd549
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedUpstream.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.Objects;
+
+/**
+ * A topology alias decomposed once, at boot, into its upstream URL components
+ * (ADR-0004).
+ *
+ * Decomposing the configured URL a single time — rather than re-parsing it on every
+ * request — is the decompose-on-read contract: the hot path consumes the already
+ * split scheme, host, port, and base path.
+ *
+ * @param scheme the URL scheme (e.g. {@code https})
+ * @param host the upstream host
+ * @param port the upstream port; the scheme default when the URL omits it
+ * @param basePath the base path prefix, empty when the URL carries none
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public record ResolvedUpstream(String scheme, String host, int port, String basePath) {
+
+ /**
+ * Canonical constructor requiring {@code scheme} and {@code host} and normalizing
+ * an absent {@code basePath} to the empty string.
+ */
+ public ResolvedUpstream {
+ Objects.requireNonNull(scheme, "scheme");
+ Objects.requireNonNull(host, "host");
+ basePath = basePath == null ? "" : basePath;
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteConfig.java
new file mode 100644
index 0000000..182a6c6
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteConfig.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import lombok.Builder;
+
+/**
+ * A single {@code routes[]} entry of an endpoint file.
+ *
+ * An absent {@code auth} block inherits the endpoint's {@code auth} wholesale; a
+ * route-level block replaces it wholesale (no field merging). An absent
+ * {@code protocol} means {@link Protocol#HTTP}.
+ *
+ * @param id the route id, unique across all endpoint files (mandatory)
+ * @param protocol the served protocol, empty meaning HTTP
+ * @param match the matcher set (mandatory)
+ * @param auth the route-level auth override, empty when inheriting the
+ * endpoint default
+ * @param securityFilter the route-level security filter, empty when the global
+ * default applies
+ * @param forward the forwarding allowlist, empty when nothing is forwarded
+ * @param upstream the upstream target settings, empty when omitted
+ * @param rateLimit the reserved rate-limit block, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record RouteConfig(String id, Optional protocol, MatchConfig match, Optional auth,
+ Optional securityFilter, Optional forward, Optional upstream,
+ Optional rateLimit) {
+
+ /**
+ * Canonical constructor requiring {@code id} and {@code match} and normalizing
+ * absent optionals to {@link Optional#empty()}.
+ */
+ public RouteConfig {
+ Objects.requireNonNull(id, "id");
+ Objects.requireNonNull(match, "match");
+ protocol = Objects.requireNonNullElse(protocol, Optional.empty());
+ auth = Objects.requireNonNullElse(auth, Optional.empty());
+ securityFilter = Objects.requireNonNullElse(securityFilter, Optional.empty());
+ forward = Objects.requireNonNullElse(forward, Optional.empty());
+ upstream = Objects.requireNonNullElse(upstream, Optional.empty());
+ rateLimit = Objects.requireNonNullElse(rateLimit, Optional.empty());
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteTable.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteTable.java
new file mode 100644
index 0000000..20abc6e
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteTable.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * The immutable, longest-prefix-ordered table of fully materialized routes the
+ * gateway serves.
+ *
+ * The table is assembled once at boot from the enabled endpoints (disabled
+ * endpoints contribute no rows) by the route-table builder. Its {@code routes}
+ * are ordered most-specific-first — by descending {@code path_prefix} length — so
+ * the first prefix match found by {@link #lookup(String)} is the most specific
+ * route for a request path. The hot path consumes the already materialized
+ * {@link ResolvedRoute} values and never re-derives inheritance.
+ *
+ * @param routes the resolved routes, ordered longest {@code path_prefix} first,
+ * empty when no enabled endpoint declares a route
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public record RouteTable(List routes) {
+
+ /**
+ * Canonical constructor defensively copying {@code routes} into an unmodifiable
+ * list and normalizing an absent list to empty.
+ */
+ public RouteTable {
+ routes = routes == null ? List.of() : List.copyOf(routes);
+ }
+
+ /**
+ * Finds the most specific route whose {@code path_prefix} is a prefix of the
+ * given request path.
+ *
+ * Because the routes are ordered longest-prefix-first, the first prefix match
+ * is the most specific one. This is a path-prefix lookup only; the full matcher
+ * set (host, methods, headers) is applied by the request pipeline.
+ *
+ * @param path the request path to resolve
+ * @return the most specific prefix-matching route, or empty when none matches
+ */
+ public Optional lookup(String path) {
+ Objects.requireNonNull(path, "path");
+ return routes.stream().filter(route -> matchesPrefix(path, route.pathPrefix())).findFirst();
+ }
+
+ /**
+ * Tests whether {@code path} is covered by {@code prefix} on segment boundaries.
+ *
+ * A path matches a prefix when it equals the prefix exactly or continues it at a
+ * segment boundary, so {@code /proxy} matches {@code /proxy} and {@code /proxy/x}
+ * but not {@code /proxy-helper}. A prefix that already ends in {@code /} is treated
+ * as a segment boundary directly.
+ *
+ * @param path the request path to test
+ * @param prefix the route {@code path_prefix} to test against
+ * @return {@code true} when the path is at or below the prefix on a segment boundary
+ */
+ private static boolean matchesPrefix(String path, String prefix) {
+ if (path.equals(prefix)) {
+ return true;
+ }
+ return prefix.endsWith("/") ? path.startsWith(prefix) : path.startsWith(prefix + "/");
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityDefaultsConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityDefaultsConfig.java
new file mode 100644
index 0000000..16248f0
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityDefaultsConfig.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * The global {@code security_defaults} block of {@code gateway.yaml}.
+ *
+ * {@code profile} selects the inbound-filter baseline preset
+ * ({@code default} / {@code strict} / {@code lenient}) applied to every route
+ * that does not declare its own {@code security_filter}. The value set is
+ * enforced by the configuration validator, not by the model.
+ *
+ * @param profile the baseline security-filter profile, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public record SecurityDefaultsConfig(Optional profile) {
+
+ /**
+ * Canonical constructor normalizing an absent {@code profile} to
+ * {@link Optional#empty()}.
+ */
+ public SecurityDefaultsConfig {
+ profile = Objects.requireNonNullElse(profile, Optional.empty());
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityFilterConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityFilterConfig.java
new file mode 100644
index 0000000..df13ff1
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityFilterConfig.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import lombok.Builder;
+
+/**
+ * The per-route {@code security_filter} block: allowlists and limits (explicitly
+ * not a WAF). The optional baseline {@code profile} is overridden by the limits
+ * below.
+ *
+ * @param profile the baseline preset, empty when the global default
+ * applies
+ * @param allowedPaths the path allowlist entries, empty when none
+ * @param maxHeaderCount the maximum header count, empty when omitted
+ * @param maxHeaderValueLength the maximum single-header-value length in
+ * characters, empty when omitted
+ * @param maxQueryParams the maximum query-parameter count, empty when omitted
+ * @param maxParamValueLength the maximum single-parameter-value length in
+ * characters, empty when omitted
+ * @param maxBodyBytes the maximum request-body size in bytes, empty when
+ * omitted
+ * @param allowedHeaderNames the header-name allowlist, empty when none
+ * @param blockedHeaderNames the header-name blocklist, empty when none
+ * @param allowedContentTypes the request {@code Content-Type} allowlist, empty
+ * when none
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record SecurityFilterConfig(Optional profile, List allowedPaths, Optional maxHeaderCount,
+ Optional maxHeaderValueLength, Optional maxQueryParams, Optional maxParamValueLength,
+ Optional maxBodyBytes, List allowedHeaderNames, List blockedHeaderNames,
+ List allowedContentTypes) {
+
+ /**
+ * Canonical constructor defensively copying collections and normalizing absent
+ * components.
+ */
+ public SecurityFilterConfig {
+ profile = Objects.requireNonNullElse(profile, Optional.empty());
+ allowedPaths = allowedPaths == null ? List.of() : List.copyOf(allowedPaths);
+ maxHeaderCount = Objects.requireNonNullElse(maxHeaderCount, Optional.empty());
+ maxHeaderValueLength = Objects.requireNonNullElse(maxHeaderValueLength, Optional.empty());
+ maxQueryParams = Objects.requireNonNullElse(maxQueryParams, Optional.empty());
+ maxParamValueLength = Objects.requireNonNullElse(maxParamValueLength, Optional.empty());
+ maxBodyBytes = Objects.requireNonNullElse(maxBodyBytes, Optional.empty());
+ allowedHeaderNames = allowedHeaderNames == null ? List.of() : List.copyOf(allowedHeaderNames);
+ blockedHeaderNames = blockedHeaderNames == null ? List.of() : List.copyOf(blockedHeaderNames);
+ allowedContentTypes = allowedContentTypes == null ? List.of() : List.copyOf(allowedContentTypes);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityHeadersConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityHeadersConfig.java
new file mode 100644
index 0000000..61b8865
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityHeadersConfig.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+
+import lombok.Builder;
+
+/**
+ * The global {@code security_headers} block of {@code gateway.yaml}: response
+ * header middleware applied to every response.
+ *
+ * @param hsts the HSTS settings, empty when omitted
+ * @param contentTypeNosniff whether {@code X-Content-Type-Options: nosniff} is
+ * emitted, empty when omitted
+ * @param frameDeny whether {@code X-Frame-Options: DENY} is emitted,
+ * empty when omitted
+ * @param cors the CORS settings, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record SecurityHeadersConfig(Optional hsts, Optional contentTypeNosniff,
+ Optional frameDeny, Optional cors) {
+
+ /**
+ * Canonical constructor normalizing absent components to {@link Optional#empty()}.
+ */
+ public SecurityHeadersConfig {
+ hsts = Objects.requireNonNullElse(hsts, Optional.empty());
+ contentTypeNosniff = Objects.requireNonNullElse(contentTypeNosniff, Optional.empty());
+ frameDeny = Objects.requireNonNullElse(frameDeny, Optional.empty());
+ cors = Objects.requireNonNullElse(cors, Optional.empty());
+ }
+
+ /**
+ * {@code Strict-Transport-Security} settings.
+ *
+ * @param maxAge the {@code max-age} in seconds, empty when omitted
+ * @param includeSubdomains whether {@code includeSubDomains} is set, empty when
+ * omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @Builder
+ public record Hsts(Optional maxAge, Optional includeSubdomains) {
+
+ /**
+ * Canonical constructor normalizing absent components to {@link Optional#empty()}.
+ */
+ public Hsts {
+ maxAge = Objects.requireNonNullElse(maxAge, Optional.empty());
+ includeSubdomains = Objects.requireNonNullElse(includeSubdomains, Optional.empty());
+ }
+ }
+
+ /**
+ * CORS preflight / response handling. Disabled by default.
+ *
+ * @param enabled whether CORS handling is enabled, empty when omitted
+ * @param allowedOrigins the exact allowed origins, empty when none
+ * @param allowedMethods the allowed methods, empty when none
+ * @param allowedHeaders the allowed request headers, empty when none
+ * @param allowCredentials whether credentials are allowed, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @Builder
+ public record Cors(Optional enabled, List allowedOrigins, List allowedMethods,
+ List allowedHeaders, Optional allowCredentials) {
+
+ /**
+ * Canonical constructor defensively copying collections and normalizing
+ * absent components.
+ */
+ public Cors {
+ enabled = Objects.requireNonNullElse(enabled, Optional.empty());
+ allowedOrigins = allowedOrigins == null ? List.of() : List.copyOf(allowedOrigins);
+ allowedMethods = allowedMethods == null ? List.of() : List.copyOf(allowedMethods);
+ allowedHeaders = allowedHeaders == null ? List.of() : List.copyOf(allowedHeaders);
+ allowCredentials = Objects.requireNonNullElse(allowCredentials, Optional.empty());
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TlsConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TlsConfig.java
new file mode 100644
index 0000000..3cf27cd
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TlsConfig.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+import lombok.Builder;
+
+/**
+ * The global {@code tls} block of {@code gateway.yaml}.
+ *
+ * @param minVersion the minimum negotiated TLS version, empty when omitted
+ * @param alpn the advertised ALPN protocols, empty when omitted
+ * @param passthroughSni a map of SNI hostname to topology alias relayed at L4
+ * without decryption, empty when omitted
+ * @param mtls the mutual-TLS settings, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record TlsConfig(Optional minVersion, List alpn, Map passthroughSni,
+ Optional mtls) {
+
+ /**
+ * Canonical constructor defensively copying collections and normalizing absent
+ * components.
+ */
+ public TlsConfig {
+ minVersion = Objects.requireNonNullElse(minVersion, Optional.empty());
+ alpn = alpn == null ? List.of() : List.copyOf(alpn);
+ passthroughSni = passthroughSni == null ? Map.of() : Map.copyOf(passthroughSni);
+ mtls = Objects.requireNonNullElse(mtls, Optional.empty());
+ }
+
+ /**
+ * Mutual-TLS settings for terminated connections.
+ *
+ * @param enabled whether client-certificate verification is required
+ * @param clientCa the client-CA path, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @Builder
+ public record Mtls(boolean enabled, Optional clientCa) {
+
+ /**
+ * Canonical constructor normalizing an absent {@code clientCa} to
+ * {@link Optional#empty()}.
+ */
+ public Mtls {
+ clientCa = Objects.requireNonNullElse(clientCa, Optional.empty());
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TokenValidationConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TokenValidationConfig.java
new file mode 100644
index 0000000..8ce8c88
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TokenValidationConfig.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.List;
+
+/**
+ * The global {@code token_validation} block of {@code gateway.yaml}.
+ *
+ * It configures offline bearer validation for {@code require: bearer} routes;
+ * it is required when any route's effective auth is {@code bearer}.
+ *
+ * @param issuers the accepted identity providers, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public record TokenValidationConfig(List issuers) {
+
+ /**
+ * Canonical constructor defensively copying {@code issuers} into an
+ * unmodifiable list and normalizing an absent list to empty.
+ */
+ public TokenValidationConfig {
+ issuers = issuers == null ? List.of() : List.copyOf(issuers);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamConfig.java
new file mode 100644
index 0000000..64f74ae
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamConfig.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import lombok.Builder;
+
+/**
+ * The per-route {@code upstream} block.
+ *
+ * The per-route {@code retry.enabled} / {@code not_modified.enabled} toggles are
+ * optional overrides of the resolved endpoint/global {@code upstream_defaults};
+ * an absent toggle inherits the resolved value. The effective values are
+ * materialized once by the route-table builder.
+ *
+ * @param path the upstream path that replaces the matched prefix,
+ * empty when omitted
+ * @param connectTimeoutMs the connect timeout in milliseconds, empty when omitted
+ * @param readTimeoutMs the read timeout in milliseconds, empty when omitted
+ * @param retry the retry settings, empty when omitted
+ * @param notModified the HTTP-304 not-modified settings, empty when omitted
+ * @param circuitBreaker the circuit-breaker settings, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@Builder
+public record UpstreamConfig(Optional path, Optional connectTimeoutMs, Optional readTimeoutMs,
+ Optional retry, Optional notModified, Optional circuitBreaker) {
+
+ /**
+ * Canonical constructor normalizing absent components to {@link Optional#empty()}.
+ */
+ public UpstreamConfig {
+ path = Objects.requireNonNullElse(path, Optional.empty());
+ connectTimeoutMs = Objects.requireNonNullElse(connectTimeoutMs, Optional.empty());
+ readTimeoutMs = Objects.requireNonNullElse(readTimeoutMs, Optional.empty());
+ retry = Objects.requireNonNullElse(retry, Optional.empty());
+ notModified = Objects.requireNonNullElse(notModified, Optional.empty());
+ circuitBreaker = Objects.requireNonNullElse(circuitBreaker, Optional.empty());
+ }
+
+ /**
+ * Per-route retry settings.
+ *
+ * @param enabled whether retry is enabled for this route; empty inherits
+ * the resolved endpoint/global default
+ * @param maxAttempts the maximum retry attempts, empty when omitted
+ * @param idempotentOnly whether only idempotent methods are retried, empty when
+ * omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @Builder
+ public record Retry(Optional enabled, Optional maxAttempts, Optional idempotentOnly) {
+
+ /**
+ * Canonical constructor normalizing absent components to {@link Optional#empty()}.
+ */
+ public Retry {
+ enabled = Objects.requireNonNullElse(enabled, Optional.empty());
+ maxAttempts = Objects.requireNonNullElse(maxAttempts, Optional.empty());
+ idempotentOnly = Objects.requireNonNullElse(idempotentOnly, Optional.empty());
+ }
+ }
+
+ /**
+ * Per-route HTTP-304 not-modified settings.
+ *
+ * @param enabled whether not-modified handling is enabled for this route; empty
+ * inherits the resolved endpoint/global default
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public record NotModified(Optional enabled) {
+
+ /**
+ * Canonical constructor normalizing an absent {@code enabled} to
+ * {@link Optional#empty()}.
+ */
+ public NotModified {
+ enabled = Objects.requireNonNullElse(enabled, Optional.empty());
+ }
+ }
+
+ /**
+ * Per-route circuit-breaker settings.
+ *
+ * @param failures the failure threshold before opening, empty when omitted
+ * @param resetMs the reset window in milliseconds, empty when omitted
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ @Builder
+ public record CircuitBreaker(Optional failures, Optional resetMs) {
+
+ /**
+ * Canonical constructor normalizing absent components to {@link Optional#empty()}.
+ */
+ public CircuitBreaker {
+ failures = Objects.requireNonNullElse(failures, Optional.empty());
+ resetMs = Objects.requireNonNullElse(resetMs, Optional.empty());
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamDefaultsConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamDefaultsConfig.java
new file mode 100644
index 0000000..44711dc
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamDefaultsConfig.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+/**
+ * The {@code upstream_defaults} block declarable at two scopes — global
+ * ({@code gateway.yaml}) and per endpoint ({@code endpoints/*.yaml}).
+ *
+ * It carries the retry and HTTP-304 not-modified toggles, both defaulting to
+ * {@code true}. When an endpoint file declares its own {@code upstream_defaults}
+ * block, it replaces the global block wholesale for that endpoint's
+ * routes (no field merging); the effective per-route values are materialized by
+ * the route-table builder.
+ *
+ * @param retryEnabled whether upstream retry is enabled (default {@code true})
+ * @param notModifiedEnabled whether HTTP-304 not-modified handling is enabled
+ * (default {@code true})
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public record UpstreamDefaultsConfig(boolean retryEnabled, boolean notModifiedEnabled) {
+
+ /**
+ * The default {@code upstream_defaults} — both toggles {@code true} — applied
+ * when no block is declared at a given scope.
+ *
+ * @return a defaults instance with retry and not-modified both enabled
+ */
+ public static UpstreamDefaultsConfig defaults() {
+ return new UpstreamDefaultsConfig(true, true);
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/package-info.java
new file mode 100644
index 0000000..85359a6
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/package-info.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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.
+ */
+/**
+ * Immutable configuration model for the API Sheriff gateway.
+ *
+ * The records in this package are the binding target for the YAML configuration
+ * loader: {@code gateway.yaml} binds to {@link de.cuioss.sheriff.api.config.model.GatewayConfig}
+ * and each {@code endpoints/*.yaml} file binds to
+ * {@link de.cuioss.sheriff.api.config.model.EndpointConfig}. Configuration is
+ * static, file-based, and immutable: it is read once at startup,
+ * validated in full, frozen into these value objects, and never mutated at
+ * runtime.
+ *
+ * Immutability and thread-safety. Every type here is an
+ * immutable Java record. Collection components are defensively copied into
+ * unmodifiable collections by the canonical constructors, and absent optional
+ * scalars are represented as {@link java.util.Optional} (never {@code null}) and
+ * absent collections as empty collections (never {@code null}). Instances are
+ * therefore safe to publish and share across threads without external
+ * synchronization.
+ *
+ * Framework-agnostic seam (ADR-0005). This package carries no
+ * CDI, Quarkus, Vert.x, MicroProfile, or Micrometer imports. Collaborators are
+ * supplied by constructor parameters; the framework-bound wiring lives in the
+ * edge package.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.api.config.model;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/package-info.java
new file mode 100644
index 0000000..fdf592a
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/package-info.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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.
+ */
+/**
+ * Framework-agnostic configuration assembly for the API Sheriff gateway.
+ *
+ * This package hosts the collaborators that turn the bound, validated
+ * configuration model into the runtime-ready {@link de.cuioss.sheriff.api.config.model.RouteTable}:
+ * the {@link de.cuioss.sheriff.api.config.RouteTableBuilder} that materializes the
+ * effective per-route settings, and the {@link de.cuioss.sheriff.api.config.ConfigLogMessages}
+ * structured-log catalogue. The framework-bound wiring — the CDI producer that
+ * drives the boot pipeline and fails startup on invalid configuration — lives in
+ * the {@code de.cuioss.sheriff.api.quarkus} edge package (ADR-0005); nothing here
+ * carries a CDI, Quarkus, or MicroProfile import.
+ *
+ * Null-safety. The package is {@link org.jspecify.annotations.NullMarked}:
+ * references are non-null by default, with any nullable component annotated
+ * explicitly.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.api.config;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolver.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolver.java
new file mode 100644
index 0000000..69a401e
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolver.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.topology;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.function.UnaryOperator;
+
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Resolves the effective enabled state of each endpoint (pipeline step 5), applying
+ * the {@code ENDPOINT__ENABLED} environment override on top of the file
+ * {@code enabled} field.
+ *
+ * Precedence: the environment variable wins over the file value, which itself
+ * defaults to {@code true}. The variable name derives from the endpoint id
+ * uppercased with every non-alphanumeric character mapped to an underscore
+ * ({@code order-api} → {@code ENDPOINT_ORDER_API_ENABLED}). Disabled endpoints are
+ * dropped before route-table assembly and are exempt from alias resolution and the
+ * disjointness / passthrough checks, though they remain schema-validated.
+ *
+ * Framework-agnostic (ADR-0005): the environment lookup is constructor-injected.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class EndpointEnablementResolver {
+
+ private final UnaryOperator<@Nullable String> environment;
+
+ /**
+ * Creates a resolver backed by the process environment
+ * ({@link System#getenv(String)}).
+ */
+ public EndpointEnablementResolver() {
+ this(System::getenv);
+ }
+
+ /**
+ * Creates a resolver backed by the supplied environment lookup.
+ *
+ * @param environment maps an environment-variable name to its value, or
+ * {@code null} when undefined
+ */
+ public EndpointEnablementResolver(UnaryOperator<@Nullable String> environment) {
+ this.environment = Objects.requireNonNull(environment, "environment");
+ }
+
+ /**
+ * Reports whether the endpoint is effectively enabled.
+ *
+ * @param endpoint the endpoint to evaluate
+ * @return the environment override when set, otherwise the file {@code enabled}
+ * value
+ */
+ public boolean isEnabled(EndpointConfig endpoint) {
+ String override = environment.apply(environmentVariableName(endpoint.id()));
+ if (override != null) {
+ return Boolean.parseBoolean(override.trim());
+ }
+ return endpoint.enabled();
+ }
+
+ /**
+ * Filters the endpoints down to those effectively enabled, preserving order.
+ *
+ * @param endpoints the schema-validated endpoints
+ * @return the enabled endpoints
+ */
+ public List enabledEndpoints(List endpoints) {
+ return endpoints.stream().filter(this::isEnabled).toList();
+ }
+
+ /**
+ * Derives the enablement-override environment variable name for an endpoint id.
+ *
+ * @param id the endpoint id
+ * @return the {@code ENDPOINT__ENABLED} variable name
+ */
+ public static String environmentVariableName(String id) {
+ String normalized = id.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", "_");
+ return "ENDPOINT_" + normalized + "_ENABLED";
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java
new file mode 100644
index 0000000..2484b9a
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.topology;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.Serial;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Properties;
+import java.util.function.UnaryOperator;
+import java.util.regex.Pattern;
+
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.ResolvedTopology;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Resolves topology aliases to decomposed upstreams for enabled endpoints (pipeline
+ * step 6).
+ *
+ * Each alias (pattern {@code [A-Z][A-Z0-9_]*}) is resolved with the precedence
+ * {@code TOPOLOGY_} environment variable over the {@code topology.properties}
+ * file value, validated as a well-formed absolute URL, and decomposed once into a
+ * {@link ResolvedUpstream} (ADR-0004). Only aliases referenced by enabled
+ * endpoints are resolved; an alias referenced by an enabled endpoint that resolves
+ * to neither an environment nor a file value is a boot failure, while a disabled
+ * endpoint's alias need not resolve.
+ *
+ * Framework-agnostic (ADR-0005): the environment lookup is constructor-injected.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class TopologyResolver {
+
+ private static final Pattern ALIAS = Pattern.compile("[A-Z][A-Z0-9_]*");
+ private static final int HTTP_PORT = 80;
+ private static final int HTTPS_PORT = 443;
+
+ private final UnaryOperator<@Nullable String> environment;
+
+ /**
+ * Creates a resolver backed by the process environment
+ * ({@link System#getenv(String)}).
+ */
+ public TopologyResolver() {
+ this(System::getenv);
+ }
+
+ /**
+ * Creates a resolver backed by the supplied environment lookup.
+ *
+ * @param environment maps an environment-variable name to its value, or
+ * {@code null} when undefined
+ */
+ public TopologyResolver(UnaryOperator<@Nullable String> environment) {
+ this.environment = Objects.requireNonNull(environment, "environment");
+ }
+
+ /**
+ * Resolves and decomposes the topology aliases referenced by the enabled
+ * endpoints.
+ *
+ * @param topologyFile the {@code topology.properties} file (may be absent)
+ * @param enabledEndpoints the endpoints already filtered to those enabled
+ * @return the immutable resolved topology
+ * @throws TopologyResolutionException when a referenced alias is unresolved or
+ * resolves to a malformed URL
+ */
+ public ResolvedTopology resolve(Path topologyFile, List enabledEndpoints) {
+ Map fileAliases = readProperties(topologyFile);
+ Map resolved = new LinkedHashMap<>();
+ for (EndpointConfig endpoint : enabledEndpoints) {
+ String alias = endpoint.baseUrl();
+ if (resolved.containsKey(alias)) {
+ continue;
+ }
+ String value = resolveValue(alias, fileAliases);
+ if (value == null) {
+ throw new TopologyResolutionException(
+ "Unresolved topology alias '%s' referenced by enabled endpoint '%s'"
+ .formatted(alias, endpoint.id()));
+ }
+ resolved.put(alias, decompose(alias, value.trim()));
+ }
+ return new ResolvedTopology(resolved);
+ }
+
+ private @Nullable String resolveValue(String alias, Map fileAliases) {
+ String override = environment.apply("TOPOLOGY_" + alias);
+ if (override != null) {
+ return override;
+ }
+ return fileAliases.get(alias);
+ }
+
+ private static Map readProperties(Path topologyFile) {
+ if (!Files.isRegularFile(topologyFile)) {
+ return Map.of();
+ }
+ Properties properties = new Properties();
+ try (Reader reader = Files.newBufferedReader(topologyFile)) {
+ properties.load(reader);
+ } catch (IOException e) {
+ throw new TopologyResolutionException("Cannot read topology file: " + topologyFile, e);
+ }
+ Map aliases = new LinkedHashMap<>();
+ for (String name : properties.stringPropertyNames()) {
+ if (ALIAS.matcher(name).matches()) {
+ aliases.put(name, properties.getProperty(name));
+ }
+ }
+ return aliases;
+ }
+
+ private static ResolvedUpstream decompose(String alias, String url) {
+ URI uri;
+ try {
+ uri = new URI(url);
+ } catch (URISyntaxException e) {
+ throw new TopologyResolutionException("Malformed topology URL for alias '%s': %s".formatted(alias, url), e);
+ }
+ if (uri.getScheme() == null || uri.getHost() == null) {
+ throw new TopologyResolutionException(
+ "Topology URL for alias '%s' must be absolute with scheme and host: %s".formatted(alias, url));
+ }
+ String scheme = uri.getScheme().toLowerCase(Locale.ROOT);
+ int port = uri.getPort() != -1 ? uri.getPort() : defaultPort(scheme);
+ String basePath = uri.getPath() == null ? "" : uri.getPath();
+ return new ResolvedUpstream(scheme, uri.getHost(), port, basePath);
+ }
+
+ private static int defaultPort(String scheme) {
+ return switch (scheme) {
+ case "http" -> HTTP_PORT;
+ case "https" -> HTTPS_PORT;
+ default -> -1;
+ };
+ }
+
+ /**
+ * Signals that a topology alias could not be resolved or decomposed.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+ public static final class TopologyResolutionException extends RuntimeException {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ TopologyResolutionException(String message) {
+ super(message);
+ }
+
+ TopologyResolutionException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/package-info.java
new file mode 100644
index 0000000..9514d76
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/package-info.java
@@ -0,0 +1,37 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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.
+ */
+/**
+ * Endpoint-enablement and topology-alias resolution for the boot pipeline.
+ *
+ * {@link de.cuioss.sheriff.api.config.topology.EndpointEnablementResolver} computes
+ * each endpoint's effective enabled state (environment override over the file
+ * default) and drops disabled endpoints, and
+ * {@link de.cuioss.sheriff.api.config.topology.TopologyResolver} resolves the
+ * topology aliases referenced by the surviving enabled endpoints — applying the
+ * {@code TOPOLOGY_} environment override, validating each URL, and
+ * decomposing it once into a
+ * {@link de.cuioss.sheriff.api.config.model.ResolvedUpstream} (ADR-0004).
+ *
+ * Framework-agnostic seam (ADR-0005). Both resolvers take their
+ * environment lookup as a constructor parameter and carry no framework imports.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.api.config.topology;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java
new file mode 100644
index 0000000..234503e
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java
@@ -0,0 +1,264 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.validation;
+
+import java.util.ArrayList;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+import de.cuioss.sheriff.api.config.load.ConfigError;
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.OidcConfig;
+import de.cuioss.sheriff.api.config.model.ResolvedTopology;
+import de.cuioss.sheriff.api.config.model.RouteConfig;
+import de.cuioss.sheriff.api.config.validation.rule.ValidationRule;
+
+/**
+ * Runs the cross-cutting configuration rules (pipeline step 7) that cannot be
+ * expressed structurally in the D2 JSON Schemas, aggregating every violation in a
+ * single pass.
+ *
+ * Each rule is a {@link ValidationRule}; {@link #validate} executes them all and
+ * returns the combined, file- and path-annotated {@link ConfigError} list — never
+ * stopping at the first problem. Disabled endpoints have already been dropped by the
+ * enablement resolver, so the rules that only concern live routes (alias
+ * resolvability, effective-method membership) see enabled endpoints only.
+ * Structural verb rules (a config naming {@code TRACE}/{@code CONNECT}) are enforced
+ * upstream by the schema and the {@link HttpMethod} enum, which cannot represent
+ * those verbs, so no post-binding rule is needed for them.
+ *
+ * Framework-agnostic (ADR-0005): the rule set is supplied at construction and the
+ * validator carries no framework imports.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+public final class ConfigValidator {
+
+ private static final int SUPPORTED_VERSION = 1;
+ private static final String GATEWAY_FILE = "gateway.yaml";
+ private static final String TRUST_ALL_IPV4 = "0.0.0.0/0";
+ private static final String TRUST_ALL_IPV6 = "::/0";
+ private static final String WILDCARD_ORIGIN = "*";
+ private static final int MILLIS_PER_SECOND = 1000;
+
+ private static final List DEFAULT_RULES = List.of(
+ (gateway, endpoints, topology, errors) -> validateVersion(gateway, errors),
+ (gateway, endpoints, topology, errors) -> validateEndpointIdUniqueness(endpoints, errors),
+ (gateway, endpoints, topology, errors) -> validateRouteIdUniqueness(endpoints, errors),
+ (gateway, endpoints, topology, errors) -> validateBaseUrlResolvable(endpoints, topology, errors),
+ (gateway, endpoints, topology, errors) -> validateEffectiveAuth(gateway, endpoints, errors),
+ (gateway, endpoints, topology, errors) -> validateMethodMembership(gateway, endpoints, errors),
+ (gateway, endpoints, topology, errors) -> validateWholeSecondTimeouts(endpoints, errors),
+ (gateway, endpoints, topology, errors) -> validateForwardedTrust(gateway, errors),
+ (gateway, endpoints, topology, errors) -> validateCors(gateway, errors),
+ (gateway, endpoints, topology, errors) -> validateSessionMode(gateway, errors));
+
+ private final List rules;
+
+ /**
+ * Creates a validator with the built-in rule set.
+ */
+ public ConfigValidator() {
+ this(DEFAULT_RULES);
+ }
+
+ /**
+ * Creates a validator with a custom rule set.
+ *
+ * @param rules the rules to run, in order
+ */
+ public ConfigValidator(List rules) {
+ this.rules = List.copyOf(Objects.requireNonNull(rules, "rules"));
+ }
+
+ /**
+ * Runs every rule and returns all violations discovered in one pass.
+ *
+ * @param gateway the bound gateway document
+ * @param enabledEndpoints the endpoints filtered to those enabled
+ * @param topology the resolved topology
+ * @return the aggregated, unmodifiable list of violations, empty when valid
+ */
+ public List validate(GatewayConfig gateway, List enabledEndpoints,
+ ResolvedTopology topology) {
+ List errors = new ArrayList<>();
+ for (ValidationRule rule : rules) {
+ rule.validate(gateway, enabledEndpoints, topology, errors);
+ }
+ return List.copyOf(errors);
+ }
+
+ private static void validateVersion(GatewayConfig gateway, List errors) {
+ if (gateway.version() != SUPPORTED_VERSION) {
+ errors.add(new ConfigError(GATEWAY_FILE, "/version",
+ "unsupported config version %d (supported: %d)".formatted(gateway.version(), SUPPORTED_VERSION)));
+ }
+ }
+
+ private static void validateEndpointIdUniqueness(List endpoints, List errors) {
+ Set seen = new HashSet<>();
+ for (EndpointConfig endpoint : endpoints) {
+ if (!seen.add(endpoint.id())) {
+ errors.add(new ConfigError(endpointFile(endpoint), "/endpoint/id",
+ "duplicate endpoint id: " + endpoint.id()));
+ }
+ }
+ }
+
+ private static void validateRouteIdUniqueness(List endpoints, List errors) {
+ Set seen = new HashSet<>();
+ for (EndpointConfig endpoint : endpoints) {
+ for (RouteConfig route : endpoint.routes()) {
+ if (!seen.add(route.id())) {
+ errors.add(new ConfigError(endpointFile(endpoint), "/endpoint/routes",
+ "duplicate route id: " + route.id()));
+ }
+ }
+ }
+ }
+
+ private static void validateBaseUrlResolvable(List endpoints, ResolvedTopology topology,
+ List errors) {
+ for (EndpointConfig endpoint : endpoints) {
+ if (topology.lookup(endpoint.baseUrl()).isEmpty()) {
+ errors.add(new ConfigError(endpointFile(endpoint), "/endpoint/base_url",
+ "unresolved topology alias: " + endpoint.baseUrl()));
+ }
+ }
+ }
+
+ private static void validateEffectiveAuth(GatewayConfig gateway, List endpoints,
+ List errors) {
+ Set requires = new HashSet<>();
+ for (EndpointConfig endpoint : endpoints) {
+ for (RouteConfig route : endpoint.routes()) {
+ requires.add(effectiveRequire(endpoint, route));
+ }
+ }
+ if (requires.contains("bearer")
+ && gateway.tokenValidation().map(tv -> tv.issuers().isEmpty()).orElse(true)) {
+ errors.add(new ConfigError(GATEWAY_FILE, "/token_validation",
+ "effective auth 'bearer' requires token_validation with at least one issuer"));
+ }
+ if (requires.contains("session") && gateway.oidc().isEmpty()) {
+ errors.add(new ConfigError(GATEWAY_FILE, "/oidc", "effective auth 'session' requires an oidc block"));
+ }
+ }
+
+ private static void validateMethodMembership(GatewayConfig gateway, List endpoints,
+ List errors) {
+ for (EndpointConfig endpoint : endpoints) {
+ Set allowed = effectiveAllowedMethods(gateway, endpoint);
+ for (RouteConfig route : endpoint.routes()) {
+ for (HttpMethod method : route.match().methods()) {
+ if (!allowed.contains(method)) {
+ errors.add(new ConfigError(endpointFile(endpoint), "/endpoint/routes",
+ "route '%s' matches method %s outside the effective allowed_methods"
+ .formatted(route.id(), method)));
+ }
+ }
+ }
+ }
+ }
+
+ private static void validateWholeSecondTimeouts(List endpoints, List errors) {
+ for (EndpointConfig endpoint : endpoints) {
+ for (RouteConfig route : endpoint.routes()) {
+ route.upstream().ifPresent(upstream -> {
+ upstream.connectTimeoutMs().ifPresent(value ->
+ requireWholeSecond(endpoint, route, "connect_timeout_ms", value, errors));
+ upstream.readTimeoutMs().ifPresent(value ->
+ requireWholeSecond(endpoint, route, "read_timeout_ms", value, errors));
+ });
+ }
+ }
+ }
+
+ private static void requireWholeSecond(EndpointConfig endpoint, RouteConfig route, String field, int value,
+ List errors) {
+ if (value <= 0) {
+ errors.add(new ConfigError(endpointFile(endpoint),
+ "/endpoint/routes/%s/upstream/%s".formatted(route.id(), field),
+ "%s must be a positive whole-second multiple of %d ms, was %d"
+ .formatted(field, MILLIS_PER_SECOND, value)));
+ return;
+ }
+ if (value % MILLIS_PER_SECOND != 0) {
+ errors.add(new ConfigError(endpointFile(endpoint),
+ "/endpoint/routes/%s/upstream/%s".formatted(route.id(), field),
+ "%s must be a whole-second multiple of %d ms, was %d".formatted(field, MILLIS_PER_SECOND, value)));
+ }
+ }
+
+ private static void validateForwardedTrust(GatewayConfig gateway, List errors) {
+ gateway.forwarded().ifPresent(forwarded -> {
+ for (String cidr : forwarded.trustedProxies()) {
+ if (TRUST_ALL_IPV4.equals(cidr) || TRUST_ALL_IPV6.equals(cidr)) {
+ errors.add(new ConfigError(GATEWAY_FILE, "/forwarded/trusted_proxies",
+ "trust-all CIDR is not permitted: " + cidr));
+ }
+ }
+ });
+ }
+
+ private static void validateCors(GatewayConfig gateway, List errors) {
+ gateway.securityHeaders().flatMap(headers -> headers.cors()).ifPresent(cors -> {
+ if (cors.allowCredentials().orElse(false) && cors.allowedOrigins().contains(WILDCARD_ORIGIN)) {
+ errors.add(new ConfigError(GATEWAY_FILE, "/security_headers/cors",
+ "wildcard origin '*' is not permitted together with allow_credentials"));
+ }
+ });
+ }
+
+ private static void validateSessionMode(GatewayConfig gateway, List errors) {
+ gateway.oidc().flatMap(OidcConfig::session).ifPresent(session ->
+ session.mode().ifPresent(mode -> {
+ if ("cookie".equals(mode) && session.encryptionKey().isEmpty()) {
+ errors.add(new ConfigError(GATEWAY_FILE, "/oidc/session/encryption_key",
+ "cookie session mode requires an encryption_key"));
+ }
+ if ("server".equals(mode) && session.store().isEmpty()) {
+ errors.add(new ConfigError(GATEWAY_FILE, "/oidc/session/store",
+ "server session mode requires a store"));
+ }
+ }));
+ }
+
+ private static String effectiveRequire(EndpointConfig endpoint, RouteConfig route) {
+ return route.auth().map(AuthConfig::require).orElse(endpoint.auth().require());
+ }
+
+ private static Set effectiveAllowedMethods(GatewayConfig gateway, EndpointConfig endpoint) {
+ if (!endpoint.allowedMethods().isEmpty()) {
+ return EnumSet.copyOf(endpoint.allowedMethods());
+ }
+ if (!gateway.allowedMethods().isEmpty()) {
+ return EnumSet.copyOf(gateway.allowedMethods());
+ }
+ return EnumSet.allOf(HttpMethod.class);
+ }
+
+ private static String endpointFile(EndpointConfig endpoint) {
+ return "endpoints/" + endpoint.id() + ".yaml";
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/package-info.java
new file mode 100644
index 0000000..95a38d4
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/package-info.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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.
+ */
+/**
+ * Cross-cutting configuration validation for the boot pipeline.
+ *
+ * {@link de.cuioss.sheriff.api.config.validation.ConfigValidator} runs the
+ * {@link de.cuioss.sheriff.api.config.validation.rule.ValidationRule} set that
+ * enforces the semantic rules the structural JSON Schemas cannot express — endpoint
+ * and route id uniqueness, alias resolvability for enabled endpoints, effective-auth
+ * dependencies, effective allowed-method membership, whole-second timeouts,
+ * forwarded trust-all rejection, CORS wildcard-with-credentials rejection, and
+ * session-mode conditionals — collecting every violation in a single pass.
+ *
+ * Framework-agnostic seam (ADR-0005). The validator and its rules
+ * carry no framework imports; the rule set is supplied at construction.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.api.config.validation;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java
new file mode 100644
index 0000000..f170baa
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.validation.rule;
+
+import java.util.List;
+
+import de.cuioss.sheriff.api.config.load.ConfigError;
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.ResolvedTopology;
+
+/**
+ * A single cross-cutting configuration validation rule.
+ *
+ * A rule inspects the bound gateway document, the enabled endpoints, and the
+ * resolved topology, and appends a {@link ConfigError} for each violation
+ * it finds to the shared error list. A rule never throws for a validation failure
+ * and never stops at the first problem — the aggregating
+ * {@link de.cuioss.sheriff.api.config.validation.ConfigValidator} runs every rule so
+ * all violations surface in one pass.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@FunctionalInterface
+public interface ValidationRule {
+
+ /**
+ * Validates the configuration, appending an error per violation.
+ *
+ * @param gateway the bound gateway document
+ * @param enabledEndpoints the endpoints filtered to those enabled
+ * @param topology the resolved topology for the enabled endpoints
+ * @param errors the shared, mutable error accumulator to append to
+ */
+ void validate(GatewayConfig gateway, List enabledEndpoints, ResolvedTopology topology,
+ List errors);
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/package-info.java
new file mode 100644
index 0000000..aa4d6dd
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/package-info.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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.
+ */
+/**
+ * The {@link de.cuioss.sheriff.api.config.validation.rule.ValidationRule} contract:
+ * the functional interface a single cross-cutting configuration rule implements,
+ * appending a {@link de.cuioss.sheriff.api.config.load.ConfigError} per violation to
+ * the shared accumulator so the
+ * {@link de.cuioss.sheriff.api.config.validation.ConfigValidator} can report every
+ * violation in one pass.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@NullMarked
+package de.cuioss.sheriff.api.config.validation.rule;
+
+import org.jspecify.annotations.NullMarked;
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyConfiguration.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyConfiguration.java
deleted file mode 100644
index 5e8ce7d..0000000
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyConfiguration.java
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
- *
- * 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 de.cuioss.sheriff.api.gateway.proxy;
-
-import io.smallrye.config.ConfigMapping;
-import io.smallrye.config.WithDefault;
-
-/**
- * Interim proxy route configuration, bound to the {@code sheriff.proxy.*}
- * MicroProfile config namespace.
- *
- * This is deliberately minimal: a single catch-all route defined by a matching
- * path prefix and a single upstream base URL. It is replaced by the real
- * configuration subsystem in Plan 02 and the real request pipeline in Plan 03.
- *
- * @author API Sheriff Team
- * @since 1.0
- */
-@ConfigMapping(prefix = "sheriff.proxy")
-public interface ProxyConfiguration {
-
- /**
- * The path prefix that activates proxying. Requests whose path starts with
- * {@code /} are forwarded to {@link #upstreamUrl()}; the prefix
- * is stripped and the remaining path is appended to the upstream URL.
- * Everything else is left to the default {@code 404} (deny-by-default).
- *
- * @return the activating path prefix, never {@code null}
- */
- @WithDefault("/proxy")
- String pathPrefix();
-
- /**
- * The upstream base URL that matched requests are forwarded to. The request
- * path remainder (after the prefix) and query string are appended to it.
- *
- * @return the upstream base URL, never {@code null}
- */
- @WithDefault("http://localhost:8080")
- String upstreamUrl();
-}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java
index 8a83f3e..19c30a8 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRoute.java
@@ -21,9 +21,13 @@
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
+import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutorService;
+import de.cuioss.sheriff.api.config.model.ResolvedRoute;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.config.model.RouteTable;
import de.cuioss.sheriff.api.gateway.proxy.ProxyLogMessages.INFO;
import de.cuioss.sheriff.api.gateway.proxy.ProxyLogMessages.WARN;
import de.cuioss.tools.logging.CuiLogger;
@@ -39,19 +43,24 @@
import jakarta.inject.Inject;
/**
- * Interim minimal reverse proxy: a single catch-all Vert.x route on the data
- * plane that forwards matched requests to a configured upstream.
+ * Interim minimal reverse proxy: a Vert.x route per configured
+ * {@code path_prefix} that forwards matched requests to the upstream resolved for
+ * that route.
*
- * Behaviour (deliberately interim — Plan 03 keeps this edge and replaces the
+ * The routing table is the {@link RouteTable} assembled by the configuration
+ * subsystem (Deliverable 7); this edge sources each route's upstream from the
+ * table's {@link ResolvedUpstream} rather than from a single static configuration
+ * bean. Behaviour (deliberately interim — Plan 03 keeps this edge and replaces the
* internals with the real request pipeline):
*
- * - The route matches {@code /*}; the prefix is stripped and the
- * remaining path plus the query string are appended to the upstream URL.
+ * - One route is registered per {@code path_prefix} in the table; the prefix
+ * is stripped and the remaining path plus the query string are appended to
+ * the route's upstream base URL.
* - Method, path remainder, query and body pass through; hop-by-hop headers
* are stripped in both directions.
* - Forwarding uses a blocking JDK {@link HttpClient} {@code send()} executed
* on a virtual thread, so the Vert.x event loop is never blocked.
- * - Any path outside the prefix falls through to the default {@code 404}
+ *
- Any path with no matching route falls through to {@code 404}
* (deny-by-default); upstream failures surface as {@code 502}.
*
*
@@ -80,23 +89,22 @@ public class ProxyRoute {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "transfer-encoding", "upgrade", "content-length");
- private final ProxyConfiguration config;
+ private final RouteTable routeTable;
private final ExecutorService virtualThreadExecutor;
private final HttpClient httpClient;
- private final String upstreamBaseUrl;
/**
* Creates the proxy route.
*
- * @param config the interim proxy configuration
+ * @param routeTable the assembled route table sourcing each route's
+ * upstream
* @param virtualThreadExecutor the Quarkus-managed virtual-thread executor
* (thread names use {@code quarkus.virtual-threads.name-prefix})
*/
@Inject
- public ProxyRoute(ProxyConfiguration config, @VirtualThreads ExecutorService virtualThreadExecutor) {
- this.config = config;
+ public ProxyRoute(RouteTable routeTable, @VirtualThreads ExecutorService virtualThreadExecutor) {
+ this.routeTable = routeTable;
this.virtualThreadExecutor = virtualThreadExecutor;
- this.upstreamBaseUrl = stripTrailingSlash(config.upstreamUrl());
this.httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(10))
@@ -105,37 +113,50 @@ public ProxyRoute(ProxyConfiguration config, @VirtualThreads ExecutorService vir
}
/**
- * Registers the catch-all proxy route on the data-plane router at startup.
+ * Registers one catch-all proxy route per {@code path_prefix} in the route
+ * table on the data-plane router at startup.
*
* @param router the Vert.x web router, observed during Quarkus startup
*/
public void registerRoutes(@Observes Router router) {
- String routePath = stripTrailingSlash(config.pathPrefix()) + "/*";
- router.route(routePath)
- .handler(BodyHandler.create())
- .handler(ctx -> virtualThreadExecutor.execute(() -> forward(ctx)));
- LOGGER.info(INFO.ROUTE_REGISTERED, config.pathPrefix(), upstreamBaseUrl);
+ for (ResolvedRoute route : routeTable.routes()) {
+ String routePath = stripTrailingSlash(route.pathPrefix()) + "/*";
+ router.route(routePath)
+ .handler(BodyHandler.create())
+ .handler(ctx -> virtualThreadExecutor.execute(() -> forward(ctx)));
+ LOGGER.info(INFO.ROUTE_REGISTERED, route.pathPrefix(), upstreamBaseUrl(route.upstream()));
+ }
}
/**
- * Forwards a single request to the upstream. Runs on a virtual thread; the
- * response is written back on the Vert.x context.
+ * Forwards a single request to the upstream resolved for its matching route.
+ * Runs on a virtual thread; the response is written back on the Vert.x context.
*
* @param ctx the routing context of the matched request
*/
private void forward(RoutingContext ctx) {
- String prefix = stripTrailingSlash(config.pathPrefix());
+ String rawUri = ctx.request().uri();
+ int queryStart = rawUri.indexOf('?');
+ String rawPath = queryStart < 0 ? rawUri : rawUri.substring(0, queryStart);
+ String rawQuery = queryStart < 0 ? "" : rawUri.substring(queryStart);
+
+ Optional match = routeTable.lookup(rawPath);
+ if (match.isEmpty()) {
+ ctx.vertx().runOnContext(v -> {
+ if (!ctx.response().ended()) {
+ ctx.response().setStatusCode(404).end();
+ }
+ });
+ return;
+ }
+
+ String prefix = stripTrailingSlash(match.get().pathPrefix());
+ String upstreamBaseUrl = upstreamBaseUrl(match.get().upstream());
// Everything that can throw (substring, URI parsing, send) stays inside the
// try so any failure yields a 502 rather than an uncaught error that would
// leave the client hanging on the virtual thread.
String target = upstreamBaseUrl;
try {
- // Derive the remainder from the raw (still percent-encoded) request URI so
- // the original path/query encoding is preserved without double-encoding.
- String rawUri = ctx.request().uri();
- int queryStart = rawUri.indexOf('?');
- String rawPath = queryStart < 0 ? rawUri : rawUri.substring(0, queryStart);
- String rawQuery = queryStart < 0 ? "" : rawUri.substring(queryStart);
String remainder = rawPath.substring(prefix.length());
target = upstreamBaseUrl + remainder + rawQuery;
@@ -188,6 +209,11 @@ private void failBadGateway(RoutingContext ctx, String target, Exception cause)
});
}
+ private static String upstreamBaseUrl(ResolvedUpstream upstream) {
+ return stripTrailingSlash("%s://%s:%d%s".formatted(upstream.scheme(), upstream.host(), upstream.port(),
+ upstream.basePath()));
+ }
+
private static String stripTrailingSlash(String value) {
return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java
index 0f4e124..a9fb77b 100644
--- a/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/package-info.java
@@ -16,12 +16,13 @@
/**
* Interim minimal reverse-proxy edge for the API Sheriff gateway.
*
- * A single catch-all Vert.x route ({@link de.cuioss.sheriff.api.gateway.proxy.ProxyRoute})
- * forwards requests matching a configured path prefix
- * ({@link de.cuioss.sheriff.api.gateway.proxy.ProxyConfiguration}) to an upstream
- * URL, executing the blocking forward on a virtual thread. This is the outermost
- * shell only: it is kept as the edge and has its internals replaced by the real
- * request pipeline in Plan 03.
+ * One Vert.x route per {@code path_prefix}
+ * ({@link de.cuioss.sheriff.api.gateway.proxy.ProxyRoute}) forwards matched
+ * requests to the upstream resolved for that route by the configuration
+ * subsystem's {@link de.cuioss.sheriff.api.config.model.RouteTable}, executing the
+ * blocking forward on a virtual thread. This is the outermost shell only: it is
+ * kept as the edge and has its internals replaced by the real request pipeline in
+ * Plan 03.
*
* @since 1.0
*/
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java
new file mode 100644
index 0000000..c50f4ff
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.quarkus;
+
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.ForwardConfig;
+import de.cuioss.sheriff.api.config.model.ForwardedConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.IssuerConfig;
+import de.cuioss.sheriff.api.config.model.MatchConfig;
+import de.cuioss.sheriff.api.config.model.Metadata;
+import de.cuioss.sheriff.api.config.model.OidcConfig;
+import de.cuioss.sheriff.api.config.model.Protocol;
+import de.cuioss.sheriff.api.config.model.RateLimitConfig;
+import de.cuioss.sheriff.api.config.model.RouteConfig;
+import de.cuioss.sheriff.api.config.model.SecurityDefaultsConfig;
+import de.cuioss.sheriff.api.config.model.SecurityFilterConfig;
+import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig;
+import de.cuioss.sheriff.api.config.model.TlsConfig;
+import de.cuioss.sheriff.api.config.model.TokenValidationConfig;
+import de.cuioss.sheriff.api.config.model.UpstreamConfig;
+import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig;
+import io.quarkus.runtime.annotations.RegisterForReflection;
+
+/**
+ * Registers the framework-agnostic configuration model records for reflection so
+ * Jackson can bind the YAML configuration trees into them in a native image.
+ *
+ * The {@link de.cuioss.sheriff.api.config.model} records carry no framework imports
+ * (ADR-0005 seam); this framework-bound holder consolidates their native
+ * reflection registration in one place — listing every Jackson-bound record, its
+ * nested records, and the bound enums — rather than annotating each model type.
+ * The class has no runtime behavior; it exists solely to carry the
+ * {@link RegisterForReflection} targets.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@RegisterForReflection(targets = {
+ GatewayConfig.class,
+ EndpointConfig.class,
+ Metadata.class,
+ TlsConfig.class,
+ TlsConfig.Mtls.class,
+ SecurityHeadersConfig.class,
+ SecurityHeadersConfig.Hsts.class,
+ SecurityHeadersConfig.Cors.class,
+ SecurityDefaultsConfig.class,
+ SecurityFilterConfig.class,
+ UpstreamDefaultsConfig.class,
+ ForwardedConfig.class,
+ ForwardConfig.class,
+ TokenValidationConfig.class,
+ IssuerConfig.class,
+ IssuerConfig.Jwks.class,
+ OidcConfig.class,
+ OidcConfig.Logout.class,
+ OidcConfig.Session.class,
+ OidcConfig.Csrf.class,
+ OidcConfig.Refresh.class,
+ OidcConfig.StepUp.class,
+ AuthConfig.class,
+ RouteConfig.class,
+ MatchConfig.class,
+ MatchConfig.HeaderMatcher.class,
+ UpstreamConfig.class,
+ UpstreamConfig.Retry.class,
+ UpstreamConfig.NotModified.class,
+ UpstreamConfig.CircuitBreaker.class,
+ RateLimitConfig.class,
+ HttpMethod.class,
+ Protocol.class
+})
+public final class ConfigModelReflection {
+
+ private ConfigModelReflection() {
+ // Reflection-registration holder; not instantiable.
+ }
+}
diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java
new file mode 100644
index 0000000..8cf6bfd
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.quarkus;
+
+import java.nio.file.Path;
+import java.util.List;
+
+import de.cuioss.sheriff.api.config.ConfigLogMessages;
+import de.cuioss.sheriff.api.config.RouteTableBuilder;
+import de.cuioss.sheriff.api.config.load.ConfigError;
+import de.cuioss.sheriff.api.config.load.ConfigLoadException;
+import de.cuioss.sheriff.api.config.load.ConfigLoader;
+import de.cuioss.sheriff.api.config.load.EnvSecretResolver;
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.Metadata;
+import de.cuioss.sheriff.api.config.model.ResolvedTopology;
+import de.cuioss.sheriff.api.config.model.RouteTable;
+import de.cuioss.sheriff.api.config.topology.TopologyResolver;
+import de.cuioss.sheriff.api.config.validation.ConfigValidator;
+import de.cuioss.tools.logging.CuiLogger;
+import io.quarkus.runtime.StartupEvent;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.event.Observes;
+import jakarta.enterprise.inject.Produces;
+import jakarta.inject.Singleton;
+import org.eclipse.microprofile.config.inject.ConfigProperty;
+
+/**
+ * The framework-bound edge (ADR-0005 seam) that assembles the file-based
+ * configuration once, at boot, and exposes the immutable result as CDI beans.
+ *
+ * It drives the framework-agnostic boot pipeline — {@link ConfigLoader} (read,
+ * schema-validate, secret-resolve, bind) → endpoint-enablement filter →
+ * {@link TopologyResolver} → {@link ConfigValidator} → {@link RouteTableBuilder} —
+ * supplying every collaborator by construction. On the first collected violation
+ * the producer logs every problem through structured ERROR
+ * {@link ConfigLogMessages} records and throws, so Quarkus exits non-zero and never
+ * serves on partial configuration. On success it emits the {@code CONFIG_LOADED}
+ * INFO record carrying the audit {@code config_version} and publishes the bound
+ * {@link GatewayConfig} and assembled {@link RouteTable} as {@code @ApplicationScoped}
+ * beans.
+ *
+ * @author API Sheriff Team
+ * @since 1.0
+ */
+@ApplicationScoped
+public class ConfigProducer {
+
+ private static final CuiLogger LOGGER = new CuiLogger(ConfigProducer.class);
+ private static final String TOPOLOGY_FILE = "topology.properties";
+
+ @ConfigProperty(name = "sheriff.config.dir", defaultValue = "config")
+ String configDir;
+
+ private GatewayConfig gateway;
+ private RouteTable routeTable;
+ private boolean built;
+
+ /**
+ * Forces eager assembly at boot so an invalid configuration fails startup
+ * before any request is served.
+ *
+ * @param event the Quarkus startup event
+ */
+ void onStartup(@Observes StartupEvent event) {
+ buildOnce();
+ }
+
+ /**
+ * Produces the bound global gateway document.
+ *
+ * {@link Singleton} (a pseudo-scope, no client proxy) because
+ * {@link GatewayConfig} is a {@code record}: ArC cannot subclass a final type to
+ * build the proxy a normal scope such as {@code @ApplicationScoped} would require.
+ * The bean is immutable and assembled once at boot, so a single instance is exact.
+ *
+ * @return the immutable, validated {@link GatewayConfig}
+ */
+ @Produces
+ @Singleton
+ public GatewayConfig gatewayConfig() {
+ buildOnce();
+ return gateway;
+ }
+
+ /**
+ * Produces the assembled route table.
+ *
+ * {@link Singleton} (a pseudo-scope, no client proxy) because {@link RouteTable}
+ * is a {@code record}: ArC cannot subclass a final type to build the proxy a
+ * normal scope such as {@code @ApplicationScoped} would require. The bean is
+ * immutable and assembled once at boot, so a single instance is exact.
+ *
+ * @return the immutable, longest-prefix-ordered {@link RouteTable}
+ */
+ @Produces
+ @Singleton
+ public RouteTable routeTable() {
+ buildOnce();
+ return routeTable;
+ }
+
+ private synchronized void buildOnce() {
+ if (built) {
+ return;
+ }
+ Path directory = Path.of(configDir);
+ try {
+ ConfigLoader.LoadedConfig loaded = new ConfigLoader(directory, new EnvSecretResolver()).load();
+ List enabled = loaded.endpoints().stream().filter(EndpointConfig::enabled).toList();
+ ResolvedTopology topology = new TopologyResolver().resolve(directory.resolve(TOPOLOGY_FILE), enabled);
+ List violations = new ConfigValidator().validate(loaded.gateway(), enabled, topology);
+ if (!violations.isEmpty()) {
+ abort(violations);
+ }
+ this.gateway = loaded.gateway();
+ this.routeTable = new RouteTableBuilder().build(loaded.gateway(), enabled, topology);
+ this.built = true;
+ LOGGER.info(ConfigLogMessages.INFO.CONFIG_LOADED, configVersion(gateway));
+ } catch (ConfigLoadException e) {
+ abort(e.errors());
+ } catch (TopologyResolver.TopologyResolutionException | RouteTableBuilder.RouteTableException e) {
+ LOGGER.error(e, ConfigLogMessages.ERROR.CONFIG_STARTUP_ABORTED, e.getMessage());
+ throw new IllegalStateException("Refusing to start — configuration is invalid", e);
+ }
+ }
+
+ private static void abort(List violations) {
+ for (ConfigError violation : violations) {
+ LOGGER.error(ConfigLogMessages.ERROR.CONFIG_VALIDATION_FAILED, violation.file(), violation.pointer(),
+ violation.message());
+ }
+ String summary = "%d configuration violation(s)".formatted(violations.size());
+ LOGGER.error(ConfigLogMessages.ERROR.CONFIG_STARTUP_ABORTED, summary);
+ throw new IllegalStateException("Refusing to start — " + summary);
+ }
+
+ private static String configVersion(GatewayConfig gateway) {
+ return gateway.metadata().flatMap(Metadata::configVersion).orElse("unversioned");
+ }
+}
diff --git a/api-sheriff/src/main/resources/application.properties b/api-sheriff/src/main/resources/application.properties
index 07bcfc6..1bafb92 100644
--- a/api-sheriff/src/main/resources/application.properties
+++ b/api-sheriff/src/main/resources/application.properties
@@ -20,7 +20,7 @@ quarkus.tls.alpn=true
# Native Image Configuration
quarkus.native.additional-build-args=--enable-url-protocols=https,--enable-http,--enable-https,-O2
-quarkus.native.resources.includes=**/*.p12,**/*.crt,**/*.key
+quarkus.native.resources.includes=**/*.p12,**/*.crt,**/*.key,schema/*.json
quarkus.native.monitoring=jfr
# Java 25 Mandrel builder image for JEP 491 (virtual threads without pinning) — see ADR-0001
quarkus.native.builder-image=quay.io/quarkus/ubi9-quarkus-mandrel-builder-image:jdk-25
@@ -28,10 +28,10 @@ quarkus.native.builder-image=quay.io/quarkus/ubi9-quarkus-mandrel-builder-image:
# Virtual Threads Configuration
quarkus.virtual-threads.name-prefix=api-sheriff
-# Proxy Configuration (interim — replaced by the real config subsystem in Plan 02)
-# Requests under / are forwarded to ; everything else -> 404.
-sheriff.proxy.path-prefix=/proxy
-sheriff.proxy.upstream-url=http://localhost:8080
+# Configuration subsystem — the directory holding gateway.yaml, the endpoints/
+# subdirectory, and topology.properties. Read once at startup by ConfigProducer,
+# which validates the whole set and fails fast (non-zero exit) on any violation.
+sheriff.config.dir=config
# Logging
quarkus.log.level=INFO
diff --git a/api-sheriff/src/main/resources/schema/endpoint.schema.json b/api-sheriff/src/main/resources/schema/endpoint.schema.json
new file mode 100644
index 0000000..89a2a5d
--- /dev/null
+++ b/api-sheriff/src/main/resources/schema/endpoint.schema.json
@@ -0,0 +1,180 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://api-sheriff.cuioss.de/schema/endpoint.schema.json",
+ "title": "API Sheriff endpoints/*.yaml",
+ "description": "JSON Schema for a single endpoint file under endpoints/*.yaml. Mirrors doc/configuration.adoc field-for-field. Endpoint-id uniqueness across files, upstream_defaults wholesale-replacement, and allowed_methods no-inheritance semantics are enforced in code, not schema. Unknown keys are rejected at every object level (additionalProperties: false).",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["endpoint"],
+ "$defs": {
+ "httpVerb": {
+ "type": "string",
+ "enum": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
+ },
+ "auth": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["require"],
+ "properties": {
+ "require": { "type": "string", "enum": ["none", "bearer", "session"] },
+ "required_scopes": { "type": "array", "items": { "type": "string" } }
+ }
+ },
+ "upstreamDefaults": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "retry": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": { "type": "boolean", "default": true }
+ }
+ },
+ "not_modified": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": { "type": "boolean", "default": true }
+ }
+ }
+ }
+ }
+ },
+ "properties": {
+ "endpoint": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "base_url", "auth", "routes"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^[a-z][a-z0-9-]*$",
+ "description": "Unique endpoint name (mandatory). Uniqueness across all endpoint files is enforced in code."
+ },
+ "enabled": {
+ "type": "boolean",
+ "default": true,
+ "description": "Whether this endpoint is active. Defaults to true. Overridable per environment by ENDPOINT__ENABLED."
+ },
+ "base_url": {
+ "type": "string",
+ "description": "Topology alias resolved from topology.properties / TOPOLOGY_. Never a concrete host."
+ },
+ "auth": { "$ref": "#/$defs/auth" },
+ "allowed_methods": {
+ "type": "array",
+ "description": "Optional per-endpoint verb allowlist. REPLACES the global gateway.allowed_methods wholesale for this endpoint (no inheritance). TRACE and CONNECT are excluded from the enum.",
+ "items": { "$ref": "#/$defs/httpVerb" }
+ },
+ "upstream_defaults": {
+ "$ref": "#/$defs/upstreamDefaults",
+ "description": "Optional endpoint-level retry / not-modified defaults. When present, replaces the global upstream_defaults wholesale for this endpoint."
+ },
+ "routes": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "match"],
+ "properties": {
+ "id": { "type": "string" },
+ "protocol": { "type": "string", "enum": ["http", "grpc", "graphql", "websocket"] },
+ "match": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["path_prefix"],
+ "properties": {
+ "path_prefix": { "type": "string" },
+ "methods": { "type": "array", "items": { "$ref": "#/$defs/httpVerb" } },
+ "host": { "type": "string" },
+ "headers": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name"],
+ "properties": {
+ "name": { "type": "string" },
+ "present": { "type": "boolean" },
+ "value": { "type": "string" }
+ }
+ }
+ }
+ }
+ },
+ "auth": { "$ref": "#/$defs/auth" },
+ "security_filter": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "profile": { "type": "string", "enum": ["default", "strict", "lenient"] },
+ "allowed_paths": { "type": "array", "items": { "type": "string" } },
+ "max_header_count": { "type": "integer" },
+ "max_header_value_length": { "type": "integer" },
+ "max_query_params": { "type": "integer" },
+ "max_param_value_length": { "type": "integer" },
+ "max_body_bytes": { "type": "integer" },
+ "allowed_header_names": { "type": "array", "items": { "type": "string" } },
+ "blocked_header_names": { "type": "array", "items": { "type": "string" } },
+ "allowed_content_types": { "type": "array", "items": { "type": "string" } }
+ }
+ },
+ "forward": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "headers_allow": { "type": "array", "items": { "type": "string" } },
+ "query_allow": { "type": "array", "items": { "type": "string" } },
+ "set_headers": { "type": "object", "additionalProperties": { "type": "string" } }
+ }
+ },
+ "upstream": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "path": { "type": "string" },
+ "connect_timeout_ms": { "type": "integer" },
+ "read_timeout_ms": { "type": "integer" },
+ "retry": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": { "type": "boolean" },
+ "max_attempts": { "type": "integer" },
+ "idempotent_only": { "type": "boolean" }
+ }
+ },
+ "not_modified": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": { "type": "boolean" }
+ }
+ },
+ "circuit_breaker": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "failures": { "type": "integer" },
+ "reset_ms": { "type": "integer" }
+ }
+ }
+ }
+ },
+ "rate_limit": {
+ "type": "object",
+ "additionalProperties": false,
+ "description": "Reserved; the rate-limit feature is deferred. Accepted and ignored.",
+ "properties": {
+ "requests_per_second": { "type": "integer" },
+ "burst": { "type": "integer" }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/api-sheriff/src/main/resources/schema/gateway.schema.json b/api-sheriff/src/main/resources/schema/gateway.schema.json
new file mode 100644
index 0000000..a1287ca
--- /dev/null
+++ b/api-sheriff/src/main/resources/schema/gateway.schema.json
@@ -0,0 +1,208 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://api-sheriff.cuioss.de/schema/gateway.schema.json",
+ "title": "API Sheriff gateway.yaml",
+ "description": "JSON Schema for the global gateway.yaml configuration document. Mirrors doc/configuration.adoc field-for-field. Unknown keys are rejected at every object level (additionalProperties: false).",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["version"],
+ "$defs": {
+ "httpVerb": {
+ "type": "string",
+ "enum": ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
+ },
+ "upstreamDefaults": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "retry": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": { "type": "boolean", "default": true }
+ }
+ },
+ "not_modified": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": { "type": "boolean", "default": true }
+ }
+ }
+ }
+ }
+ },
+ "properties": {
+ "version": {
+ "type": "integer",
+ "description": "Config schema version; unknown values are refused in code."
+ },
+ "metadata": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "config_version": { "type": "string" }
+ }
+ },
+ "tls": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "min_version": { "type": "string", "enum": ["1.2", "1.3"] },
+ "alpn": { "type": "array", "items": { "type": "string" } },
+ "passthrough_sni": {
+ "type": "object",
+ "description": "Map of SNI hostname -> topology alias, relayed at L4 without decryption.",
+ "additionalProperties": { "type": "string" }
+ },
+ "mtls": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["enabled"],
+ "properties": {
+ "enabled": { "type": "boolean" },
+ "client_ca": { "type": "string" }
+ }
+ }
+ }
+ },
+ "security_headers": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "hsts": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "max_age": { "type": "integer" },
+ "include_subdomains": { "type": "boolean" }
+ }
+ },
+ "content_type_nosniff": { "type": "boolean" },
+ "frame_deny": { "type": "boolean" },
+ "cors": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": { "type": "boolean" },
+ "allowed_origins": { "type": "array", "items": { "type": "string" } },
+ "allowed_methods": { "type": "array", "items": { "type": "string" } },
+ "allowed_headers": { "type": "array", "items": { "type": "string" } },
+ "allow_credentials": { "type": "boolean" }
+ }
+ }
+ }
+ },
+ "security_defaults": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "profile": { "type": "string", "enum": ["default", "strict", "lenient"] }
+ }
+ },
+ "allowed_methods": {
+ "type": "array",
+ "description": "Global positive HTTP-verb allowlist. TRACE and CONNECT are never permitted and are excluded from the enum.",
+ "items": { "$ref": "#/$defs/httpVerb" }
+ },
+ "upstream_defaults": {
+ "$ref": "#/$defs/upstreamDefaults",
+ "description": "Global retry / HTTP-304 not-modified defaults (both default true). An endpoint upstream_defaults block replaces this wholesale."
+ },
+ "forwarded": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "trusted_proxies": { "type": "array", "items": { "type": "string" } },
+ "trust_scheme_host": { "type": "boolean" },
+ "emit": { "type": "string", "enum": ["x-forwarded", "both"] }
+ }
+ },
+ "token_validation": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "issuers": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["name", "issuer"],
+ "properties": {
+ "name": { "type": "string" },
+ "issuer": { "type": "string" },
+ "audience": { "type": "string" },
+ "jwks": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["source"],
+ "properties": {
+ "source": { "type": "string", "enum": ["http", "file", "inline"] },
+ "url": { "type": "string" },
+ "file": { "type": "string" }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "oidc": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "issuer": { "type": "string" },
+ "client_id": { "type": "string" },
+ "client_secret": { "type": "string" },
+ "scopes": { "type": "array", "items": { "type": "string" } },
+ "redirect_uri": { "type": "string" },
+ "logout": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "path": { "type": "string" },
+ "post_logout_redirect_uri": { "type": "string" },
+ "final_redirect": { "type": "string" },
+ "backchannel_path": { "type": "string" }
+ }
+ },
+ "session": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "mode": { "type": "string", "enum": ["cookie", "server"] },
+ "store": { "type": "string", "enum": ["memory"] },
+ "cookie_name": { "type": "string" },
+ "encryption_key": { "type": "string" },
+ "previous_key": { "type": "string" },
+ "ttl_seconds": { "type": "integer" },
+ "csrf": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "trusted_origins": { "type": "array", "items": { "type": "string" } }
+ }
+ },
+ "refresh": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": { "type": "boolean" },
+ "leeway_seconds": { "type": "integer" },
+ "on_failure": { "type": "string", "enum": ["reauthenticate", "reject"] }
+ }
+ }
+ }
+ },
+ "step_up": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "enabled": { "type": "boolean" },
+ "honor_upstream_challenge": { "type": "boolean" }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java
new file mode 100644
index 0000000..1f8b201
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java
@@ -0,0 +1,395 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.MatchConfig;
+import de.cuioss.sheriff.api.config.model.MatchConfig.HeaderMatcher;
+import de.cuioss.sheriff.api.config.model.ResolvedRoute;
+import de.cuioss.sheriff.api.config.model.ResolvedTopology;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.config.model.RouteConfig;
+import de.cuioss.sheriff.api.config.model.RouteTable;
+import de.cuioss.sheriff.api.config.model.UpstreamConfig;
+import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig;
+
+/**
+ * Tests for {@link RouteTableBuilder}: enabled-only merge, longest-prefix
+ * ordering, same-prefix disjointness enforcement, and the materialization of
+ * effective auth, effective {@code allowed_methods}, and the three-level
+ * retry / not-modified override chain.
+ */
+class RouteTableBuilderTest {
+
+ private final RouteTableBuilder builder = new RouteTableBuilder();
+
+ // --- fixture helpers -------------------------------------------------
+
+ private static GatewayConfig.GatewayConfigBuilder gateway() {
+ return GatewayConfig.builder().version(1);
+ }
+
+ private static ResolvedTopology topologyWith(String... aliases) {
+ Map map = new HashMap<>();
+ for (String alias : aliases) {
+ map.put(alias, new ResolvedUpstream("https", alias.toLowerCase(Locale.ROOT) + ".internal", 443, ""));
+ }
+ return new ResolvedTopology(map);
+ }
+
+ private static MatchConfig match(String pathPrefix, HttpMethod... methods) {
+ return MatchConfig.builder().pathPrefix(pathPrefix).methods(List.of(methods)).build();
+ }
+
+ private static RouteConfig route(String id, HttpMethod... methods) {
+ return RouteConfig.builder().id(id).match(match("/" + id, methods)).build();
+ }
+
+ private static RouteConfig routeWithPrefix(String id, String pathPrefix, HttpMethod... methods) {
+ return RouteConfig.builder().id(id).match(match(pathPrefix, methods)).build();
+ }
+
+ private static RouteConfig routeWithHeader(String id, String pathPrefix, HeaderMatcher header) {
+ MatchConfig match = MatchConfig.builder().pathPrefix(pathPrefix).headers(List.of(header)).build();
+ return RouteConfig.builder().id(id).match(match).build();
+ }
+
+ private static RouteConfig routeWithToggles(String id, Boolean retry, Boolean notModified) {
+ UpstreamConfig.UpstreamConfigBuilder upstream = UpstreamConfig.builder();
+ if (retry != null) {
+ upstream.retry(Optional.of(UpstreamConfig.Retry.builder().enabled(Optional.of(retry)).build()));
+ }
+ if (notModified != null) {
+ upstream.notModified(Optional.of(new UpstreamConfig.NotModified(Optional.of(notModified))));
+ }
+ return RouteConfig.builder().id(id).match(match("/" + id)).upstream(Optional.of(upstream.build())).build();
+ }
+
+ private static EndpointConfig.EndpointConfigBuilder endpoint(String id, String alias) {
+ return EndpointConfig.builder().id(id).enabled(true).baseUrl(alias).auth(new AuthConfig("none", List.of()));
+ }
+
+ private static ResolvedRoute find(RouteTable table, String id) {
+ return table.routes().stream().filter(route -> route.id().equals(id)).findFirst().orElseThrow();
+ }
+
+ @Nested
+ @DisplayName("Merge, ordering, and disjointness")
+ class MergeOrderDisjointness {
+
+ @Test
+ @DisplayName("Should merge enabled endpoints only, skipping disabled ones")
+ void shouldMergeEnabledEndpointsOnly() {
+ EndpointConfig enabled = endpoint("orders", "ORDERS")
+ .routes(List.of(route("orders-get", HttpMethod.GET))).build();
+ EndpointConfig disabled = endpoint("legacy", "LEGACY").enabled(false)
+ .routes(List.of(route("legacy-get", HttpMethod.GET))).build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(enabled, disabled), topologyWith("ORDERS"));
+
+ assertEquals(1, table.routes().size(), "the disabled endpoint should contribute no rows");
+ assertEquals("orders-get", table.routes().getFirst().id());
+ }
+
+ @Test
+ @DisplayName("Should order routes by descending path_prefix length")
+ void shouldOrderByLongestPrefixFirst() {
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .routes(List.of(routeWithPrefix("short", "/a", HttpMethod.GET),
+ routeWithPrefix("long", "/a/b/c", HttpMethod.GET),
+ routeWithPrefix("mid", "/a/b", HttpMethod.GET)))
+ .build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ assertEquals(List.of("long", "mid", "short"),
+ table.routes().stream().map(ResolvedRoute::id).toList());
+ }
+
+ @Test
+ @DisplayName("Should reject two same-prefix routes that overlap on method")
+ void shouldRejectNonDisjointSamePrefixRoutes() {
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .routes(List.of(routeWithPrefix("first", "/x", HttpMethod.GET),
+ routeWithPrefix("second", "/x", HttpMethod.GET)))
+ .build();
+
+ assertThrows(RouteTableBuilder.RouteTableException.class,
+ () -> builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")));
+ }
+
+ @Test
+ @DisplayName("Should accept two same-prefix routes disjoint by method")
+ void shouldAcceptSamePrefixRoutesDisjointByMethod() {
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .routes(List.of(routeWithPrefix("reader", "/x", HttpMethod.GET),
+ routeWithPrefix("writer", "/x", HttpMethod.POST)))
+ .build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ assertEquals(2, table.routes().size(), "method-disjoint same-prefix routes should both survive");
+ }
+
+ @Test
+ @DisplayName("Should reject an unresolved alias for an enabled endpoint")
+ void shouldRejectUnresolvedAliasForEnabledEndpoint() {
+ EndpointConfig endpoint = endpoint("orders", "MISSING")
+ .routes(List.of(route("r", HttpMethod.GET))).build();
+
+ assertThrows(RouteTableBuilder.RouteTableException.class,
+ () -> builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")));
+ }
+
+ @Test
+ @DisplayName("Should resolve each route's upstream from the topology")
+ void shouldResolveUpstreamTarget() {
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .routes(List.of(route("r", HttpMethod.GET))).build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ assertEquals("orders.internal", find(table, "r").upstream().host());
+ }
+
+ @Test
+ @DisplayName("Should look up the most specific prefix match")
+ void shouldLookUpMostSpecificPrefix() {
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .routes(List.of(routeWithPrefix("broad", "/a", HttpMethod.GET),
+ routeWithPrefix("narrow", "/a/b", HttpMethod.GET)))
+ .build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ assertEquals("narrow", table.lookup("/a/b/item").orElseThrow().id());
+ assertEquals("broad", table.lookup("/a/other").orElseThrow().id());
+ assertTrue(table.lookup("/unmatched").isEmpty());
+ }
+
+ @Test
+ @DisplayName("Should not match a prefix that only shares a leading substring with the path")
+ void shouldMatchOnSegmentBoundaryOnly() {
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .routes(List.of(routeWithPrefix("proxy", "/proxy", HttpMethod.GET)))
+ .build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ assertTrue(table.lookup("/proxy-helper").isEmpty(),
+ "/proxy-helper must not match the prefix /proxy across a segment boundary");
+ assertEquals("proxy", table.lookup("/proxy").orElseThrow().id(),
+ "an exact prefix match must resolve the route");
+ assertEquals("proxy", table.lookup("/proxy/items").orElseThrow().id(),
+ "a child path must match the prefix on the segment boundary");
+ }
+
+ @Test
+ @DisplayName("Should treat a prefix already ending in a slash as a segment boundary")
+ void shouldMatchTrailingSlashPrefix() {
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .routes(List.of(routeWithPrefix("api", "/api/", HttpMethod.GET)))
+ .build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ assertEquals("api", table.lookup("/api/orders").orElseThrow().id());
+ assertTrue(table.lookup("/api-internal").isEmpty(),
+ "/api-internal must not match the prefix /api/");
+ }
+
+ @Test
+ @DisplayName("Should accept same-prefix routes made disjoint by mutually exclusive header presence")
+ void shouldAcceptSamePrefixRoutesDisjointByHeaderPresence() {
+ RouteConfig present = routeWithHeader("present", "/x",
+ HeaderMatcher.builder().name("X-Debug").present(Optional.of(true)).build());
+ RouteConfig absent = routeWithHeader("absent", "/x",
+ HeaderMatcher.builder().name("X-Debug").present(Optional.of(false)).build());
+ EndpointConfig endpoint = endpoint("orders", "ORDERS").routes(List.of(present, absent)).build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ assertEquals(2, table.routes().size(),
+ "presence-disjoint same-prefix routes should both survive");
+ }
+
+ @Test
+ @DisplayName("Should reject same-prefix routes whose header matchers only agree on presence")
+ void shouldRejectSamePrefixRoutesWithMatchingHeaderPresence() {
+ RouteConfig first = routeWithHeader("first", "/x",
+ HeaderMatcher.builder().name("X-Debug").present(Optional.of(true)).build());
+ RouteConfig second = routeWithHeader("second", "/x",
+ HeaderMatcher.builder().name("X-Debug").present(Optional.of(true)).build());
+ EndpointConfig endpoint = endpoint("orders", "ORDERS").routes(List.of(first, second)).build();
+
+ assertThrows(RouteTableBuilder.RouteTableException.class,
+ () -> builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS")));
+ }
+ }
+
+ @Nested
+ @DisplayName("Effective auth materialization")
+ class EffectiveAuth {
+
+ @Test
+ @DisplayName("Should apply a route-level auth override wholesale")
+ void shouldApplyRouteAuthOverride() {
+ RouteConfig secured = RouteConfig.builder().id("secured").match(match("/secured", HttpMethod.GET))
+ .auth(Optional.of(new AuthConfig("bearer", List.of("read")))).build();
+ EndpointConfig endpoint = endpoint("orders", "ORDERS").routes(List.of(secured)).build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ assertEquals("bearer", find(table, "secured").effectiveAuth().require());
+ }
+
+ @Test
+ @DisplayName("Should inherit the endpoint default auth when the route omits it")
+ void shouldInheritEndpointAuth() {
+ EndpointConfig endpoint = EndpointConfig.builder().id("orders").enabled(true).baseUrl("ORDERS")
+ .auth(new AuthConfig("session", List.of())).routes(List.of(route("r", HttpMethod.GET))).build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ assertEquals("session", find(table, "r").effectiveAuth().require());
+ }
+ }
+
+ @Nested
+ @DisplayName("Effective allowed_methods resolution")
+ class EffectiveAllowedMethods {
+
+ @Test
+ @DisplayName("Should let the endpoint list replace the global list wholesale, even for a verb the global omits")
+ void shouldLetEndpointReplaceGlobalWholesale() {
+ GatewayConfig config = gateway().allowedMethods(List.of(HttpMethod.GET)).build();
+ EndpointConfig endpoint = endpoint("orders", "ORDERS").allowedMethods(List.of(HttpMethod.PUT))
+ .routes(List.of(route("r", HttpMethod.PUT))).build();
+
+ RouteTable table = builder.build(config, List.of(endpoint), topologyWith("ORDERS"));
+
+ assertEquals(List.of(HttpMethod.PUT), find(table, "r").effectiveAllowedMethods());
+ }
+
+ @Test
+ @DisplayName("Should fall back to the global list when the endpoint declares none")
+ void shouldFallBackToGlobalList() {
+ GatewayConfig config = gateway().allowedMethods(List.of(HttpMethod.GET, HttpMethod.POST)).build();
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .routes(List.of(route("r", HttpMethod.GET))).build();
+
+ RouteTable table = builder.build(config, List.of(endpoint), topologyWith("ORDERS"));
+
+ assertEquals(List.of(HttpMethod.GET, HttpMethod.POST), find(table, "r").effectiveAllowedMethods());
+ }
+
+ @Test
+ @DisplayName("Should fall back to the standard set when neither level declares a list")
+ void shouldFallBackToStandardSet() {
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .routes(List.of(route("r", HttpMethod.GET))).build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ List effective = find(table, "r").effectiveAllowedMethods();
+ assertEquals(EnumSet.allOf(HttpMethod.class).size(), effective.size());
+ assertTrue(effective.containsAll(EnumSet.allOf(HttpMethod.class)),
+ "the standard set should contain every representable verb");
+ }
+ }
+
+ @Nested
+ @DisplayName("Effective retry / not-modified resolution")
+ class EffectiveUpstreamDefaults {
+
+ @Test
+ @DisplayName("Should default both toggles to true when nothing is declared")
+ void shouldDefaultBothTogglesTrue() {
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .routes(List.of(route("r", HttpMethod.GET))).build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ ResolvedRoute resolved = find(table, "r");
+ assertTrue(resolved.retryEnabled(), "retry should default to true");
+ assertTrue(resolved.notModifiedEnabled(), "not-modified should default to true");
+ }
+
+ @Test
+ @DisplayName("Should let the endpoint upstream_defaults replace the global block wholesale")
+ void shouldLetEndpointDefaultsReplaceGlobal() {
+ GatewayConfig config = gateway()
+ .upstreamDefaults(Optional.of(new UpstreamDefaultsConfig(true, true))).build();
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .upstreamDefaults(Optional.of(new UpstreamDefaultsConfig(false, false)))
+ .routes(List.of(route("r", HttpMethod.GET))).build();
+
+ RouteTable table = builder.build(config, List.of(endpoint), topologyWith("ORDERS"));
+
+ ResolvedRoute resolved = find(table, "r");
+ assertFalse(resolved.retryEnabled(), "endpoint block should replace the global retry toggle");
+ assertFalse(resolved.notModifiedEnabled(), "endpoint block should replace the global not-modified toggle");
+ }
+
+ @Test
+ @DisplayName("Should let a per-route toggle override the resolved endpoint value")
+ void shouldLetPerRouteToggleOverride() {
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .upstreamDefaults(Optional.of(new UpstreamDefaultsConfig(false, false)))
+ .routes(List.of(routeWithToggles("r", true, null))).build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ ResolvedRoute resolved = find(table, "r");
+ assertTrue(resolved.retryEnabled(), "the per-route retry override should win");
+ assertFalse(resolved.notModifiedEnabled(), "the absent per-route toggle should inherit the endpoint value");
+ }
+
+ @Test
+ @DisplayName("Should inherit the resolved value when a per-route toggle is absent")
+ void shouldInheritWhenPerRouteToggleAbsent() {
+ EndpointConfig endpoint = endpoint("orders", "ORDERS")
+ .upstreamDefaults(Optional.of(new UpstreamDefaultsConfig(false, true)))
+ .routes(List.of(routeWithToggles("r", null, null))).build();
+
+ RouteTable table = builder.build(gateway().build(), List.of(endpoint), topologyWith("ORDERS"));
+
+ ResolvedRoute resolved = find(table, "r");
+ assertFalse(resolved.retryEnabled(), "absent retry toggle should inherit the endpoint value");
+ assertTrue(resolved.notModifiedEnabled(), "absent not-modified toggle should inherit the endpoint value");
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java
new file mode 100644
index 0000000..8661679
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java
@@ -0,0 +1,181 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.load;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.Protocol;
+import de.cuioss.sheriff.api.config.model.RouteConfig;
+import de.cuioss.sheriff.api.config.model.UpstreamDefaultsConfig;
+
+/**
+ * Tests for {@link ConfigLoader}: binding a valid {@code gateway.yaml} (including
+ * secret resolution and the flattened {@code upstream_defaults} block), endpoint
+ * binding with the {@code enabled} default, and the aggregated, path-annotated error
+ * reporting for schema violations, missing secrets, and a missing gateway file.
+ */
+class ConfigLoaderTest {
+
+ @TempDir
+ Path configDir;
+
+ private ConfigLoader loader(Map environment) {
+ return new ConfigLoader(configDir, new EnvSecretResolver(environment::get));
+ }
+
+ private void copyFixtureAs(String resource, String relativeTarget) throws IOException {
+ Path target = configDir.resolve(relativeTarget);
+ Files.createDirectories(target.getParent());
+ try (InputStream in = getClass().getResourceAsStream(resource)) {
+ assertNotNull(in, "fixture not on classpath: " + resource);
+ Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ private void writeConfig(String relativeTarget, String content) throws IOException {
+ Path target = configDir.resolve(relativeTarget);
+ Files.createDirectories(target.getParent());
+ Files.writeString(target, content);
+ }
+
+ @Test
+ void bindsValidGatewayConfigAndResolvesSecrets() throws Exception {
+ copyFixtureAs("/config/valid/gateway.yaml", "gateway.yaml");
+
+ ConfigLoader.LoadedConfig loaded = loader(Map.of("OIDC_CLIENT_SECRET", "s3cr3t")).load();
+
+ GatewayConfig gateway = loaded.gateway();
+ assertEquals(1, gateway.version());
+ assertEquals(List.of(HttpMethod.GET, HttpMethod.POST, HttpMethod.PUT, HttpMethod.DELETE),
+ gateway.allowedMethods());
+ assertEquals(Optional.of("1.3"), gateway.tls().orElseThrow().minVersion());
+ assertEquals(new UpstreamDefaultsConfig(true, false), gateway.upstreamDefaults().orElseThrow());
+ assertEquals(Optional.of("s3cr3t"), gateway.oidc().orElseThrow().clientSecret());
+ assertEquals(1, gateway.tokenValidation().orElseThrow().issuers().size());
+ assertEquals("primary", gateway.tokenValidation().orElseThrow().issuers().getFirst().name());
+ assertTrue(loaded.endpoints().isEmpty());
+ }
+
+ @Test
+ void reportsMissingEnvSecretWithPointer() throws Exception {
+ copyFixtureAs("/config/valid/gateway.yaml", "gateway.yaml");
+
+ ConfigLoadException exception = assertThrows(ConfigLoadException.class, () -> loader(Map.of()).load());
+
+ assertTrue(exception.errors().stream()
+ .anyMatch(error -> error.pointer().contains("client_secret")
+ && error.message().contains("OIDC_CLIENT_SECRET")),
+ () -> "expected a pointer-annotated missing-secret error, got: " + exception.errors());
+ }
+
+ @Test
+ void aggregatesSchemaErrorsWithPathAnnotation() throws Exception {
+ copyFixtureAs("/config/invalid/unknown-key.yaml", "gateway.yaml");
+
+ ConfigLoadException exception = assertThrows(ConfigLoadException.class, () -> loader(Map.of()).load());
+
+ assertFalse(exception.errors().isEmpty());
+ assertTrue(exception.errors().stream().allMatch(error -> "gateway.yaml".equals(error.file())));
+ assertTrue(exception.errors().stream().anyMatch(error -> error.pointer().contains("min_version")),
+ () -> "expected a path-annotated enum violation, got: " + exception.errors());
+ }
+
+ @Test
+ void reportsMissingGatewayFile() {
+ ConfigLoadException exception = assertThrows(ConfigLoadException.class, () -> loader(Map.of()).load());
+
+ assertTrue(exception.errors().stream()
+ .anyMatch(error -> "gateway.yaml".equals(error.file())
+ && error.message().contains("not found")),
+ () -> "expected a missing-file error, got: " + exception.errors());
+ }
+
+ @Test
+ void bindsEndpointApplyingEnabledDefaultAndFlattenedUpstreamDefaults() throws Exception {
+ writeConfig("gateway.yaml", "version: 1\n");
+ writeConfig("endpoints/orders.yaml", """
+ endpoint:
+ id: orders
+ base_url: ORDERS
+ auth:
+ require: bearer
+ required_scopes: ["orders.read"]
+ allowed_methods: ["GET", "POST"]
+ upstream_defaults:
+ retry:
+ enabled: false
+ not_modified:
+ enabled: true
+ routes:
+ - id: orders-read
+ protocol: http
+ match:
+ path_prefix: /orders
+ methods: ["GET"]
+ auth:
+ require: bearer
+ upstream:
+ path: /v1/orders
+ retry:
+ enabled: true
+ max_attempts: 3
+ """);
+
+ ConfigLoader.LoadedConfig loaded = loader(Map.of()).load();
+
+ assertEquals(1, loaded.endpoints().size());
+ EndpointConfig endpoint = loaded.endpoints().getFirst();
+ assertEquals("orders", endpoint.id());
+ assertTrue(endpoint.enabled(), "an endpoint omitting 'enabled' defaults to enabled");
+ assertEquals(List.of(HttpMethod.GET, HttpMethod.POST), endpoint.allowedMethods());
+ assertEquals(new UpstreamDefaultsConfig(false, true), endpoint.upstreamDefaults().orElseThrow());
+ assertEquals(1, endpoint.routes().size());
+ RouteConfig route = endpoint.routes().getFirst();
+ assertEquals("orders-read", route.id());
+ assertEquals(Optional.of(Protocol.HTTP), route.protocol());
+ assertTrue(route.upstream().orElseThrow().retry().isPresent());
+ }
+
+ @Test
+ void loadsWithoutEndpointsWhenDirectoryAbsent() throws Exception {
+ writeConfig("gateway.yaml", "version: 1\n");
+
+ ConfigLoader.LoadedConfig loaded = loader(Map.of()).load();
+
+ assertEquals(1, loaded.gateway().version());
+ assertTrue(loaded.endpoints().isEmpty());
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java
new file mode 100644
index 0000000..fd4bb7f
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.load;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Map;
+
+
+import org.junit.jupiter.api.Test;
+
+import de.cuioss.sheriff.api.config.load.EnvSecretResolver.MissingVariableException;
+
+/**
+ * Tests for {@link EnvSecretResolver}: reference detection, single / embedded /
+ * multiple substitution, pass-through of plain values, and the missing-variable
+ * failure.
+ */
+class EnvSecretResolverTest {
+
+ private static EnvSecretResolver resolverWith(Map environment) {
+ return new EnvSecretResolver(environment::get);
+ }
+
+ @Test
+ void detectsPresenceOfReference() {
+ EnvSecretResolver resolver = resolverWith(Map.of());
+ assertTrue(resolver.hasReference("prefix-${TOKEN}-suffix"));
+ assertFalse(resolver.hasReference("no-reference-here"));
+ }
+
+ @Test
+ void resolvesSingleReference() {
+ EnvSecretResolver resolver = resolverWith(Map.of("SECRET", "s3cr3t"));
+ assertEquals("s3cr3t", resolver.resolve("${SECRET}"));
+ }
+
+ @Test
+ void resolvesEmbeddedReference() {
+ EnvSecretResolver resolver = resolverWith(Map.of("HOST", "example.com"));
+ assertEquals("https://example.com/callback", resolver.resolve("https://${HOST}/callback"));
+ }
+
+ @Test
+ void resolvesMultipleReferences() {
+ EnvSecretResolver resolver = resolverWith(Map.of("USER", "sheriff", "PASS", "pw"));
+ assertEquals("sheriff:pw", resolver.resolve("${USER}:${PASS}"));
+ }
+
+ @Test
+ void passesPlainValueThroughUnchanged() {
+ EnvSecretResolver resolver = resolverWith(Map.of());
+ assertEquals("plain-value", resolver.resolve("plain-value"));
+ }
+
+ @Test
+ void throwsNamingTheMissingVariable() {
+ EnvSecretResolver resolver = resolverWith(Map.of());
+ MissingVariableException exception = assertThrows(MissingVariableException.class,
+ () -> resolver.resolve("${ABSENT}"));
+ assertEquals("ABSENT", exception.variableName());
+ }
+
+ @Test
+ void defaultConstructorResolvesPlainValueWithoutTouchingEnvironment() {
+ assertEquals("plain", new EnvSecretResolver().resolve("plain"));
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java
new file mode 100644
index 0000000..f0836f9
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java
@@ -0,0 +1,557 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.model;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Named;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Value-object contract test suite for the immutable configuration model records
+ * under {@code de.cuioss.sheriff.api.config.model}.
+ *
+ * The records are the binding target for the YAML loader (D4); their behavioural
+ * contract is entirely value-based. This suite verifies:
+ *
+ * - the record {@code equals}/{@code hashCode}/{@code toString} contract for
+ * every model record (top-level and nested),
+ * - the Lombok {@code @Builder} produces instances equal to the canonical
+ * constructor,
+ * - the canonical constructors normalize absent optionals to
+ * {@link Optional#empty()} and absent collections to empty collections,
+ * - collection components are defensively copied into unmodifiable copies,
+ * - mandatory fields are rejected with a {@link NullPointerException},
+ * - the {@link HttpMethod} and {@link Protocol} enum contracts, including the
+ * deliberate absence of {@code TRACE}/{@code CONNECT}.
+ *
+ */
+class ConfigModelContractTest {
+
+ // --- Shared fixtures ---------------------------------------------------
+
+ private static AuthConfig auth() {
+ return new AuthConfig("bearer", List.of("read"));
+ }
+
+ private static GatewayConfig gatewayConfig() {
+ return GatewayConfig.builder()
+ .version(1)
+ .metadata(Optional.of(new Metadata(Optional.of("2024-01"))))
+ .tls(Optional.of(tlsConfig()))
+ .securityHeaders(Optional.of(securityHeadersConfig()))
+ .securityDefaults(Optional.of(new SecurityDefaultsConfig(Optional.of("strict"))))
+ .allowedMethods(List.of(HttpMethod.GET, HttpMethod.POST))
+ .upstreamDefaults(Optional.of(UpstreamDefaultsConfig.defaults()))
+ .forwarded(Optional.of(new ForwardedConfig(List.of("10.0.0.0/8"), Optional.of(true), Optional.of("both"))))
+ .tokenValidation(Optional.of(tokenValidationConfig()))
+ .oidc(Optional.of(oidcConfig()))
+ .build();
+ }
+
+ private static TlsConfig tlsConfig() {
+ return TlsConfig.builder()
+ .minVersion(Optional.of("TLSv1.3"))
+ .alpn(List.of("h2", "http/1.1"))
+ .passthroughSni(Map.of("internal.example.com", "internal-alias"))
+ .mtls(Optional.of(new TlsConfig.Mtls(true, Optional.of("/etc/ca.pem"))))
+ .build();
+ }
+
+ private static SecurityHeadersConfig securityHeadersConfig() {
+ return SecurityHeadersConfig.builder()
+ .hsts(Optional.of(new SecurityHeadersConfig.Hsts(Optional.of(31536000), Optional.of(true))))
+ .contentTypeNosniff(Optional.of(true))
+ .frameDeny(Optional.of(true))
+ .cors(Optional.of(new SecurityHeadersConfig.Cors(Optional.of(true), List.of("https://app.example.com"),
+ List.of("GET"), List.of("Authorization"), Optional.of(false))))
+ .build();
+ }
+
+ private static TokenValidationConfig tokenValidationConfig() {
+ return new TokenValidationConfig(List.of(issuerConfig()));
+ }
+
+ private static IssuerConfig issuerConfig() {
+ return IssuerConfig.builder()
+ .name("primary")
+ .issuer("https://issuer.example.com")
+ .audience(Optional.of("api-sheriff"))
+ .jwks(Optional.of(new IssuerConfig.Jwks("http", Optional.of("https://issuer.example.com/jwks"),
+ Optional.empty())))
+ .build();
+ }
+
+ private static OidcConfig oidcConfig() {
+ return OidcConfig.builder()
+ .issuer(Optional.of("https://issuer.example.com"))
+ .clientId(Optional.of("sheriff"))
+ .clientSecret(Optional.of("${OIDC_SECRET}"))
+ .scopes(List.of("openid", "profile"))
+ .redirectUri(Optional.of("https://gw.example.com/callback"))
+ .logout(Optional.of(new OidcConfig.Logout(Optional.of("/logout"), Optional.of("/post-logout"),
+ Optional.of("/home"), Optional.of("/backchannel"))))
+ .session(Optional.of(OidcConfig.Session.builder()
+ .mode(Optional.of("cookie"))
+ .cookieName(Optional.of("sid"))
+ .encryptionKey(Optional.of("${SESSION_KEY}"))
+ .ttlSeconds(Optional.of(3600))
+ .csrf(Optional.of(new OidcConfig.Csrf(List.of("https://app.example.com"))))
+ .refresh(Optional.of(new OidcConfig.Refresh(Optional.of(true), Optional.of(60),
+ Optional.of("reauthenticate"))))
+ .build()))
+ .stepUp(Optional.of(new OidcConfig.StepUp(Optional.of(true), Optional.of(false))))
+ .build();
+ }
+
+ private static EndpointConfig endpointConfig() {
+ return EndpointConfig.builder()
+ .id("orders")
+ .enabled(true)
+ .baseUrl("orders-service")
+ .auth(auth())
+ .allowedMethods(List.of(HttpMethod.GET, HttpMethod.POST))
+ .upstreamDefaults(Optional.of(UpstreamDefaultsConfig.defaults()))
+ .routes(List.of(routeConfig()))
+ .build();
+ }
+
+ private static RouteConfig routeConfig() {
+ return RouteConfig.builder()
+ .id("orders-read")
+ .protocol(Optional.of(Protocol.HTTP))
+ .match(matchConfig())
+ .auth(Optional.of(auth()))
+ .securityFilter(Optional.of(securityFilterConfig()))
+ .forward(Optional.of(new ForwardConfig(List.of("Accept"), List.of("page"),
+ Map.of("X-Gateway", "api-sheriff"))))
+ .upstream(Optional.of(upstreamConfig()))
+ .rateLimit(Optional.of(new RateLimitConfig(Optional.of(100), Optional.of(200))))
+ .build();
+ }
+
+ private static MatchConfig matchConfig() {
+ return MatchConfig.builder()
+ .pathPrefix("/orders")
+ .methods(List.of(HttpMethod.GET))
+ .host(Optional.of("api.example.com"))
+ .headers(List.of(new MatchConfig.HeaderMatcher("X-Tenant", Optional.of(true), Optional.of("acme"))))
+ .build();
+ }
+
+ private static SecurityFilterConfig securityFilterConfig() {
+ return SecurityFilterConfig.builder()
+ .profile(Optional.of("strict"))
+ .allowedPaths(List.of("/orders"))
+ .maxHeaderCount(Optional.of(50))
+ .maxHeaderValueLength(Optional.of(4096))
+ .maxQueryParams(Optional.of(32))
+ .maxParamValueLength(Optional.of(1024))
+ .maxBodyBytes(Optional.of(1048576))
+ .allowedHeaderNames(List.of("Accept"))
+ .blockedHeaderNames(List.of("X-Debug"))
+ .allowedContentTypes(List.of("application/json"))
+ .build();
+ }
+
+ private static UpstreamConfig upstreamConfig() {
+ return UpstreamConfig.builder()
+ .path(Optional.of("/v1/orders"))
+ .connectTimeoutMs(Optional.of(2000))
+ .readTimeoutMs(Optional.of(5000))
+ .retry(Optional.of(new UpstreamConfig.Retry(Optional.of(true), Optional.of(3), Optional.of(true))))
+ .notModified(Optional.of(new UpstreamConfig.NotModified(Optional.of(true))))
+ .circuitBreaker(Optional.of(new UpstreamConfig.CircuitBreaker(Optional.of(5), Optional.of(30000))))
+ .build();
+ }
+
+ // --- equals / hashCode / toString contract ----------------------------
+
+ @Nested
+ @DisplayName("equals / hashCode / toString contract")
+ class ValueObjectContract {
+
+ private static Arguments voCase(String name, Object subject, Object equalCopy, Object different) {
+ return Arguments.of(Named.named(name, subject), equalCopy, different);
+ }
+
+ static Stream valueObjects() {
+ return Stream.of(
+ voCase("GatewayConfig", gatewayConfig(), gatewayConfig(),
+ GatewayConfig.builder().version(99).build()),
+ voCase("Metadata", new Metadata(Optional.of("v1")), new Metadata(Optional.of("v1")),
+ new Metadata(Optional.of("v2"))),
+ voCase("TlsConfig", tlsConfig(), tlsConfig(), TlsConfig.builder().build()),
+ voCase("TlsConfig.Mtls", new TlsConfig.Mtls(true, Optional.of("/ca")),
+ new TlsConfig.Mtls(true, Optional.of("/ca")), new TlsConfig.Mtls(false, Optional.empty())),
+ voCase("SecurityHeadersConfig", securityHeadersConfig(), securityHeadersConfig(),
+ SecurityHeadersConfig.builder().build()),
+ voCase("SecurityHeadersConfig.Hsts",
+ new SecurityHeadersConfig.Hsts(Optional.of(1), Optional.of(true)),
+ new SecurityHeadersConfig.Hsts(Optional.of(1), Optional.of(true)),
+ new SecurityHeadersConfig.Hsts(Optional.of(2), Optional.of(false))),
+ voCase("SecurityHeadersConfig.Cors",
+ new SecurityHeadersConfig.Cors(Optional.of(true), List.of("a"), List.of("GET"),
+ List.of("Accept"), Optional.of(false)),
+ new SecurityHeadersConfig.Cors(Optional.of(true), List.of("a"), List.of("GET"),
+ List.of("Accept"), Optional.of(false)),
+ new SecurityHeadersConfig.Cors(Optional.of(false), List.of("b"), List.of("POST"),
+ List.of("Authorization"), Optional.of(true))),
+ voCase("SecurityDefaultsConfig", new SecurityDefaultsConfig(Optional.of("strict")),
+ new SecurityDefaultsConfig(Optional.of("strict")),
+ new SecurityDefaultsConfig(Optional.of("lenient"))),
+ voCase("ForwardedConfig",
+ new ForwardedConfig(List.of("10.0.0.0/8"), Optional.of(true), Optional.of("both")),
+ new ForwardedConfig(List.of("10.0.0.0/8"), Optional.of(true), Optional.of("both")),
+ new ForwardedConfig(List.of("192.168.0.0/16"), Optional.of(false),
+ Optional.of("x-forwarded"))),
+ voCase("TokenValidationConfig", tokenValidationConfig(), tokenValidationConfig(),
+ new TokenValidationConfig(List.of())),
+ voCase("IssuerConfig", issuerConfig(), issuerConfig(),
+ new IssuerConfig("other", "https://other", Optional.empty(), Optional.empty())),
+ voCase("IssuerConfig.Jwks",
+ new IssuerConfig.Jwks("http", Optional.of("https://j"), Optional.empty()),
+ new IssuerConfig.Jwks("http", Optional.of("https://j"), Optional.empty()),
+ new IssuerConfig.Jwks("file", Optional.empty(), Optional.of("/jwks.json"))),
+ voCase("OidcConfig", oidcConfig(), oidcConfig(), OidcConfig.builder().build()),
+ voCase("OidcConfig.Logout",
+ new OidcConfig.Logout(Optional.of("/l"), Optional.empty(), Optional.empty(),
+ Optional.empty()),
+ new OidcConfig.Logout(Optional.of("/l"), Optional.empty(), Optional.empty(),
+ Optional.empty()),
+ new OidcConfig.Logout(Optional.of("/other"), Optional.empty(), Optional.empty(),
+ Optional.empty())),
+ voCase("OidcConfig.Csrf", new OidcConfig.Csrf(List.of("https://a")),
+ new OidcConfig.Csrf(List.of("https://a")), new OidcConfig.Csrf(List.of("https://b"))),
+ voCase("OidcConfig.Refresh",
+ new OidcConfig.Refresh(Optional.of(true), Optional.of(60), Optional.of("reject")),
+ new OidcConfig.Refresh(Optional.of(true), Optional.of(60), Optional.of("reject")),
+ new OidcConfig.Refresh(Optional.of(false), Optional.empty(), Optional.empty())),
+ voCase("OidcConfig.StepUp", new OidcConfig.StepUp(Optional.of(true), Optional.of(false)),
+ new OidcConfig.StepUp(Optional.of(true), Optional.of(false)),
+ new OidcConfig.StepUp(Optional.of(false), Optional.of(true))),
+ voCase("UpstreamDefaultsConfig", new UpstreamDefaultsConfig(true, true),
+ new UpstreamDefaultsConfig(true, true), new UpstreamDefaultsConfig(false, true)),
+ voCase("EndpointConfig", endpointConfig(), endpointConfig(), EndpointConfig.builder()
+ .id("other").baseUrl("svc").auth(auth()).build()),
+ voCase("AuthConfig", auth(), auth(), new AuthConfig("none", List.of())),
+ voCase("RouteConfig", routeConfig(), routeConfig(),
+ RouteConfig.builder().id("other").match(matchConfig()).build()),
+ voCase("MatchConfig", matchConfig(), matchConfig(),
+ MatchConfig.builder().pathPrefix("/other").build()),
+ voCase("MatchConfig.HeaderMatcher",
+ new MatchConfig.HeaderMatcher("X-Key", Optional.of(true), Optional.of("v")),
+ new MatchConfig.HeaderMatcher("X-Key", Optional.of(true), Optional.of("v")),
+ new MatchConfig.HeaderMatcher("X-Other", Optional.empty(), Optional.empty())),
+ voCase("SecurityFilterConfig", securityFilterConfig(), securityFilterConfig(),
+ SecurityFilterConfig.builder().build()),
+ voCase("ForwardConfig",
+ new ForwardConfig(List.of("Accept"), List.of("page"), Map.of("X-Gateway", "sheriff")),
+ new ForwardConfig(List.of("Accept"), List.of("page"), Map.of("X-Gateway", "sheriff")),
+ new ForwardConfig(List.of(), List.of(), Map.of())),
+ voCase("UpstreamConfig", upstreamConfig(), upstreamConfig(), UpstreamConfig.builder().build()),
+ voCase("UpstreamConfig.Retry",
+ new UpstreamConfig.Retry(Optional.of(true), Optional.of(3), Optional.of(true)),
+ new UpstreamConfig.Retry(Optional.of(true), Optional.of(3), Optional.of(true)),
+ new UpstreamConfig.Retry(Optional.of(false), Optional.empty(), Optional.empty())),
+ voCase("UpstreamConfig.NotModified", new UpstreamConfig.NotModified(Optional.of(true)),
+ new UpstreamConfig.NotModified(Optional.of(true)),
+ new UpstreamConfig.NotModified(Optional.of(false))),
+ voCase("UpstreamConfig.CircuitBreaker",
+ new UpstreamConfig.CircuitBreaker(Optional.of(5), Optional.of(30000)),
+ new UpstreamConfig.CircuitBreaker(Optional.of(5), Optional.of(30000)),
+ new UpstreamConfig.CircuitBreaker(Optional.of(1), Optional.of(1))),
+ voCase("RateLimitConfig", new RateLimitConfig(Optional.of(100), Optional.of(200)),
+ new RateLimitConfig(Optional.of(100), Optional.of(200)),
+ new RateLimitConfig(Optional.of(1), Optional.of(1))));
+ }
+
+ @ParameterizedTest(name = "{0}")
+ @MethodSource("valueObjects")
+ void shouldHonorValueObjectContract(Object subject, Object equalCopy, Object different) {
+ assertEquals(subject, equalCopy, "instances built from equal components must be equal");
+ assertEquals(subject.hashCode(), equalCopy.hashCode(), "equal instances must share a hashCode");
+ assertNotEquals(subject, different, "instances with differing components must not be equal");
+ assertNotEquals(null, subject, "a value object is never equal to null");
+ assertNotEquals("not-a-config", subject, "a value object is never equal to a foreign type");
+ assertNotNull(subject.toString(), "toString must be present");
+ }
+ }
+
+ // --- Builder equivalence ----------------------------------------------
+
+ @Nested
+ @DisplayName("Lombok @Builder equivalence with the canonical constructor")
+ class BuilderEquivalence {
+
+ @Test
+ void authConfigBuilderMatchesConstructor() {
+ AuthConfig viaCtor = new AuthConfig("bearer", List.of("read"));
+ AuthConfig viaBuilder = AuthConfig.builder().require("bearer").requiredScopes(List.of("read")).build();
+ assertEquals(viaCtor, viaBuilder);
+ }
+
+ @Test
+ void gatewayConfigBuilderMatchesConstructor() {
+ GatewayConfig viaCtor = new GatewayConfig(2, Optional.empty(), Optional.empty(), Optional.empty(),
+ Optional.empty(), List.of(HttpMethod.GET), Optional.empty(), Optional.empty(), Optional.empty(),
+ Optional.empty());
+ GatewayConfig viaBuilder = GatewayConfig.builder().version(2).allowedMethods(List.of(HttpMethod.GET))
+ .build();
+ assertEquals(viaCtor, viaBuilder);
+ }
+
+ @Test
+ void endpointConfigBuilderMatchesConstructor() {
+ AuthConfig auth = auth();
+ EndpointConfig viaCtor = new EndpointConfig("orders", true, "orders-service", auth,
+ List.of(HttpMethod.GET), Optional.empty(), List.of());
+ EndpointConfig viaBuilder = EndpointConfig.builder().id("orders").enabled(true).baseUrl("orders-service")
+ .auth(auth).allowedMethods(List.of(HttpMethod.GET)).build();
+ assertEquals(viaCtor, viaBuilder);
+ }
+ }
+
+ // --- Null-normalization ------------------------------------------------
+
+ @Nested
+ @DisplayName("Canonical constructors normalize absent components")
+ class NullNormalization {
+
+ @Test
+ void gatewayConfigNormalizesAllAbsentComponents() {
+ GatewayConfig cfg = new GatewayConfig(1, null, null, null, null, null, null, null, null, null);
+ assertTrue(cfg.metadata().isEmpty());
+ assertTrue(cfg.tls().isEmpty());
+ assertTrue(cfg.securityHeaders().isEmpty());
+ assertTrue(cfg.securityDefaults().isEmpty());
+ assertTrue(cfg.allowedMethods().isEmpty());
+ assertTrue(cfg.upstreamDefaults().isEmpty());
+ assertTrue(cfg.forwarded().isEmpty());
+ assertTrue(cfg.tokenValidation().isEmpty());
+ assertTrue(cfg.oidc().isEmpty());
+ }
+
+ @Test
+ void endpointConfigNormalizesAbsentCollectionsAndOptionals() {
+ EndpointConfig cfg = new EndpointConfig("id", true, "url", auth(), null, null, null);
+ assertTrue(cfg.allowedMethods().isEmpty());
+ assertTrue(cfg.upstreamDefaults().isEmpty());
+ assertTrue(cfg.routes().isEmpty());
+ }
+
+ @Test
+ void collectionBearingRecordsNormalizeNullToEmpty() {
+ assertTrue(new AuthConfig("none", null).requiredScopes().isEmpty());
+ assertTrue(new TokenValidationConfig(null).issuers().isEmpty());
+ assertTrue(new ForwardedConfig(null, null, null).trustedProxies().isEmpty());
+ assertTrue(new ForwardConfig(null, null, null).setHeaders().isEmpty());
+ assertTrue(new TlsConfig(null, null, null, null).alpn().isEmpty());
+ assertTrue(new TlsConfig(null, null, null, null).passthroughSni().isEmpty());
+ assertTrue(new MatchConfig("/p", null, null, null).methods().isEmpty());
+ assertTrue(new SecurityFilterConfig(null, null, null, null, null, null, null, null, null, null)
+ .allowedPaths().isEmpty());
+ assertTrue(new OidcConfig.Csrf(null).trustedOrigins().isEmpty());
+ }
+ }
+
+ // --- Defensive copy ----------------------------------------------------
+
+ @Nested
+ @DisplayName("Collection components are defensively copied and unmodifiable")
+ class DefensiveCopy {
+
+ @Test
+ void listComponentIsDecoupledFromTheSource() {
+ List source = new ArrayList<>(List.of(HttpMethod.GET));
+ GatewayConfig cfg = GatewayConfig.builder().version(1).allowedMethods(source).build();
+ source.add(HttpMethod.POST);
+ assertEquals(List.of(HttpMethod.GET), cfg.allowedMethods(),
+ "mutating the source list after construction must not affect the record");
+ }
+
+ @Test
+ void listComponentIsUnmodifiable() {
+ GatewayConfig cfg = GatewayConfig.builder().version(1).allowedMethods(List.of(HttpMethod.GET)).build();
+ assertThrows(UnsupportedOperationException.class, () -> cfg.allowedMethods().add(HttpMethod.PUT));
+ }
+
+ @Test
+ void mapComponentIsDecoupledAndUnmodifiable() {
+ Map source = new HashMap<>();
+ source.put("X-Gateway", "sheriff");
+ ForwardConfig cfg = ForwardConfig.builder().setHeaders(source).build();
+ source.put("X-Extra", "leak");
+ assertEquals(Map.of("X-Gateway", "sheriff"), cfg.setHeaders());
+ assertThrows(UnsupportedOperationException.class, () -> cfg.setHeaders().put("X-New", "v"));
+ }
+ }
+
+ // --- Mandatory fields --------------------------------------------------
+
+ @Nested
+ @DisplayName("Mandatory fields are rejected with NullPointerException")
+ class MandatoryFields {
+
+ @Test
+ void endpointConfigRequiresIdBaseUrlAndAuth() {
+ AuthConfig auth = auth();
+ assertThrows(NullPointerException.class,
+ () -> new EndpointConfig(null, true, "url", auth, List.of(), Optional.empty(), List.of()));
+ assertThrows(NullPointerException.class,
+ () -> new EndpointConfig("id", true, null, auth, List.of(), Optional.empty(), List.of()));
+ assertThrows(NullPointerException.class,
+ () -> new EndpointConfig("id", true, "url", null, List.of(), Optional.empty(), List.of()));
+ }
+
+ @Test
+ void authConfigRequiresRequire() {
+ NullPointerException ex = assertThrows(NullPointerException.class,
+ () -> new AuthConfig(null, List.of()));
+ assertEquals("require", ex.getMessage());
+ }
+
+ @Test
+ void issuerConfigRequiresNameAndIssuer() {
+ assertThrows(NullPointerException.class,
+ () -> new IssuerConfig(null, "iss", Optional.empty(), Optional.empty()));
+ assertThrows(NullPointerException.class,
+ () -> new IssuerConfig("name", null, Optional.empty(), Optional.empty()));
+ }
+
+ @Test
+ void jwksRequiresSource() {
+ assertThrows(NullPointerException.class,
+ () -> new IssuerConfig.Jwks(null, Optional.empty(), Optional.empty()));
+ }
+
+ @Test
+ void matchConfigRequiresPathPrefix() {
+ assertThrows(NullPointerException.class, () -> new MatchConfig(null, List.of(), Optional.empty(), List.of()));
+ }
+
+ @Test
+ void headerMatcherRequiresName() {
+ assertThrows(NullPointerException.class,
+ () -> new MatchConfig.HeaderMatcher(null, Optional.empty(), Optional.empty()));
+ }
+
+ @Test
+ void routeConfigRequiresIdAndMatch() {
+ MatchConfig match = matchConfig();
+ assertThrows(NullPointerException.class, () -> new RouteConfig(null, Optional.empty(), match,
+ Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()));
+ assertThrows(NullPointerException.class, () -> new RouteConfig("id", Optional.empty(), null,
+ Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()));
+ }
+ }
+
+ // --- New fields introduced for this deliverable ------------------------
+
+ @Nested
+ @DisplayName("New endpoint-enablement / method-allowlist fields")
+ class NewFields {
+
+ @Test
+ void endpointConfigExposesIdEnabledAndAllowedMethods() {
+ EndpointConfig cfg = EndpointConfig.builder()
+ .id("orders")
+ .enabled(true)
+ .baseUrl("orders-service")
+ .auth(auth())
+ .allowedMethods(List.of(HttpMethod.GET, HttpMethod.POST))
+ .build();
+ assertEquals("orders", cfg.id());
+ assertTrue(cfg.enabled());
+ assertEquals(List.of(HttpMethod.GET, HttpMethod.POST), cfg.allowedMethods());
+ }
+
+ @Test
+ void endpointConfigEnabledDefaultsToFalseWhenUnsetOnTheRecord() {
+ EndpointConfig cfg = EndpointConfig.builder().id("orders").baseUrl("orders-service").auth(auth()).build();
+ assertFalse(cfg.enabled(), "the record itself applies no default; the YAML loader owns the true default");
+ }
+
+ @Test
+ void gatewayConfigExposesAllowedMethods() {
+ GatewayConfig cfg = GatewayConfig.builder().version(1).allowedMethods(List.of(HttpMethod.DELETE)).build();
+ assertEquals(List.of(HttpMethod.DELETE), cfg.allowedMethods());
+ }
+ }
+
+ // --- Enum contracts ----------------------------------------------------
+
+ @Nested
+ @DisplayName("HttpMethod enum contract")
+ class HttpMethodEnum {
+
+ @Test
+ void exposesExactlyTheProxyableVerbsInOrder() {
+ assertEquals(List.of(HttpMethod.GET, HttpMethod.HEAD, HttpMethod.POST, HttpMethod.PUT, HttpMethod.PATCH,
+ HttpMethod.DELETE, HttpMethod.OPTIONS), List.of(HttpMethod.values()));
+ }
+
+ @Test
+ void doesNotRepresentTraceOrConnect() {
+ assertThrows(IllegalArgumentException.class, () -> HttpMethod.valueOf("TRACE"));
+ assertThrows(IllegalArgumentException.class, () -> HttpMethod.valueOf("CONNECT"));
+ }
+ }
+
+ @Nested
+ @DisplayName("Protocol enum contract")
+ class ProtocolEnum {
+
+ @Test
+ void exposesTheSupportedProtocols() {
+ assertEquals(List.of(Protocol.HTTP, Protocol.GRPC, Protocol.GRAPHQL, Protocol.WEBSOCKET),
+ List.of(Protocol.values()));
+ }
+ }
+
+ // --- Static factory ----------------------------------------------------
+
+ @Nested
+ @DisplayName("UpstreamDefaultsConfig static factory")
+ class UpstreamDefaultsFactory {
+
+ @Test
+ void defaultsEnableRetryAndNotModified() {
+ UpstreamDefaultsConfig defaults = UpstreamDefaultsConfig.defaults();
+ assertTrue(defaults.retryEnabled());
+ assertTrue(defaults.notModifiedEnabled());
+ assertEquals(new UpstreamDefaultsConfig(true, true), defaults);
+ }
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolverTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolverTest.java
new file mode 100644
index 0000000..a6de0f9
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolverTest.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.topology;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+
+import org.junit.jupiter.api.Test;
+
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+
+/**
+ * Tests for {@link EndpointEnablementResolver}: environment override precedence over
+ * the file {@code enabled} default, the id-to-variable-name derivation, and filtering
+ * to the enabled endpoints.
+ */
+class EndpointEnablementResolverTest {
+
+ private static EndpointConfig endpoint(String id, boolean enabled) {
+ return EndpointConfig.builder()
+ .id(id)
+ .enabled(enabled)
+ .baseUrl(id.toUpperCase(Locale.ROOT))
+ .auth(new AuthConfig("none", List.of()))
+ .build();
+ }
+
+ private static EndpointEnablementResolver resolverWith(Map environment) {
+ return new EndpointEnablementResolver(environment::get);
+ }
+
+ @Test
+ void environmentOverrideDisablesAFileEnabledEndpoint() {
+ EndpointEnablementResolver resolver = resolverWith(Map.of("ENDPOINT_ORDERS_ENABLED", "false"));
+ assertFalse(resolver.isEnabled(endpoint("orders", true)));
+ }
+
+ @Test
+ void environmentOverrideEnablesAFileDisabledEndpoint() {
+ EndpointEnablementResolver resolver = resolverWith(Map.of("ENDPOINT_ORDERS_ENABLED", "true"));
+ assertTrue(resolver.isEnabled(endpoint("orders", false)));
+ }
+
+ @Test
+ void fallsBackToFileValueWhenNoOverride() {
+ EndpointEnablementResolver resolver = resolverWith(Map.of());
+ assertTrue(resolver.isEnabled(endpoint("orders", true)));
+ assertFalse(resolver.isEnabled(endpoint("orders", false)));
+ }
+
+ @Test
+ void derivesEnvironmentVariableNameFromEndpointId() {
+ assertEquals("ENDPOINT_ORDER_API_ENABLED", EndpointEnablementResolver.environmentVariableName("order-api"));
+ assertEquals("ENDPOINT_ORDERS_ENABLED", EndpointEnablementResolver.environmentVariableName("orders"));
+ }
+
+ @Test
+ void filtersDownToEnabledEndpoints() {
+ EndpointEnablementResolver resolver = resolverWith(Map.of("ENDPOINT_USERS_ENABLED", "false"));
+ List enabled = resolver.enabledEndpoints(List.of(
+ endpoint("orders", true),
+ endpoint("users", true),
+ endpoint("legacy", false)));
+ assertEquals(List.of("orders"), enabled.stream().map(EndpointConfig::id).toList());
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java
new file mode 100644
index 0000000..e76a84f
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.topology;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Stream;
+
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.ResolvedTopology;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.config.topology.TopologyResolver.TopologyResolutionException;
+
+/**
+ * Tests for {@link TopologyResolver}: file / environment resolution precedence, URL
+ * decomposition with scheme-default ports, and the boot failures for malformed or
+ * missing aliases referenced by enabled endpoints.
+ */
+class TopologyResolverTest {
+
+ @TempDir
+ Path directory;
+
+ private static EndpointConfig endpointFor(String alias) {
+ return EndpointConfig.builder()
+ .id(alias.toLowerCase(Locale.ROOT))
+ .enabled(true)
+ .baseUrl(alias)
+ .auth(new AuthConfig("none", List.of()))
+ .build();
+ }
+
+ private Path topologyFile(String content) throws IOException {
+ Path file = directory.resolve("topology.properties");
+ Files.writeString(file, content);
+ return file;
+ }
+
+ private static TopologyResolver resolverWith(Map environment) {
+ return new TopologyResolver(environment::get);
+ }
+
+ @Test
+ void resolvesFromFileAndDecomposesComponents() throws Exception {
+ Path file = topologyFile("ORDERS=https://orders.internal:8443/api\n");
+
+ ResolvedTopology topology = resolverWith(Map.of()).resolve(file, List.of(endpointFor("ORDERS")));
+
+ ResolvedUpstream upstream = topology.lookup("ORDERS").orElseThrow();
+ assertEquals("https", upstream.scheme());
+ assertEquals("orders.internal", upstream.host());
+ assertEquals(8443, upstream.port());
+ assertEquals("/api", upstream.basePath());
+ }
+
+ @Test
+ void defaultsPortFromSchemeWhenAbsent() throws Exception {
+ Path file = topologyFile("USERS=https://users.internal\nORDERS=http://orders.internal\n");
+
+ ResolvedTopology topology = resolverWith(Map.of())
+ .resolve(file, List.of(endpointFor("USERS"), endpointFor("ORDERS")));
+
+ assertEquals(443, topology.lookup("USERS").orElseThrow().port());
+ assertEquals(80, topology.lookup("ORDERS").orElseThrow().port());
+ assertEquals("", topology.lookup("USERS").orElseThrow().basePath());
+ }
+
+ @Test
+ void environmentOverrideWinsOverFile() throws Exception {
+ Path file = topologyFile("ORDERS=https://file.host:1000/file\n");
+
+ ResolvedTopology topology = resolverWith(Map.of("TOPOLOGY_ORDERS", "https://env.host:2000/env"))
+ .resolve(file, List.of(endpointFor("ORDERS")));
+
+ ResolvedUpstream upstream = topology.lookup("ORDERS").orElseThrow();
+ assertEquals("env.host", upstream.host());
+ assertEquals(2000, upstream.port());
+ }
+
+ @Test
+ void trimsSurroundingWhitespaceBeforeParsing() throws Exception {
+ Path file = topologyFile("ORDERS= https://orders.internal:8443/api \n");
+
+ ResolvedTopology topology = resolverWith(Map.of()).resolve(file, List.of(endpointFor("ORDERS")));
+
+ ResolvedUpstream upstream = topology.lookup("ORDERS").orElseThrow();
+ assertEquals("orders.internal", upstream.host());
+ assertEquals(8443, upstream.port());
+ assertEquals("/api", upstream.basePath());
+ }
+
+ @ParameterizedTest(name = "rejects {1}")
+ @MethodSource("invalidTopologies")
+ void rejectsInvalidTopology(String topologyContent, String scenario, String endpointAlias) throws Exception {
+ Path file = topologyFile(topologyContent);
+ TopologyResolver resolver = resolverWith(Map.of());
+ List endpoints = List.of(endpointFor(endpointAlias));
+
+ assertThrows(TopologyResolutionException.class,
+ () -> resolver.resolve(file, endpoints));
+ }
+
+ static Stream invalidTopologies() {
+ return Stream.of(
+ Arguments.of("ORDERS=http://orders internal:8443\n", "malformed url", "ORDERS"),
+ Arguments.of("ORDERS=orders.internal:8443\n", "url without scheme or host", "ORDERS"),
+ Arguments.of("OTHER=https://other.internal\n", "missing alias referenced by enabled endpoint",
+ "MISSING"));
+ }
+
+ @Test
+ void resolvesNothingWhenNoEnabledEndpointsAndToleratesAbsentFile() {
+ Path absent = directory.resolve("does-not-exist.properties");
+
+ ResolvedTopology topology = resolverWith(Map.of()).resolve(absent, List.of());
+
+ assertTrue(topology.aliases().isEmpty());
+ }
+}
diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java
new file mode 100644
index 0000000..5926321
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java
@@ -0,0 +1,408 @@
+/*
+ * Copyright © 2022 CUI-OpenSource-Software (info@cuioss.de)
+ *
+ * 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 de.cuioss.sheriff.api.config.validation;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import de.cuioss.sheriff.api.config.load.ConfigError;
+import de.cuioss.sheriff.api.config.model.AuthConfig;
+import de.cuioss.sheriff.api.config.model.EndpointConfig;
+import de.cuioss.sheriff.api.config.model.ForwardedConfig;
+import de.cuioss.sheriff.api.config.model.GatewayConfig;
+import de.cuioss.sheriff.api.config.model.HttpMethod;
+import de.cuioss.sheriff.api.config.model.IssuerConfig;
+import de.cuioss.sheriff.api.config.model.MatchConfig;
+import de.cuioss.sheriff.api.config.model.OidcConfig;
+import de.cuioss.sheriff.api.config.model.ResolvedTopology;
+import de.cuioss.sheriff.api.config.model.ResolvedUpstream;
+import de.cuioss.sheriff.api.config.model.RouteConfig;
+import de.cuioss.sheriff.api.config.model.SecurityHeadersConfig;
+import de.cuioss.sheriff.api.config.model.TokenValidationConfig;
+import de.cuioss.sheriff.api.config.model.UpstreamConfig;
+
+/**
+ * Tests for {@link ConfigValidator}: one negative case per enforced cross-cutting
+ * rule, the structural {@code TRACE}/{@code CONNECT} rejection, and the
+ * single-pass aggregation contract that reports every violation together rather
+ * than stopping at the first.
+ */
+class ConfigValidatorTest {
+
+ private final ConfigValidator validator = new ConfigValidator();
+
+ // --- fixture helpers -------------------------------------------------
+
+ private static GatewayConfig.GatewayConfigBuilder validGateway() {
+ return GatewayConfig.builder().version(1);
+ }
+
+ private static ResolvedTopology topologyWith(String... aliases) {
+ Map map = new HashMap<>();
+ for (String alias : aliases) {
+ map.put(alias, new ResolvedUpstream("https", alias.toLowerCase(Locale.ROOT) + ".internal", 443, ""));
+ }
+ return new ResolvedTopology(map);
+ }
+
+ private static MatchConfig match(String pathPrefix, HttpMethod... methods) {
+ return MatchConfig.builder().pathPrefix(pathPrefix).methods(List.of(methods)).build();
+ }
+
+ private static RouteConfig route(String id, HttpMethod... methods) {
+ return RouteConfig.builder().id(id).match(match("/" + id, methods)).build();
+ }
+
+ private static EndpointConfig endpoint(String id, String alias, List allowedMethods,
+ RouteConfig... routes) {
+ return EndpointConfig.builder()
+ .id(id)
+ .enabled(true)
+ .baseUrl(alias)
+ .auth(new AuthConfig("none", List.of()))
+ .allowedMethods(allowedMethods)
+ .routes(List.of(routes))
+ .build();
+ }
+
+ private static void assertHasError(List errors, String pointerContains, String messageContains) {
+ assertTrue(errors.stream()
+ .anyMatch(e -> e.pointer().contains(pointerContains) && e.message().contains(messageContains)),
+ () -> "expected an error whose pointer contains '" + pointerContains + "' and message contains '"
+ + messageContains + "', but got: " + errors);
+ }
+
+ @Nested
+ @DisplayName("A well-formed configuration")
+ class ValidConfiguration {
+
+ @Test
+ @DisplayName("Should report no violations")
+ void shouldReportNoViolations() {
+ GatewayConfig gateway = validGateway().build();
+ EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("orders-list", HttpMethod.GET));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS"));
+
+ assertTrue(errors.isEmpty(), () -> "expected no violations, got: " + errors);
+ }
+ }
+
+ @Nested
+ @DisplayName("Each enforced rule")
+ class RuleViolations {
+
+ @Test
+ @DisplayName("Should reject an unsupported config version")
+ void shouldRejectUnsupportedVersion() {
+ GatewayConfig gateway = GatewayConfig.builder().version(2).build();
+ EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET));
+
+ List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS"));
+
+ assertHasError(errors, "/version", "unsupported config version");
+ }
+
+ @Test
+ @DisplayName("Should reject a duplicate endpoint id across endpoint files")
+ void shouldRejectDuplicateEndpointId() {
+ EndpointConfig first = endpoint("orders", "ORDERS", List.of(), route("r1", HttpMethod.GET));
+ EndpointConfig second = endpoint("orders", "USERS", List.of(), route("r2", HttpMethod.GET));
+
+ List errors = validator.validate(validGateway().build(), List.of(first, second),
+ topologyWith("ORDERS", "USERS"));
+
+ assertHasError(errors, "/endpoint/id", "duplicate endpoint id: orders");
+ }
+
+ @Test
+ @DisplayName("Should reject a duplicate route id across endpoint files")
+ void shouldRejectDuplicateRouteId() {
+ EndpointConfig first = endpoint("ep-a", "ORDERS", List.of(), route("shared", HttpMethod.GET));
+ EndpointConfig second = endpoint("ep-b", "USERS", List.of(), route("shared", HttpMethod.GET));
+
+ List errors = validator.validate(validGateway().build(), List.of(first, second),
+ topologyWith("ORDERS", "USERS"));
+
+ assertHasError(errors, "/endpoint/routes", "duplicate route id: shared");
+ }
+
+ @Test
+ @DisplayName("Should reject an enabled endpoint whose base_url alias does not resolve")
+ void shouldRejectUnresolvedAliasForEnabledEndpoint() {
+ EndpointConfig endpoint = endpoint("orders", "MISSING", List.of(), route("r", HttpMethod.GET));
+
+ List errors = validator.validate(validGateway().build(), List.of(endpoint),
+ topologyWith("ORDERS"));
+
+ assertHasError(errors, "/endpoint/base_url", "unresolved topology alias: MISSING");
+ }
+
+ @Test
+ @DisplayName("Should reject effective auth 'bearer' without a token_validation issuer")
+ void shouldRejectBearerWithoutIssuer() {
+ EndpointConfig endpoint = EndpointConfig.builder()
+ .id("orders").enabled(true).baseUrl("ORDERS")
+ .auth(new AuthConfig("bearer", List.of()))
+ .routes(List.of(route("r", HttpMethod.GET)))
+ .build();
+
+ List errors = validator.validate(validGateway().build(), List.of(endpoint),
+ topologyWith("ORDERS"));
+
+ assertHasError(errors, "/token_validation", "requires token_validation with at least one issuer");
+ }
+
+ @Test
+ @DisplayName("Should reject effective auth 'session' without an oidc block")
+ void shouldRejectSessionWithoutOidc() {
+ EndpointConfig endpoint = EndpointConfig.builder()
+ .id("orders").enabled(true).baseUrl("ORDERS")
+ .auth(new AuthConfig("session", List.of()))
+ .routes(List.of(route("r", HttpMethod.GET)))
+ .build();
+
+ List errors = validator.validate(validGateway().build(), List.of(endpoint),
+ topologyWith("ORDERS"));
+
+ assertHasError(errors, "/oidc", "requires an oidc block");
+ }
+
+ @Test
+ @DisplayName("Should reject a route matching a method outside the effective allowed_methods")
+ void shouldRejectMethodOutsideEffectiveAllowedMethods() {
+ EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(HttpMethod.GET),
+ route("orders-post", HttpMethod.POST));
+
+ List errors = validator.validate(validGateway().build(), List.of(endpoint),
+ topologyWith("ORDERS"));
+
+ assertHasError(errors, "/endpoint/routes", "outside the effective allowed_methods");
+ }
+
+ @Test
+ @DisplayName("Should reject a non whole-second upstream timeout")
+ void shouldRejectNonWholeSecondTimeout() {
+ RouteConfig route = RouteConfig.builder()
+ .id("r").match(match("/r", HttpMethod.GET))
+ .upstream(Optional.of(UpstreamConfig.builder().connectTimeoutMs(Optional.of(1500)).build()))
+ .build();
+ EndpointConfig endpoint = EndpointConfig.builder()
+ .id("orders").enabled(true).baseUrl("ORDERS")
+ .auth(new AuthConfig("none", List.of()))
+ .routes(List.of(route))
+ .build();
+
+ List errors = validator.validate(validGateway().build(), List.of(endpoint),
+ topologyWith("ORDERS"));
+
+ assertHasError(errors, "connect_timeout_ms", "must be a whole-second multiple");
+ }
+
+ @Test
+ @DisplayName("Should reject a non-positive upstream timeout")
+ void shouldRejectNonPositiveTimeout() {
+ RouteConfig route = RouteConfig.builder()
+ .id("r").match(match("/r", HttpMethod.GET))
+ .upstream(Optional.of(UpstreamConfig.builder().readTimeoutMs(Optional.of(0)).build()))
+ .build();
+ EndpointConfig endpoint = EndpointConfig.builder()
+ .id("orders").enabled(true).baseUrl("ORDERS")
+ .auth(new AuthConfig("none", List.of()))
+ .routes(List.of(route))
+ .build();
+
+ List errors = validator.validate(validGateway().build(), List.of(endpoint),
+ topologyWith("ORDERS"));
+
+ assertHasError(errors, "read_timeout_ms", "must be a positive whole-second multiple");
+ }
+
+ @Test
+ @DisplayName("Should reject a negative upstream timeout")
+ void shouldRejectNegativeTimeout() {
+ RouteConfig route = RouteConfig.builder()
+ .id("r").match(match("/r", HttpMethod.GET))
+ .upstream(Optional.of(UpstreamConfig.builder().connectTimeoutMs(Optional.of(-1000)).build()))
+ .build();
+ EndpointConfig endpoint = EndpointConfig.builder()
+ .id("orders").enabled(true).baseUrl("ORDERS")
+ .auth(new AuthConfig("none", List.of()))
+ .routes(List.of(route))
+ .build();
+
+ List errors = validator.validate(validGateway().build(), List.of(endpoint),
+ topologyWith("ORDERS"));
+
+ assertHasError(errors, "connect_timeout_ms", "must be a positive whole-second multiple");
+ }
+
+ @Test
+ @DisplayName("Should accept a positive whole-second upstream timeout")
+ void shouldAcceptPositiveWholeSecondTimeout() {
+ RouteConfig route = RouteConfig.builder()
+ .id("r").match(match("/r", HttpMethod.GET))
+ .upstream(Optional.of(UpstreamConfig.builder().readTimeoutMs(Optional.of(2000)).build()))
+ .build();
+ EndpointConfig endpoint = EndpointConfig.builder()
+ .id("orders").enabled(true).baseUrl("ORDERS")
+ .auth(new AuthConfig("none", List.of()))
+ .routes(List.of(route))
+ .build();
+
+ List errors = validator.validate(validGateway().build(), List.of(endpoint),
+ topologyWith("ORDERS"));
+
+ assertTrue(errors.isEmpty(), () -> "expected no violations for a positive whole-second timeout, got: "
+ + errors);
+ }
+
+ @Test
+ @DisplayName("Should reject a trust-all CIDR in forwarded.trusted_proxies")
+ void shouldRejectTrustAllCidr() {
+ GatewayConfig gateway = validGateway()
+ .forwarded(Optional.of(ForwardedConfig.builder().trustedProxies(List.of("0.0.0.0/0")).build()))
+ .build();
+ EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET));
+
+ List