-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat(config): implement configuration management subsystem #60
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
cuioss-oliver
merged 21 commits into
main
from
feature/implement-configuration-management
Jul 13, 2026
Merged
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 5c96092
feat(config): add immutable configuration model records with contract…
cuioss-oliver 3fae1ec
style(config): apply spotless formatting to configuration model records
cuioss-oliver 070b8b2
feat(config): add JSON Schemas for gateway.yaml and endpoints/*.yaml
cuioss-oliver 2758bd7
feat(config): add YAML loader with schema validation and record binding
cuioss-oliver 108d0c7
feat(config): add endpoint-enablement and topology resolution
cuioss-oliver e36fdf3
feat(config): add cross-cutting ConfigValidator with rule interface a…
cuioss-oliver bc6b67c
feat(config): materialize RouteTable with effective auth, allowed_met…
cuioss-oliver b3116ca
feat(config): wire ConfigProducer with fail-fast startup and CONFIG_L…
cuioss-oliver bb268e6
feat(proxy): rewire proxy edge to consume RouteTable; remove ProxyCon…
cuioss-oliver 7fc3752
test(config): mount file-based config into integration containers
cuioss-oliver 3e9d72f
docs(config): add Schemas section, upstream_defaults reference, confi…
cuioss-oliver 3baf4e7
docs(plan): update cui-http 2.1 refs, relocate Plan 02, sweep dead links
cuioss-oliver b380e30
chore(format): apply pre-commit import and throws normalizations
cuioss-oliver 1527be4
fix(security): redact OIDC secrets in config model toString()
cuioss-oliver a99055a
fix(config): register schemas + config model for native image
cuioss-oliver 5f0dc48
fix(config): guard Optional access in header-distinguish check
cuioss-oliver b0413d9
fix(config): use requireNonNullElse for Optional normalization (Sonar…
cuioss-oliver bf48e01
fix(config): address review findings (prefix match, header disjointne…
cuioss-oliver cd6cc23
refactor(config): resolve remaining Sonar maintainability findings
cuioss-oliver 89bddf1
test(config): parameterize topology-rejection tests (Sonar S5976)
cuioss-oliver File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
76 changes: 76 additions & 0 deletions
76
api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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(); | ||
| } | ||
| } |
211 changes: 211 additions & 0 deletions
211
api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } | ||
|
|
||
| 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); | ||
| } | ||
| } | ||
| } | ||
51 changes: 51 additions & 0 deletions
51
api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigError.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.