Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9f32f10
chore(deps): bump cui-java-parent to 1.5.1 and add config parsing dep…
cuioss-oliver Jul 13, 2026
5c96092
feat(config): add immutable configuration model records with contract…
cuioss-oliver Jul 13, 2026
3fae1ec
style(config): apply spotless formatting to configuration model records
cuioss-oliver Jul 13, 2026
070b8b2
feat(config): add JSON Schemas for gateway.yaml and endpoints/*.yaml
cuioss-oliver Jul 13, 2026
2758bd7
feat(config): add YAML loader with schema validation and record binding
cuioss-oliver Jul 13, 2026
108d0c7
feat(config): add endpoint-enablement and topology resolution
cuioss-oliver Jul 13, 2026
e36fdf3
feat(config): add cross-cutting ConfigValidator with rule interface a…
cuioss-oliver Jul 13, 2026
bc6b67c
feat(config): materialize RouteTable with effective auth, allowed_met…
cuioss-oliver Jul 13, 2026
b3116ca
feat(config): wire ConfigProducer with fail-fast startup and CONFIG_L…
cuioss-oliver Jul 13, 2026
bb268e6
feat(proxy): rewire proxy edge to consume RouteTable; remove ProxyCon…
cuioss-oliver Jul 13, 2026
7fc3752
test(config): mount file-based config into integration containers
cuioss-oliver Jul 13, 2026
3e9d72f
docs(config): add Schemas section, upstream_defaults reference, confi…
cuioss-oliver Jul 13, 2026
3baf4e7
docs(plan): update cui-http 2.1 refs, relocate Plan 02, sweep dead links
cuioss-oliver Jul 13, 2026
b380e30
chore(format): apply pre-commit import and throws normalizations
cuioss-oliver Jul 13, 2026
1527be4
fix(security): redact OIDC secrets in config model toString()
cuioss-oliver Jul 13, 2026
a99055a
fix(config): register schemas + config model for native image
cuioss-oliver Jul 13, 2026
5f0dc48
fix(config): guard Optional access in header-distinguish check
cuioss-oliver Jul 13, 2026
b0413d9
fix(config): use requireNonNullElse for Optional normalization (Sonar…
cuioss-oliver Jul 13, 2026
bf48e01
fix(config): address review findings (prefix match, header disjointne…
cuioss-oliver Jul 13, 2026
cd6cc23
refactor(config): resolve remaining Sonar maintainability findings
cuioss-oliver Jul 13, 2026
89bddf1
test(config): parameterize topology-rejection tests (Sonar S5976)
cuioss-oliver Jul 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions api-sheriff/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@
<artifactId>cui-java-tools</artifactId>
</dependency>

<!-- Configuration management: YAML parsing + boot-time JSON Schema validation -->
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
</dependency>

<!-- Quarkus dependencies -->
<dependency>
<groupId>io.quarkus</groupId>
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* <p>
* 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();
}
}
Original file line number Diff line number Diff line change
@@ -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).
* <p>
* The builder merges the routes of the <em>enabled endpoints only</em> — 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.
* <p>
* 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<HttpMethod> 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<EndpointConfig> endpoints, ResolvedTopology topology) {
Objects.requireNonNull(gateway, "gateway");
Objects.requireNonNull(endpoints, "endpoints");
Objects.requireNonNull(topology, "topology");

List<ResolvedRoute> 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<HttpMethod> 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<HttpMethod> 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<HttpMethod> 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<ResolvedRoute> 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;
}
Comment thread
cuioss-oliver marked this conversation as resolved.

private static boolean valuesDistinguish(HeaderMatcher headerA, HeaderMatcher headerB) {
Optional<String> valueA = headerA.value();
Optional<String> valueB = headerB.value();
return valueA.isPresent() && valueB.isPresent() && !valueA.get().equals(valueB.get());
}

private static boolean presenceDistinguishes(HeaderMatcher headerA, HeaderMatcher headerB) {
Optional<Boolean> presentA = headerA.present();
Optional<Boolean> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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");
}
}
Loading
Loading