From 9f32f10e1943efcdc0c95a3e619bfe5205eb19cb Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:15:34 +0200 Subject: [PATCH 01/21] chore(deps): bump cui-java-parent to 1.5.1 and add config parsing dependencies Bump the parent POM to 1.5.1 and add the YAML/JSON-Schema config parsing and validation dependencies required by the configuration-management feature (deliverable 1). Co-Authored-By: Claude --- api-sheriff/pom.xml | 10 ++++++++++ pom.xml | 10 +++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/api-sheriff/pom.xml b/api-sheriff/pom.xml index ba59ebc..a332555 100644 --- a/api-sheriff/pom.xml +++ b/api-sheriff/pom.xml @@ -30,6 +30,16 @@ cui-java-tools + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + com.networknt + json-schema-validator + + io.quarkus diff --git a/pom.xml b/pom.xml index d40e2ca..66a0b81 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ de.cuioss cui-java-parent - 1.5.0 + 1.5.1 de.cuioss.sheriff.api @@ -44,6 +44,7 @@ 25 3.37.2 0.8.0 + 1.5.9 @@ -71,6 +72,13 @@ pom import + + + com.networknt + json-schema-validator + ${version.json-schema-validator} + de.cuioss.sheriff.api From 5c960921d826bbac69bf4844164db8b6b1cc42b6 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:16:06 +0200 Subject: [PATCH 02/21] feat(config): add immutable configuration model records with contract tests Introduce the immutable Java record hierarchy under de.cuioss.sheriff.api.config.model (GatewayConfig, EndpointConfig with id/enabled/allowedMethods, the HttpMethod enum excluding TRACE/CONNECT, and the supporting gateway/route/upstream records) as the framework-agnostic binding target for the YAML loader, plus the ConfigModelContractTest value-object contract suite covering equals/hashCode/toString, builder equivalence, null-normalization, defensive copying, mandatory-field enforcement, and the enum contracts (deliverable 3). Co-Authored-By: Claude --- .../sheriff/api/config/model/AuthConfig.java | 48 ++ .../api/config/model/EndpointConfig.java | 64 ++ .../api/config/model/ForwardConfig.java | 50 ++ .../api/config/model/ForwardedConfig.java | 52 ++ .../api/config/model/GatewayConfig.java | 68 +++ .../sheriff/api/config/model/HttpMethod.java | 45 ++ .../api/config/model/IssuerConfig.java | 70 +++ .../sheriff/api/config/model/MatchConfig.java | 72 +++ .../sheriff/api/config/model/Metadata.java | 39 ++ .../sheriff/api/config/model/OidcConfig.java | 187 ++++++ .../sheriff/api/config/model/Protocol.java | 39 ++ .../api/config/model/RateLimitConfig.java | 43 ++ .../sheriff/api/config/model/RouteConfig.java | 62 ++ .../config/model/SecurityDefaultsConfig.java | 41 ++ .../config/model/SecurityFilterConfig.java | 68 +++ .../config/model/SecurityHeadersConfig.java | 98 +++ .../sheriff/api/config/model/TlsConfig.java | 69 +++ .../config/model/TokenValidationConfig.java | 39 ++ .../api/config/model/UpstreamConfig.java | 118 ++++ .../config/model/UpstreamDefaultsConfig.java | 45 ++ .../api/config/model/package-info.java | 46 ++ .../config/model/ConfigModelContractTest.java | 556 ++++++++++++++++++ 22 files changed, 1919 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/AuthConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/EndpointConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardedConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/GatewayConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/HttpMethod.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/IssuerConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/MatchConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Metadata.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/OidcConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Protocol.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RateLimitConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityDefaultsConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityFilterConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityHeadersConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TlsConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TokenValidationConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamDefaultsConfig.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/package-info.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java 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..39c60c7 --- /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 { + require = 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..f287425 --- /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 { + id = Objects.requireNonNull(id, "id"); + baseUrl = Objects.requireNonNull(baseUrl, "baseUrl"); + auth = Objects.requireNonNull(auth, "auth"); + allowedMethods = allowedMethods == null ? List.of() : List.copyOf(allowedMethods); + upstreamDefaults = upstreamDefaults == null ? Optional.empty() : upstreamDefaults; + 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..08116b7 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardedConfig.java @@ -0,0 +1,52 @@ +/* + * 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.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 = trustSchemeHost == null ? Optional.empty() : trustSchemeHost; + emit = emit == null ? Optional.empty() : emit; + } +} 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..c63fe2d --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/GatewayConfig.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.model; + +import java.util.List; +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 = metadata == null ? Optional.empty() : metadata; + tls = tls == null ? Optional.empty() : tls; + securityHeaders = securityHeaders == null ? Optional.empty() : securityHeaders; + securityDefaults = securityDefaults == null ? Optional.empty() : securityDefaults; + allowedMethods = allowedMethods == null ? List.of() : List.copyOf(allowedMethods); + upstreamDefaults = upstreamDefaults == null ? Optional.empty() : upstreamDefaults; + forwarded = forwarded == null ? Optional.empty() : forwarded; + tokenValidation = tokenValidation == null ? Optional.empty() : tokenValidation; + oidc = oidc == null ? Optional.empty() : oidc; + } +} 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..3135cc5 --- /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 { + name = Objects.requireNonNull(name, "name"); + issuer = Objects.requireNonNull(issuer, "issuer"); + audience = audience == null ? Optional.empty() : audience; + jwks = jwks == null ? Optional.empty() : jwks; + } + + /** + * 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 { + source = Objects.requireNonNull(source, "source"); + url = url == null ? Optional.empty() : url; + file = file == null ? Optional.empty() : file; + } + } +} 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..c1be801 --- /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 { + pathPrefix = Objects.requireNonNull(pathPrefix, "pathPrefix"); + methods = methods == null ? List.of() : List.copyOf(methods); + host = host == null ? Optional.empty() : host; + 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 { + name = Objects.requireNonNull(name, "name"); + present = present == null ? Optional.empty() : present; + value = value == null ? Optional.empty() : value; + } + } +} 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..d504d09 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/Metadata.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.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 = configVersion == null ? Optional.empty() : configVersion; + } +} 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..d9da9e7 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/OidcConfig.java @@ -0,0 +1,187 @@ +/* + * 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.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 = issuer == null ? Optional.empty() : issuer; + clientId = clientId == null ? Optional.empty() : clientId; + clientSecret = clientSecret == null ? Optional.empty() : clientSecret; + scopes = scopes == null ? List.of() : List.copyOf(scopes); + redirectUri = redirectUri == null ? Optional.empty() : redirectUri; + logout = logout == null ? Optional.empty() : logout; + session = session == null ? Optional.empty() : session; + stepUp = stepUp == null ? Optional.empty() : stepUp; + } + + /** + * 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 = path == null ? Optional.empty() : path; + postLogoutRedirectUri = postLogoutRedirectUri == null ? Optional.empty() : postLogoutRedirectUri; + finalRedirect = finalRedirect == null ? Optional.empty() : finalRedirect; + backchannelPath = backchannelPath == null ? Optional.empty() : backchannelPath; + } + } + + /** + * 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 = mode == null ? Optional.empty() : mode; + store = store == null ? Optional.empty() : store; + cookieName = cookieName == null ? Optional.empty() : cookieName; + encryptionKey = encryptionKey == null ? Optional.empty() : encryptionKey; + previousKey = previousKey == null ? Optional.empty() : previousKey; + ttlSeconds = ttlSeconds == null ? Optional.empty() : ttlSeconds; + csrf = csrf == null ? Optional.empty() : csrf; + refresh = refresh == null ? Optional.empty() : 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 = enabled == null ? Optional.empty() : enabled; + leewaySeconds = leewaySeconds == null ? Optional.empty() : leewaySeconds; + onFailure = onFailure == null ? Optional.empty() : onFailure; + } + } + + /** + * 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 = enabled == null ? Optional.empty() : enabled; + honorUpstreamChallenge = honorUpstreamChallenge == null ? Optional.empty() : honorUpstreamChallenge; + } + } +} 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..70dffc6 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RateLimitConfig.java @@ -0,0 +1,43 @@ +/* + * 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.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 = requestsPerSecond == null ? Optional.empty() : requestsPerSecond; + burst = burst == null ? Optional.empty() : burst; + } +} 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..5bb2e66 --- /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 { + id = Objects.requireNonNull(id, "id"); + match = Objects.requireNonNull(match, "match"); + protocol = protocol == null ? Optional.empty() : protocol; + auth = auth == null ? Optional.empty() : auth; + securityFilter = securityFilter == null ? Optional.empty() : securityFilter; + forward = forward == null ? Optional.empty() : forward; + upstream = upstream == null ? Optional.empty() : upstream; + rateLimit = rateLimit == null ? Optional.empty() : rateLimit; + } +} 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..4080c8b --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityDefaultsConfig.java @@ -0,0 +1,41 @@ +/* + * 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.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 = profile == null ? Optional.empty() : profile; + } +} 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..0f416e4 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityFilterConfig.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.model; + +import java.util.List; +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 = profile == null ? Optional.empty() : profile; + allowedPaths = allowedPaths == null ? List.of() : List.copyOf(allowedPaths); + maxHeaderCount = maxHeaderCount == null ? Optional.empty() : maxHeaderCount; + maxHeaderValueLength = maxHeaderValueLength == null ? Optional.empty() : maxHeaderValueLength; + maxQueryParams = maxQueryParams == null ? Optional.empty() : maxQueryParams; + maxParamValueLength = maxParamValueLength == null ? Optional.empty() : maxParamValueLength; + maxBodyBytes = maxBodyBytes == null ? Optional.empty() : maxBodyBytes; + 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..68e9b51 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityHeadersConfig.java @@ -0,0 +1,98 @@ +/* + * 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.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 = hsts == null ? Optional.empty() : hsts; + contentTypeNosniff = contentTypeNosniff == null ? Optional.empty() : contentTypeNosniff; + frameDeny = frameDeny == null ? Optional.empty() : frameDeny; + cors = cors == null ? Optional.empty() : cors; + } + + /** + * {@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 = maxAge == null ? Optional.empty() : maxAge; + includeSubdomains = includeSubdomains == null ? Optional.empty() : includeSubdomains; + } + } + + /** + * 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 = enabled == null ? Optional.empty() : enabled; + 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 = allowCredentials == null ? Optional.empty() : allowCredentials; + } + } +} 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..4b5b4e6 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/TlsConfig.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.Map; +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 = minVersion == null ? Optional.empty() : minVersion; + alpn = alpn == null ? List.of() : List.copyOf(alpn); + passthroughSni = passthroughSni == null ? Map.of() : Map.copyOf(passthroughSni); + mtls = mtls == null ? Optional.empty() : mtls; + } + + /** + * 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 = clientCa == null ? Optional.empty() : clientCa; + } + } +} 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..b86f417 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/UpstreamConfig.java @@ -0,0 +1,118 @@ +/* + * 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.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 = path == null ? Optional.empty() : path; + connectTimeoutMs = connectTimeoutMs == null ? Optional.empty() : connectTimeoutMs; + readTimeoutMs = readTimeoutMs == null ? Optional.empty() : readTimeoutMs; + retry = retry == null ? Optional.empty() : retry; + notModified = notModified == null ? Optional.empty() : notModified; + circuitBreaker = circuitBreaker == null ? Optional.empty() : circuitBreaker; + } + + /** + * 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 = enabled == null ? Optional.empty() : enabled; + maxAttempts = maxAttempts == null ? Optional.empty() : maxAttempts; + idempotentOnly = idempotentOnly == null ? Optional.empty() : idempotentOnly; + } + } + + /** + * 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 = enabled == null ? Optional.empty() : enabled; + } + } + + /** + * 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 = failures == null ? Optional.empty() : failures; + resetMs = resetMs == null ? Optional.empty() : resetMs; + } + } +} 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/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..214dd9d --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/model/ConfigModelContractTest.java @@ -0,0 +1,556 @@ +/* + * 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.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Named; +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(subject, null, "a value object is never equal to null"); + assertNotEquals(subject, "not-a-config", "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); + } + } +} From 3fae1ec31e2a5000b0b0c9fbc8ec454d880bdc61 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:21:17 +0200 Subject: [PATCH 03/21] style(config): apply spotless formatting to configuration model records Align multi-line record-component continuation lines and normalize import ordering in the configuration model records and ConfigModelContractTest as enforced by the pre-commit spotless profile. Co-Authored-By: Claude --- .../sheriff/api/config/model/EndpointConfig.java | 2 +- .../cuioss/sheriff/api/config/model/GatewayConfig.java | 6 +++--- .../cuioss/sheriff/api/config/model/MatchConfig.java | 2 +- .../de/cuioss/sheriff/api/config/model/OidcConfig.java | 10 +++++----- .../cuioss/sheriff/api/config/model/RouteConfig.java | 4 ++-- .../sheriff/api/config/model/SecurityFilterConfig.java | 6 +++--- .../api/config/model/SecurityHeadersConfig.java | 4 ++-- .../de/cuioss/sheriff/api/config/model/TlsConfig.java | 2 +- .../sheriff/api/config/model/UpstreamConfig.java | 2 +- .../api/config/model/ConfigModelContractTest.java | 7 ++++--- 10 files changed, 23 insertions(+), 22 deletions(-) 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 index f287425..900dc31 100644 --- 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 @@ -47,7 +47,7 @@ */ @Builder public record EndpointConfig(String id, boolean enabled, String baseUrl, AuthConfig auth, - List allowedMethods, Optional upstreamDefaults, List routes) { + List allowedMethods, Optional upstreamDefaults, List routes) { /** * Canonical constructor requiring the mandatory fields, defensively copying the 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 index c63fe2d..3562d36 100644 --- 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 @@ -46,9 +46,9 @@ */ @Builder public record GatewayConfig(int version, Optional metadata, Optional tls, - Optional securityHeaders, Optional securityDefaults, - List allowedMethods, Optional upstreamDefaults, - Optional forwarded, Optional tokenValidation, Optional oidc) { + Optional securityHeaders, Optional securityDefaults, + List allowedMethods, Optional upstreamDefaults, + Optional forwarded, Optional tokenValidation, Optional oidc) { /** * Canonical constructor defensively copying {@code allowedMethods} and 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 index c1be801..ab5ffb4 100644 --- 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 @@ -34,7 +34,7 @@ */ @Builder public record MatchConfig(String pathPrefix, List methods, Optional host, - List headers) { + List headers) { /** * Canonical constructor requiring {@code pathPrefix}, defensively copying the 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 index d9da9e7..83df335 100644 --- 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 @@ -39,8 +39,8 @@ */ @Builder public record OidcConfig(Optional issuer, Optional clientId, Optional clientSecret, - List scopes, Optional redirectUri, Optional logout, Optional session, - Optional stepUp) { + List scopes, Optional redirectUri, Optional logout, Optional session, + Optional stepUp) { /** * Canonical constructor defensively copying {@code scopes} and normalizing @@ -71,7 +71,7 @@ public record OidcConfig(Optional issuer, Optional clientId, Opt */ @Builder public record Logout(Optional path, Optional postLogoutRedirectUri, Optional finalRedirect, - Optional backchannelPath) { + Optional backchannelPath) { /** * Canonical constructor normalizing absent components to {@link Optional#empty()}. @@ -104,8 +104,8 @@ public record Logout(Optional path, Optional postLogoutRedirectU */ @Builder public record Session(Optional mode, Optional store, Optional cookieName, - Optional encryptionKey, Optional previousKey, Optional ttlSeconds, - Optional csrf, Optional refresh) { + Optional encryptionKey, Optional previousKey, Optional ttlSeconds, + Optional csrf, Optional refresh) { /** * Canonical constructor normalizing absent components to {@link Optional#empty()}. 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 index 5bb2e66..9959a8a 100644 --- 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 @@ -42,8 +42,8 @@ */ @Builder public record RouteConfig(String id, Optional protocol, MatchConfig match, Optional auth, - Optional securityFilter, Optional forward, Optional upstream, - Optional rateLimit) { + Optional securityFilter, Optional forward, Optional upstream, + Optional rateLimit) { /** * Canonical constructor requiring {@code id} and {@code match} and normalizing 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 index 0f416e4..682e248 100644 --- 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 @@ -45,9 +45,9 @@ */ @Builder public record SecurityFilterConfig(Optional profile, List allowedPaths, Optional maxHeaderCount, - Optional maxHeaderValueLength, Optional maxQueryParams, Optional maxParamValueLength, - Optional maxBodyBytes, List allowedHeaderNames, List blockedHeaderNames, - List allowedContentTypes) { + Optional maxHeaderValueLength, Optional maxQueryParams, Optional maxParamValueLength, + Optional maxBodyBytes, List allowedHeaderNames, List blockedHeaderNames, + List allowedContentTypes) { /** * Canonical constructor defensively copying collections and normalizing absent 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 index 68e9b51..51e8edd 100644 --- 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 @@ -35,7 +35,7 @@ */ @Builder public record SecurityHeadersConfig(Optional hsts, Optional contentTypeNosniff, - Optional frameDeny, Optional cors) { + Optional frameDeny, Optional cors) { /** * Canonical constructor normalizing absent components to {@link Optional#empty()}. @@ -81,7 +81,7 @@ public record Hsts(Optional maxAge, Optional includeSubdomains */ @Builder public record Cors(Optional enabled, List allowedOrigins, List allowedMethods, - List allowedHeaders, Optional allowCredentials) { + List allowedHeaders, Optional allowCredentials) { /** * Canonical constructor defensively copying collections and normalizing 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 index 4b5b4e6..8ed5b88 100644 --- 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 @@ -34,7 +34,7 @@ */ @Builder public record TlsConfig(Optional minVersion, List alpn, Map passthroughSni, - Optional mtls) { + Optional mtls) { /** * Canonical constructor defensively copying collections and normalizing absent 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 index b86f417..e1745e2 100644 --- 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 @@ -39,7 +39,7 @@ */ @Builder public record UpstreamConfig(Optional path, Optional connectTimeoutMs, Optional readTimeoutMs, - Optional retry, Optional notModified, Optional circuitBreaker) { + Optional retry, Optional notModified, Optional circuitBreaker) { /** * Canonical constructor normalizing absent components to {@link Optional#empty()}. 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 index 214dd9d..f0836f9 100644 --- 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 @@ -29,10 +29,11 @@ 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.api.Named; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -302,8 +303,8 @@ void shouldHonorValueObjectContract(Object subject, Object equalCopy, Object dif 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(subject, null, "a value object is never equal to null"); - assertNotEquals(subject, "not-a-config", "a value object is never equal to a foreign type"); + 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"); } } From 070b8b25414af706ccd5c47193a8d977d794bd96 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:42:32 +0200 Subject: [PATCH 04/21] feat(config): add JSON Schemas for gateway.yaml and endpoints/*.yaml Add the draft 2020-12 JSON Schemas that mirror the configuration reference field-for-field and reject unknown keys at every object level (additionalProperties:false), forming the structural-validation contract the YAML loader enforces at boot (deliverable 2). Co-Authored-By: Claude --- .../resources/schema/endpoint.schema.json | 180 +++++++++++++++ .../main/resources/schema/gateway.schema.json | 207 ++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 api-sheriff/src/main/resources/schema/endpoint.schema.json create mode 100644 api-sheriff/src/main/resources/schema/gateway.schema.json 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..b3204a4 --- /dev/null +++ b/api-sheriff/src/main/resources/schema/gateway.schema.json @@ -0,0 +1,207 @@ +{ + "$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, + "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" } + } + } + } + } + } +} From 2758bd7550bc8b4692011de3f8c86195d1b10092 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:43:03 +0200 Subject: [PATCH 05/21] feat(config): add YAML loader with schema validation and record binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the framework-agnostic boot loader under de.cuioss.sheriff.api.config.load: ConfigLoader reads gateway.yaml and endpoints/*.yaml (sorted), validates each document against the D2 JSON Schemas via networknt, resolves ${ENV_VAR} secret references through EnvSecretResolver, and binds the valid trees to the immutable D3 model records with Jackson (snake_case mapping, case-insensitive enums, and a custom deserializer flattening the nested upstream_defaults block). Every schema, secret, and binding problem is aggregated with file + JSON-pointer context and raised together as ConfigLoadException — never failing on the first. Includes ConfigError, ConfigLoadException, the loader/resolver unit tests, and valid / invalid configuration fixtures (deliverable 4). The endpoint-enablement, topology-alias, cross-cutting validation, and route-table steps of the full boot sequence are layered on by later deliverables. Co-Authored-By: Claude --- .../sheriff/api/config/load/ConfigError.java | 51 +++ .../api/config/load/ConfigLoadException.java | 68 ++++ .../sheriff/api/config/load/ConfigLoader.java | 318 ++++++++++++++++++ .../api/config/load/EnvSecretResolver.java | 126 +++++++ .../sheriff/api/config/load/package-info.java | 45 +++ .../api/config/load/ConfigLoaderTest.java | 181 ++++++++++ .../config/load/EnvSecretResolverTest.java | 84 +++++ .../resources/config/invalid/unknown-key.yaml | 6 + .../test/resources/config/valid/gateway.yaml | 38 +++ .../config/valid/topology.properties | 5 + 10 files changed, 922 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigError.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoadException.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/EnvSecretResolver.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/package-info.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/ConfigLoaderTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/api/config/load/EnvSecretResolverTest.java create mode 100644 api-sheriff/src/test/resources/config/invalid/unknown-key.yaml create mode 100644 api-sheriff/src/test/resources/config/valid/gateway.yaml create mode 100644 api-sheriff/src/test/resources/config/valid/topology.properties 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..6fa365b --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/load/ConfigLoader.java @@ -0,0 +1,318 @@ +/* + * 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.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)) { + String file = ENDPOINTS_DIR + "/" + path.getFileName(); + JsonNode root = readYaml(path, file, errors); + if (root == null) { + continue; + } + validate(endpointSchema, root, file, errors); + resolveSecrets(root, file, "", errors); + if (hasErrorsFor(file, errors)) { + continue; + } + JsonNode block = root.get(ENDPOINT_ROOT); + applyEnabledDefault(block); + EndpointConfig endpoint = bind(block, EndpointConfig.class, file, errors); + if (endpoint != null) { + endpoints.add(endpoint); + } + } + return endpoints; + } + + 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()); + return YAMLMapper.builder() + .enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS) + .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .findAndAddModules() + .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"); + 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..da24009 --- /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.Function; +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_][A-Za-z0-9_]*)}"); + + private final Function 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(Function 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/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/resources/config/invalid/unknown-key.yaml b/api-sheriff/src/test/resources/config/invalid/unknown-key.yaml new file mode 100644 index 0000000..5dafb13 --- /dev/null +++ b/api-sheriff/src/test/resources/config/invalid/unknown-key.yaml @@ -0,0 +1,6 @@ +version: 1 +# Unknown top-level key: rejected by additionalProperties:false. +bogus_top_level: true +tls: + # Out-of-enum value: only "1.2" and "1.3" are permitted. + min_version: "1.1" diff --git a/api-sheriff/src/test/resources/config/valid/gateway.yaml b/api-sheriff/src/test/resources/config/valid/gateway.yaml new file mode 100644 index 0000000..4de7f53 --- /dev/null +++ b/api-sheriff/src/test/resources/config/valid/gateway.yaml @@ -0,0 +1,38 @@ +version: 1 +metadata: + config_version: "2024-05-01" +tls: + min_version: "1.3" + alpn: ["h2", "http/1.1"] +security_headers: + content_type_nosniff: true + frame_deny: true + hsts: + max_age: 31536000 + include_subdomains: true +security_defaults: + profile: "strict" +allowed_methods: ["GET", "POST", "PUT", "DELETE"] +upstream_defaults: + retry: + enabled: true + not_modified: + enabled: false +forwarded: + trusted_proxies: ["10.0.0.0/8"] + trust_scheme_host: true + emit: "both" +token_validation: + issuers: + - name: "primary" + issuer: "https://issuer.example.com" + audience: "api-sheriff" + jwks: + source: "http" + url: "https://issuer.example.com/jwks" +oidc: + issuer: "https://issuer.example.com" + client_id: "sheriff" + client_secret: "${OIDC_CLIENT_SECRET}" + scopes: ["openid", "profile"] + redirect_uri: "https://gw.example.com/callback" diff --git a/api-sheriff/src/test/resources/config/valid/topology.properties b/api-sheriff/src/test/resources/config/valid/topology.properties new file mode 100644 index 0000000..2240b07 --- /dev/null +++ b/api-sheriff/src/test/resources/config/valid/topology.properties @@ -0,0 +1,5 @@ +# Topology alias -> upstream URL mappings. +# Aliases match [A-Z][A-Z0-9_]*; TOPOLOGY_ environment variables override +# these file values at boot. Consumed by the topology resolver (Deliverable 5). +ORDERS=https://orders.internal:8443/api +USERS=https://users.internal:8443 From 108d0c7d936d9a8cf4824d90cde7872e8be2ffc4 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:52:29 +0200 Subject: [PATCH 06/21] feat(config): add endpoint-enablement and topology resolution Add the framework-agnostic boot-pipeline resolvers under de.cuioss.sheriff.api.config.topology: EndpointEnablementResolver computes each endpoint's effective enabled state (ENDPOINT__ENABLED environment override over the file default) and drops disabled endpoints, and TopologyResolver resolves the topology aliases referenced by the surviving enabled endpoints (TOPOLOGY_ override over topology.properties), validates each URL, and decomposes it once into the immutable ResolvedUpstream (scheme/host/port/basePath, ADR-0004). Adds the ResolvedTopology / ResolvedUpstream model records and the resolver unit tests. A missing or malformed alias referenced by an enabled endpoint is a boot failure; disabled endpoints are exempt from alias resolution (deliverable 5). Co-Authored-By: Claude --- .../api/config/model/ResolvedTopology.java | 51 +++++ .../api/config/model/ResolvedUpstream.java | 46 +++++ .../topology/EndpointEnablementResolver.java | 100 ++++++++++ .../api/config/topology/TopologyResolver.java | 182 ++++++++++++++++++ .../api/config/topology/package-info.java | 37 ++++ .../EndpointEnablementResolverTest.java | 86 +++++++++ .../config/topology/TopologyResolverTest.java | 137 +++++++++++++ 7 files changed, 639 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedTopology.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedUpstream.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolver.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/package-info.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/EndpointEnablementResolverTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java 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..9502838 --- /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 { + scheme = Objects.requireNonNull(scheme, "scheme"); + host = Objects.requireNonNull(host, "host"); + basePath = basePath == null ? "" : basePath; + } +} 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..7b3d3a9 --- /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.Function; + +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 Function 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(Function 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..23d0552 --- /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.Function; +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 Function 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(Function 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)); + } + 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/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..b850e4c --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/topology/TopologyResolverTest.java @@ -0,0 +1,137 @@ +/* + * 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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +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 rejectsMalformedUrl() throws Exception { + Path file = topologyFile("ORDERS=http://orders internal:8443\n"); + + assertThrows(TopologyResolutionException.class, + () -> resolverWith(Map.of()).resolve(file, List.of(endpointFor("ORDERS")))); + } + + @Test + void rejectsUrlWithoutSchemeOrHost() throws Exception { + Path file = topologyFile("ORDERS=orders.internal:8443\n"); + + assertThrows(TopologyResolutionException.class, + () -> resolverWith(Map.of()).resolve(file, List.of(endpointFor("ORDERS")))); + } + + @Test + void rejectsMissingAliasReferencedByEnabledEndpoint() throws Exception { + Path file = topologyFile("OTHER=https://other.internal\n"); + + assertThrows(TopologyResolutionException.class, + () -> resolverWith(Map.of()).resolve(file, List.of(endpointFor("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()); + } +} From e36fdf3fc7343d0bfe96827ea37b0672cdafdfd5 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:16:02 +0200 Subject: [PATCH 07/21] feat(config): add cross-cutting ConfigValidator with rule interface and tests Co-Authored-By: Claude --- .../config/validation/ConfigValidator.java | 256 +++++++++++++ .../api/config/validation/package-info.java | 36 ++ .../validation/rule/ValidationRule.java | 51 +++ .../config/validation/rule/package-info.java | 30 ++ .../validation/ConfigValidatorTest.java | 350 ++++++++++++++++++ .../validation/rule/ValidationRuleTest.java | 104 ++++++ 6 files changed, 827 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/package-info.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRule.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/rule/package-info.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRuleTest.java 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..605a65a --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/validation/ConfigValidator.java @@ -0,0 +1,256 @@ +/* + * 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.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 % 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(oidc -> oidc.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/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..74a47ee --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/ConfigValidatorTest.java @@ -0,0 +1,350 @@ +/* + * 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 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 errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertHasError(errors, "/forwarded/trusted_proxies", "trust-all CIDR is not permitted: 0.0.0.0/0"); + } + + @Test + @DisplayName("Should reject a wildcard CORS origin combined with allow_credentials") + void shouldRejectWildcardOriginWithCredentials() { + GatewayConfig gateway = validGateway() + .securityHeaders(Optional.of(SecurityHeadersConfig.builder() + .cors(Optional.of(SecurityHeadersConfig.Cors.builder() + .allowedOrigins(List.of("*")) + .allowCredentials(Optional.of(true)) + .build())) + .build())) + .build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET)); + + List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertHasError(errors, "/security_headers/cors", "wildcard origin '*' is not permitted"); + } + + @Test + @DisplayName("Should reject cookie session mode without an encryption_key") + void shouldRejectCookieSessionWithoutEncryptionKey() { + GatewayConfig gateway = validGateway() + .oidc(Optional.of(OidcConfig.builder() + .session(Optional.of(OidcConfig.Session.builder() + .mode(Optional.of("cookie")) + .build())) + .build())) + .build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET)); + + List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertHasError(errors, "/oidc/session/encryption_key", "cookie session mode requires an encryption_key"); + } + + @Test + @DisplayName("Should reject server session mode without a store") + void shouldRejectServerSessionWithoutStore() { + GatewayConfig gateway = validGateway() + .oidc(Optional.of(OidcConfig.builder() + .session(Optional.of(OidcConfig.Session.builder() + .mode(Optional.of("server")) + .build())) + .build())) + .build(); + EndpointConfig endpoint = endpoint("orders", "ORDERS", List.of(), route("r", HttpMethod.GET)); + + List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertHasError(errors, "/oidc/session/store", "server session mode requires a store"); + } + + @Test + @DisplayName("Should accept effective auth 'bearer' when a token_validation issuer is present") + void shouldAcceptBearerWithIssuer() { + GatewayConfig gateway = validGateway() + .tokenValidation(Optional.of(new TokenValidationConfig( + List.of(IssuerConfig.builder().name("primary").issuer("https://idp.example").build())))) + .build(); + 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(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertTrue(errors.isEmpty(), () -> "expected no violations for a valid bearer config, got: " + errors); + } + } + + @Nested + @DisplayName("The TRACE / CONNECT verbs") + class StructuralVerbRejection { + + @Test + @DisplayName("Should not be representable in the HttpMethod model") + void shouldNotBeRepresentableInModel() { + assertAll("forbidden verbs are absent from the enum", + () -> assertThrows(IllegalArgumentException.class, () -> HttpMethod.valueOf("TRACE"), + "TRACE must not be a representable HttpMethod"), + () -> assertThrows(IllegalArgumentException.class, () -> HttpMethod.valueOf("CONNECT"), + "CONNECT must not be a representable HttpMethod")); + } + } + + @Nested + @DisplayName("The aggregating validate pass") + class Aggregation { + + @Test + @DisplayName("Should report every violation together in a single pass") + void shouldReportEveryViolationInOnePass() { + GatewayConfig gateway = GatewayConfig.builder().version(2).build(); + EndpointConfig endpoint = EndpointConfig.builder() + .id("orders").enabled(true).baseUrl("MISSING") + .auth(new AuthConfig("none", List.of())) + .allowedMethods(List.of(HttpMethod.GET)) + .routes(List.of(route("orders-post", HttpMethod.POST))) + .build(); + + List errors = validator.validate(gateway, List.of(endpoint), topologyWith("ORDERS")); + + assertAll("all three independent violations surface together", + () -> assertTrue(errors.size() >= 3, () -> "expected at least three violations, got: " + errors), + () -> assertHasError(errors, "/version", "unsupported config version"), + () -> assertHasError(errors, "/endpoint/base_url", "unresolved topology alias: MISSING"), + () -> assertHasError(errors, "/endpoint/routes", "outside the effective allowed_methods")); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRuleTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRuleTest.java new file mode 100644 index 0000000..a3871af --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/validation/rule/ValidationRuleTest.java @@ -0,0 +1,104 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import de.cuioss.sheriff.api.config.load.ConfigError; +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.ResolvedTopology; + +/** + * Tests for the {@link ValidationRule} functional-interface contract: a rule + * appends a {@link ConfigError} per violation to the shared accumulator, + * leaves it untouched when nothing is wrong, may append several violations in one + * pass, and never replaces the caller's list so that consecutive rules accumulate + * into it. + */ +class ValidationRuleTest { + + private static final GatewayConfig GATEWAY = GatewayConfig.builder().version(1).build(); + private static final ResolvedTopology TOPOLOGY = new ResolvedTopology(Map.of()); + + @Test + @DisplayName("Should append one error per violation to the shared accumulator") + void shouldAppendOneErrorPerViolation() { + ValidationRule rule = (gateway, endpoints, topology, errors) -> + errors.add(new ConfigError("gateway.yaml", "/version", "boom")); + List errors = new ArrayList<>(); + + rule.validate(GATEWAY, List.of(), TOPOLOGY, errors); + + assertAll("single appended violation", + () -> assertEquals(1, errors.size(), "exactly one error should be appended"), + () -> assertEquals("boom", errors.getFirst().message(), "the appended message should be preserved")); + } + + @Test + @DisplayName("Should leave the accumulator untouched when there is no violation") + void shouldLeaveAccumulatorUntouchedWhenNoViolation() { + ValidationRule rule = (gateway, endpoints, topology, errors) -> { + // a compliant configuration appends nothing + }; + List errors = new ArrayList<>(); + + rule.validate(GATEWAY, List.of(), TOPOLOGY, errors); + + assertTrue(errors.isEmpty(), () -> "expected no errors, got: " + errors); + } + + @Test + @DisplayName("Should append several violations in a single pass without stopping at the first") + void shouldAppendSeveralViolationsInSinglePass() { + ValidationRule rule = (gateway, endpoints, topology, errors) -> { + errors.add(new ConfigError("a.yaml", "/x", "first")); + errors.add(new ConfigError("a.yaml", "/y", "second")); + }; + List errors = new ArrayList<>(); + + rule.validate(GATEWAY, List.of(), TOPOLOGY, errors); + + assertEquals(2, errors.size(), "both violations from one pass should be present"); + } + + @Test + @DisplayName("Should accumulate into the shared list across consecutive rules") + void shouldAccumulateAcrossConsecutiveRules() { + ValidationRule first = (gateway, endpoints, topology, errors) -> + errors.add(new ConfigError("a.yaml", "/x", "first")); + ValidationRule second = (gateway, endpoints, topology, errors) -> + errors.add(new ConfigError("b.yaml", "/y", "second")); + List errors = new ArrayList<>(); + + first.validate(GATEWAY, List.of(), TOPOLOGY, errors); + second.validate(GATEWAY, List.of(), TOPOLOGY, errors); + + assertAll("violations from both rules coexist", + () -> assertEquals(2, errors.size(), "both rules should contribute to the same list"), + () -> assertEquals("first", errors.getFirst().message(), "the first rule's error should be retained"), + () -> assertEquals("second", errors.get(1).message(), "the second rule's error should be appended")); + } +} From bc6b67c7b9bc0a56d7f64fe4f46329bb0dd4a022 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:28:12 +0200 Subject: [PATCH 08/21] feat(config): materialize RouteTable with effective auth, allowed_methods, retry/not-modified Co-Authored-By: Claude --- .../sheriff/api/config/RouteTableBuilder.java | 199 +++++++++++ .../api/config/model/ResolvedRoute.java | 77 ++++ .../sheriff/api/config/model/RouteTable.java | 63 ++++ .../api/config/RouteTableBuilderTest.java | 329 ++++++++++++++++++ 4 files changed, 668 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteTable.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java 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..a9d69e3 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/RouteTableBuilder.java @@ -0,0 +1,199 @@ +/* + * 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 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()) + && headerA.value().isPresent() && headerB.value().isPresent() + && !headerA.value().get().equals(headerB.value().get())) { + return true; + } + } + } + return false; + } + + /** + * 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/model/ResolvedRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ResolvedRoute.java new file mode 100644 index 0000000..7cb664c --- /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 { + id = Objects.requireNonNull(id, "id"); + protocol = protocol == null ? Protocol.HTTP : protocol; + match = Objects.requireNonNull(match, "match"); + effectiveAuth = Objects.requireNonNull(effectiveAuth, "effectiveAuth"); + effectiveAllowedMethods = effectiveAllowedMethods == null ? List.of() : List.copyOf(effectiveAllowedMethods); + upstream = 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/RouteTable.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteTable.java new file mode 100644 index 0000000..c80b3ec --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RouteTable.java @@ -0,0 +1,63 @@ +/* + * 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 -> path.startsWith(route.pathPrefix())).findFirst(); + } +} 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..ad84de0 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/config/RouteTableBuilderTest.java @@ -0,0 +1,329 @@ +/* + * 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.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 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()); + } + } + + @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"); + } + } +} From b3116cac8c9fe96182634d149546c46b7c39dbd8 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:42:41 +0200 Subject: [PATCH 09/21] feat(config): wire ConfigProducer with fail-fast startup and CONFIG_LOADED LogRecords Co-Authored-By: Claude --- .../sheriff/api/config/ConfigLogMessages.java | 76 +++++++++ .../sheriff/api/config/package-info.java | 38 +++++ .../sheriff/api/quarkus/ConfigProducer.java | 145 ++++++++++++++++++ .../api/quarkus/ConfigFailFastTest.java | 72 +++++++++ .../api/quarkus/ConfigProducerTest.java | 100 ++++++++++++ .../src/test/resources/application.properties | 4 + .../test/resources/config/broken/gateway.yaml | 6 + .../resources/config/testboot/gateway.yaml | 8 + 8 files changed, 449 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/ConfigLogMessages.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/config/package-info.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java create mode 100644 api-sheriff/src/test/resources/config/broken/gateway.yaml create mode 100644 api-sheriff/src/test/resources/config/testboot/gateway.yaml 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/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/quarkus/ConfigProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java new file mode 100644 index 0000000..46868a6 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java @@ -0,0 +1,145 @@ +/* + * 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 org.eclipse.microprofile.config.inject.ConfigProperty; + +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; + +/** + * 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. + * + * @return the immutable, validated {@link GatewayConfig} + */ + @Produces + @ApplicationScoped + public GatewayConfig gatewayConfig() { + buildOnce(); + return gateway; + } + + /** + * Produces the assembled route table. + * + * @return the immutable, longest-prefix-ordered {@link RouteTable} + */ + @Produces + @ApplicationScoped + 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/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java new file mode 100644 index 0000000..2ac1aad --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.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.quarkus; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.net.URISyntaxException; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +import de.cuioss.test.juli.LogAsserts; +import de.cuioss.test.juli.TestLogLevel; +import de.cuioss.test.juli.junit5.EnableTestLogger; + +/** + * Tests that {@link ConfigProducer} fails fast on an invalid configuration: the + * bean producer and the startup observer both throw, and every collected violation + * plus the abort are logged as structured ERROR records — so Quarkus exits non-zero + * rather than serving on partial configuration. + */ +@EnableTestLogger +class ConfigFailFastTest { + + private static ConfigProducer producerForBrokenConfig() throws URISyntaxException { + var resource = ConfigFailFastTest.class.getResource("/config/broken"); + assertNotNull(resource, "broken config fixture must be on the test classpath"); + ConfigProducer producer = new ConfigProducer(); + producer.configDir = Path.of(resource.toURI()).toString(); + return producer; + } + + @Test + void shouldThrowFromProducerOnBrokenConfig() throws URISyntaxException { + ConfigProducer producer = producerForBrokenConfig(); + + assertThrows(IllegalStateException.class, producer::gatewayConfig, + "producing a bean from a broken config must fail fast"); + } + + @Test + void shouldFailStartupOnBrokenConfig() throws URISyntaxException { + ConfigProducer producer = producerForBrokenConfig(); + + assertThrows(IllegalStateException.class, () -> producer.onStartup(null), + "startup must abort on a broken config"); + } + + @Test + void shouldLogEveryViolationAndTheAbort() throws URISyntaxException { + ConfigProducer producer = producerForBrokenConfig(); + + assertThrows(IllegalStateException.class, producer::gatewayConfig); + + LogAsserts.assertLogMessagePresentContaining(TestLogLevel.ERROR, "Invalid configuration"); + LogAsserts.assertLogMessagePresentContaining(TestLogLevel.ERROR, "Refusing to start"); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java new file mode 100644 index 0000000..3b6c60a --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.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.quarkus; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import de.cuioss.sheriff.api.config.model.GatewayConfig; +import de.cuioss.sheriff.api.config.model.RouteTable; +import de.cuioss.test.juli.LogAsserts; +import de.cuioss.test.juli.TestLogLevel; +import de.cuioss.test.juli.junit5.EnableTestLogger; + +/** + * Tests for {@link ConfigProducer}: CDI bean assembly from a valid configuration + * directory, the memoized single build, eager startup assembly, and the + * {@code CONFIG_LOADED} INFO log record. + */ +@EnableTestLogger +class ConfigProducerTest { + + private static final String VALID_GATEWAY = """ + version: 1 + metadata: + config_version: "2026-07-13" + """; + + @TempDir + Path configDir; + + private ConfigProducer producerForValidConfig() throws IOException { + Files.writeString(configDir.resolve("gateway.yaml"), VALID_GATEWAY); + ConfigProducer producer = new ConfigProducer(); + producer.configDir = configDir.toString(); + return producer; + } + + @Test + void shouldProduceBeansFromValidConfig() throws IOException { + ConfigProducer producer = producerForValidConfig(); + + GatewayConfig gateway = producer.gatewayConfig(); + RouteTable routeTable = producer.routeTable(); + + assertEquals(1, gateway.version(), "the bound gateway should carry the configured version"); + assertNotNull(routeTable, "the route table bean should be produced"); + assertTrue(routeTable.routes().isEmpty(), "a config with no endpoints yields an empty route table"); + } + + @Test + void shouldLogConfigLoadedOnSuccess() throws IOException { + ConfigProducer producer = producerForValidConfig(); + + producer.gatewayConfig(); + + LogAsserts.assertLogMessagePresentContaining(TestLogLevel.INFO, "Configuration loaded successfully"); + } + + @Test + void shouldBuildOnceAndCacheTheResult() throws IOException { + ConfigProducer producer = producerForValidConfig(); + + GatewayConfig first = producer.gatewayConfig(); + GatewayConfig second = producer.gatewayConfig(); + + assertSame(first, second, "the pipeline should be assembled once and cached"); + } + + @Test + void shouldAssembleEagerlyOnStartupForValidConfig() throws IOException { + ConfigProducer producer = producerForValidConfig(); + + assertDoesNotThrow(() -> producer.onStartup(null), + "a valid configuration should assemble without failing startup"); + assertNotNull(producer.gatewayConfig(), "beans should be available after startup assembly"); + } +} diff --git a/api-sheriff/src/test/resources/application.properties b/api-sheriff/src/test/resources/application.properties index 5a705b3..c1d7606 100644 --- a/api-sheriff/src/test/resources/application.properties +++ b/api-sheriff/src/test/resources/application.properties @@ -4,6 +4,10 @@ quarkus.tls.key-store.pem.0.key=../integration-tests/src/main/docker/certificate quarkus.management.enabled=false quarkus.log.file.enable=false +# Minimal valid boot config so ConfigProducer's fail-fast startup succeeds inside +# the full-container @QuarkusTest boot (see src/test/resources/config/testboot). +sheriff.config.dir=target/test-classes/config/testboot + # Proxy route under test — upstream is a MockWebServer started on this fixed port # by ProxyRouteTest (Quarkus freezes config at boot, so a dynamic port cannot be used). sheriff.proxy.path-prefix=/proxy diff --git a/api-sheriff/src/test/resources/config/broken/gateway.yaml b/api-sheriff/src/test/resources/config/broken/gateway.yaml new file mode 100644 index 0000000..3bf9fd7 --- /dev/null +++ b/api-sheriff/src/test/resources/config/broken/gateway.yaml @@ -0,0 +1,6 @@ +# Intentionally broken gateway configuration used by ConfigFailFastTest. +# It violates the D2 gateway schema in two ways: 'version' is a string rather than +# an integer, and 'unknown_key' is not an allowed top-level property +# (additionalProperties: false). Loading it must fail fast at boot. +version: "not-an-integer" +unknown_key: true diff --git a/api-sheriff/src/test/resources/config/testboot/gateway.yaml b/api-sheriff/src/test/resources/config/testboot/gateway.yaml new file mode 100644 index 0000000..47d71df --- /dev/null +++ b/api-sheriff/src/test/resources/config/testboot/gateway.yaml @@ -0,0 +1,8 @@ +# Minimal valid boot configuration for the @QuarkusTest container. +# It declares no endpoints, so the produced RouteTable is empty; its sole purpose +# is to let ConfigProducer assemble successfully during full-container tests +# (GatewayResourceTest, ProxyRouteTest). Referenced by sheriff.config.dir in the +# test application.properties. +version: 1 +metadata: + config_version: "test-boot" From bb268e6221aadd6038b710629084ef269a46f13b Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:02:06 +0200 Subject: [PATCH 10/21] feat(proxy): rewire proxy edge to consume RouteTable; remove ProxyConfiguration The proxy edge now sources each route's upstream from the RouteTable assembled by the configuration subsystem instead of a static ProxyConfiguration bean, which is deleted. ProxyRouteTest and the @QuarkusTest boot config are rewired: the shared testboot config gains a /proxy endpoint whose UPSTREAM topology alias resolves to the MockWebServer port. Produce the immutable GatewayConfig and RouteTable records under the @Singleton pseudo-scope: ArC cannot build a client proxy for a normal scope (@ApplicationScoped) over a final record type, which failed every @QuarkusTest container at boot once test-compile went green. Co-Authored-By: Claude --- .../api/gateway/proxy/ProxyConfiguration.java | 54 ------------- .../sheriff/api/gateway/proxy/ProxyRoute.java | 80 ++++++++++++------- .../api/gateway/proxy/package-info.java | 13 +-- .../sheriff/api/quarkus/ConfigProducer.java | 15 +++- .../src/main/resources/application.properties | 8 +- .../api/gateway/proxy/ProxyRouteTest.java | 17 ++-- .../src/test/resources/application.properties | 8 +- .../config/testboot/endpoints/proxy.yaml | 15 ++++ .../resources/config/testboot/gateway.yaml | 8 +- .../config/testboot/topology.properties | 6 ++ 10 files changed, 116 insertions(+), 108 deletions(-) delete mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/gateway/proxy/ProxyConfiguration.java create mode 100644 api-sheriff/src/test/resources/config/testboot/endpoints/proxy.yaml create mode 100644 api-sheriff/src/test/resources/config/testboot/topology.properties 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/ConfigProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigProducer.java index 46868a6..74f9f7d 100644 --- 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 @@ -38,6 +38,7 @@ import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import jakarta.enterprise.inject.Produces; +import jakarta.inject.Singleton; /** * The framework-bound edge (ADR-0005 seam) that assembles the file-based @@ -82,11 +83,16 @@ void onStartup(@Observes StartupEvent event) { /** * 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 - @ApplicationScoped + @Singleton public GatewayConfig gatewayConfig() { buildOnce(); return gateway; @@ -94,11 +100,16 @@ public GatewayConfig gatewayConfig() { /** * 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 - @ApplicationScoped + @Singleton public RouteTable routeTable() { buildOnce(); return routeTable; diff --git a/api-sheriff/src/main/resources/application.properties b/api-sheriff/src/main/resources/application.properties index 07bcfc6..dc27a3e 100644 --- a/api-sheriff/src/main/resources/application.properties +++ b/api-sheriff/src/main/resources/application.properties @@ -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/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java index 183b84a..e994f75 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/gateway/proxy/ProxyRouteTest.java @@ -48,18 +48,23 @@ * body to the upstream, hop-by-hop header stripping, and {@code 404} * deny-by-default for unmatched paths. *

- * The upstream is a {@link MockWebServer} started on the fixed port that - * {@code sheriff.proxy.upstream-url} points to in the test configuration — - * {@code @QuarkusTest} freezes config at boot, so a dynamically-assigned port - * cannot be injected. The mock echoes each received request back as JSON, so the - * assertions verify exactly what the gateway forwarded. + * The proxy edge sources its upstream from the {@code RouteTable} the + * configuration subsystem assembles at boot: the test config + * ({@code src/test/resources/config/testboot}) declares a single {@code /proxy} + * route ({@code endpoints/proxy.yaml}) whose {@code UPSTREAM} topology alias + * ({@code topology.properties}) resolves to the {@link MockWebServer} started on + * the fixed port below. There is no static {@code ProxyConfiguration} bean any + * more. The fixed port is required because {@code @QuarkusTest} freezes config at + * boot, so a dynamically-assigned port cannot be injected. The mock echoes each + * received request back as JSON, so the assertions verify exactly what the gateway + * forwarded from the RouteTable-sourced upstream. */ @QuarkusTest @EnableMockWebServer(manualStart = true) @ModuleDispatcher class ProxyRouteTest { - /** Must match {@code sheriff.proxy.upstream-url} in {@code src/test/resources/application.properties}. */ + /** Must match the {@code UPSTREAM} alias port in {@code config/testboot/topology.properties}. */ private static final int UPSTREAM_PORT = 19191; @BeforeAll diff --git a/api-sheriff/src/test/resources/application.properties b/api-sheriff/src/test/resources/application.properties index c1d7606..065eb81 100644 --- a/api-sheriff/src/test/resources/application.properties +++ b/api-sheriff/src/test/resources/application.properties @@ -6,9 +6,7 @@ quarkus.log.file.enable=false # Minimal valid boot config so ConfigProducer's fail-fast startup succeeds inside # the full-container @QuarkusTest boot (see src/test/resources/config/testboot). +# The proxy edge under test sources its upstream from the RouteTable assembled from +# this directory (endpoints/proxy.yaml -> UPSTREAM topology alias), not from any +# static sheriff.proxy.* configuration. sheriff.config.dir=target/test-classes/config/testboot - -# Proxy route under test — upstream is a MockWebServer started on this fixed port -# by ProxyRouteTest (Quarkus freezes config at boot, so a dynamic port cannot be used). -sheriff.proxy.path-prefix=/proxy -sheriff.proxy.upstream-url=http://localhost:19191 diff --git a/api-sheriff/src/test/resources/config/testboot/endpoints/proxy.yaml b/api-sheriff/src/test/resources/config/testboot/endpoints/proxy.yaml new file mode 100644 index 0000000..cae0de7 --- /dev/null +++ b/api-sheriff/src/test/resources/config/testboot/endpoints/proxy.yaml @@ -0,0 +1,15 @@ +# Proxy endpoint for the @QuarkusTest boot configuration. +# +# Declares the single /proxy route ProxyRouteTest exercises. Its upstream is the +# UPSTREAM topology alias (topology.properties -> http://localhost:19191), so the +# assembled RouteTable carries one route whose ResolvedUpstream the proxy edge +# forwards to. This replaces the removed static ProxyConfiguration bean. +endpoint: + id: proxy + base_url: UPSTREAM + auth: + require: none + routes: + - id: proxy-all + match: + path_prefix: /proxy diff --git a/api-sheriff/src/test/resources/config/testboot/gateway.yaml b/api-sheriff/src/test/resources/config/testboot/gateway.yaml index 47d71df..397dfee 100644 --- a/api-sheriff/src/test/resources/config/testboot/gateway.yaml +++ b/api-sheriff/src/test/resources/config/testboot/gateway.yaml @@ -1,8 +1,8 @@ # Minimal valid boot configuration for the @QuarkusTest container. -# It declares no endpoints, so the produced RouteTable is empty; its sole purpose -# is to let ConfigProducer assemble successfully during full-container tests -# (GatewayResourceTest, ProxyRouteTest). Referenced by sheriff.config.dir in the -# test application.properties. +# The sibling endpoints/proxy.yaml + topology.properties add the single /proxy +# route that ProxyRouteTest exercises, so ConfigProducer assembles a RouteTable +# with one route during full-container tests (GatewayResourceTest, ProxyRouteTest). +# Referenced by sheriff.config.dir in the test application.properties. version: 1 metadata: config_version: "test-boot" diff --git a/api-sheriff/src/test/resources/config/testboot/topology.properties b/api-sheriff/src/test/resources/config/testboot/topology.properties new file mode 100644 index 0000000..c78a1eb --- /dev/null +++ b/api-sheriff/src/test/resources/config/testboot/topology.properties @@ -0,0 +1,6 @@ +# Topology aliases for the @QuarkusTest boot configuration. +# +# UPSTREAM points at the MockWebServer that ProxyRouteTest starts on the fixed +# port 19191. The proxy edge sources this upstream from the assembled RouteTable +# (endpoints/proxy.yaml -> UPSTREAM), no longer from a static ProxyConfiguration. +UPSTREAM=http://localhost:19191 From 7fc375280e6772c0fdc7f4d6e8f63a25b83b1e71 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:10:03 +0200 Subject: [PATCH 11/21] test(config): mount file-based config into integration containers Replace the removed SHERIFF_PROXY_* env with a mounted sheriff-config directory (SHERIFF_CONFIG_DIR): gateway.yaml, endpoints/httpbin.yaml and topology.properties assemble the RouteTable whose UPSTREAM alias resolves to the go-httpbin echo backend. The benchmark overlay repoints the target via the TOPOLOGY_UPSTREAM alias override rather than a proxy env var. Add ConfigLoadedIntegrationIT (the mounted /proxy route forwards, unmatched paths deny by default, container boots healthy) and verify-invalid-config-fails.sh, a negative check that an invalid mounted config makes the container fail fast with a non-zero exit. Co-Authored-By: Claude --- .../docker-compose.benchmark.yml | 6 +- integration-tests/docker-compose.yml | 9 ++- .../scripts/verify-invalid-config-fails.sh | 72 ++++++++++++++++++ .../sheriff-config/endpoints/httpbin.yaml | 16 ++++ .../main/docker/sheriff-config/gateway.yaml | 8 ++ .../docker/sheriff-config/topology.properties | 7 ++ .../ConfigLoadedIntegrationIT.java | 76 +++++++++++++++++++ 7 files changed, 189 insertions(+), 5 deletions(-) create mode 100755 integration-tests/scripts/verify-invalid-config-fails.sh create mode 100644 integration-tests/src/main/docker/sheriff-config/endpoints/httpbin.yaml create mode 100644 integration-tests/src/main/docker/sheriff-config/gateway.yaml create mode 100644 integration-tests/src/main/docker/sheriff-config/topology.properties create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ConfigLoadedIntegrationIT.java diff --git a/integration-tests/docker-compose.benchmark.yml b/integration-tests/docker-compose.benchmark.yml index 0cdfc10..e4a9c69 100644 --- a/integration-tests/docker-compose.benchmark.yml +++ b/integration-tests/docker-compose.benchmark.yml @@ -15,7 +15,9 @@ services: api-sheriff: environment: - # Override the IT upstream (go-httpbin) with the static backend for benchmarks - - SHERIFF_PROXY_UPSTREAM_URL=http://nginx-static:8080 + # Override the UPSTREAM topology alias (go-httpbin) with the static backend + # for benchmarks. TOPOLOGY_ takes precedence over topology.properties, + # so the mounted config is reused unchanged and only the target is repointed. + - TOPOLOGY_UPSTREAM=http://nginx-static:8080 depends_on: - nginx-static diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index 2e36d1e..84ce1ae 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -67,9 +67,10 @@ services: - QUARKUS_HTTP_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PATH=/app/certificates/localhost-truststore.p12 - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PASSWORD=localhost-trust - # Interim proxy route -> go-httpbin echo backend (reached by service name) - - SHERIFF_PROXY_PATH_PREFIX=/proxy - - SHERIFF_PROXY_UPSTREAM_URL=http://go-httpbin:8080/anything + # File-based configuration mounted at /app/sheriff-config: the proxy edge + # sources its routes and upstreams from the assembled RouteTable, and the + # UPSTREAM topology alias resolves to the go-httpbin echo backend. + - SHERIFF_CONFIG_DIR=/app/sheriff-config depends_on: - keycloak @@ -78,6 +79,8 @@ services: volumes: # Read-only certificate mount (production pattern) - ./src/main/docker/certificates:/app/certificates:ro + # Read-only file-based gateway configuration mount (production pattern) + - ./src/main/docker/sheriff-config:/app/sheriff-config:ro # Mount target directory for log files (defaults to ./target) - ${LOG_TARGET_DIR:-./target}:/logs:rw diff --git a/integration-tests/scripts/verify-invalid-config-fails.sh b/integration-tests/scripts/verify-invalid-config-fails.sh new file mode 100755 index 0000000..ce4ae74 --- /dev/null +++ b/integration-tests/scripts/verify-invalid-config-fails.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Negative check: an invalid mounted configuration MUST make the api-sheriff +# container fail fast and exit non-zero, never serving on partial configuration. +# +# ConfigProducer validates the mounted gateway configuration at boot +# (StartupEvent). On any violation it logs structured ERROR records and throws, so +# Quarkus exits non-zero. This script mounts a deliberately invalid gateway.yaml +# (non-integer version + an unknown top-level key, both rejected by the D2 schema) +# and asserts the container exits with a non-zero code. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +IMAGE="api-sheriff:distroless" +CONTAINER_NAME="api-sheriff-invalid-config-check" +BOOT_TIMEOUT_SECONDS=60 +INVALID_CONFIG_DIR="" + +cleanup() { + docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true + [[ -n "${INVALID_CONFIG_DIR}" ]] && rm -rf "${INVALID_CONFIG_DIR}" +} +trap cleanup EXIT + +if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then + echo "❌ Required image '${IMAGE}' not found." + echo " Build it first: ./mvnw verify -Pintegration-tests -pl integration-tests -am" + exit 1 +fi + +# Throwaway config dir whose gateway.yaml is invalid on two counts the schema +# rejects: version must be an integer, and unknown top-level keys are not allowed. +INVALID_CONFIG_DIR="$(mktemp -d)" +cat > "${INVALID_CONFIG_DIR}/gateway.yaml" <<'YAML' +version: "not-an-integer" +unknown_key: true +YAML + +echo "🚦 Starting '${CONTAINER_NAME}' with an invalid mounted configuration..." +docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true +docker run -d --name "${CONTAINER_NAME}" \ + -e SHERIFF_CONFIG_DIR=/app/sheriff-config \ + -e QUARKUS_HTTP_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt \ + -e QUARKUS_HTTP_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key \ + -v "${PROJECT_DIR}/src/main/docker/certificates:/app/certificates:ro" \ + -v "${INVALID_CONFIG_DIR}:/app/sheriff-config:ro" \ + "${IMAGE}" >/dev/null + +echo "⏳ Waiting up to ${BOOT_TIMEOUT_SECONDS}s for the container to exit..." +set +e +EXIT_CODE="$(timeout "${BOOT_TIMEOUT_SECONDS}" docker wait "${CONTAINER_NAME}")" +WAIT_STATUS=$? +set -e + +if [[ ${WAIT_STATUS} -ne 0 ]]; then + echo "❌ docker wait timed out — the container failed to exit and may be" + echo " serving despite the invalid configuration." + docker logs "${CONTAINER_NAME}" 2>&1 | tail -50 || true + exit 1 +fi + +echo "📄 Container exited with code ${EXIT_CODE}. Fail-fast markers (if any):" +docker logs "${CONTAINER_NAME}" 2>&1 | grep -E "Refusing to start|ApiSheriff-20[01]|configuration is invalid" || true + +if [[ "${EXIT_CODE}" == "0" ]]; then + echo "❌ Expected a non-zero exit for invalid configuration, but the container exited 0." + exit 1 +fi + +echo "✅ Invalid configuration correctly caused a fail-fast non-zero exit (${EXIT_CODE})." diff --git a/integration-tests/src/main/docker/sheriff-config/endpoints/httpbin.yaml b/integration-tests/src/main/docker/sheriff-config/endpoints/httpbin.yaml new file mode 100644 index 0000000..0642ae7 --- /dev/null +++ b/integration-tests/src/main/docker/sheriff-config/endpoints/httpbin.yaml @@ -0,0 +1,16 @@ +# The single proxied endpoint the integration suite exercises. Its /proxy route +# is the only route in the assembled RouteTable, so a request that echoes proves +# the gateway sourced its upstream from this mounted configuration (not from a +# removed static ProxyConfiguration). The UPSTREAM topology alias resolves to the +# go-httpbin echo backend (topology.properties), overridable per environment by +# TOPOLOGY_UPSTREAM (the benchmark overlay repoints it at nginx-static). +endpoint: + id: httpbin + enabled: true + base_url: UPSTREAM + auth: + require: none + routes: + - id: httpbin-proxy + match: + path_prefix: /proxy diff --git a/integration-tests/src/main/docker/sheriff-config/gateway.yaml b/integration-tests/src/main/docker/sheriff-config/gateway.yaml new file mode 100644 index 0000000..06e54b9 --- /dev/null +++ b/integration-tests/src/main/docker/sheriff-config/gateway.yaml @@ -0,0 +1,8 @@ +# Global gateway document mounted into the api-sheriff container at +# /app/sheriff-config (SHERIFF_CONFIG_DIR). Kept intentionally minimal: the proxy +# integration suite only needs the single httpbin endpoint's /proxy route, and +# require: none there means no token_validation / oidc block is required here. +version: 1 +metadata: + config_version: "integration-test" +allowed_methods: ["GET", "POST", "PUT", "DELETE"] diff --git a/integration-tests/src/main/docker/sheriff-config/topology.properties b/integration-tests/src/main/docker/sheriff-config/topology.properties new file mode 100644 index 0000000..66ea1d7 --- /dev/null +++ b/integration-tests/src/main/docker/sheriff-config/topology.properties @@ -0,0 +1,7 @@ +# Topology aliases for the mounted integration configuration. +# +# UPSTREAM is the go-httpbin echo backend, reached by the gateway on the internal +# compose network. Its /anything/* endpoint echoes the received request as JSON so +# the proxy ITs can assert exactly what was forwarded. The benchmark overlay +# overrides this at runtime with TOPOLOGY_UPSTREAM=http://nginx-static:8080. +UPSTREAM=http://go-httpbin:8080/anything diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ConfigLoadedIntegrationIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ConfigLoadedIntegrationIT.java new file mode 100644 index 0000000..92f229d --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/api/integration/ConfigLoadedIntegrationIT.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.integration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +/** + * End-to-end proof that the gateway booted from the file-based configuration + * mounted into the container at {@code /app/sheriff-config} (the + * {@code SHERIFF_CONFIG_DIR} environment variable). + *

+ * The proxy edge no longer reads a static {@code ProxyConfiguration}: its routes + * and upstreams come from the {@code RouteTable} the configuration subsystem + * assembles at boot from the mounted {@code gateway.yaml}, + * {@code endpoints/httpbin.yaml} and {@code topology.properties}. The single + * mounted {@code /proxy} route being live — forwarding to the {@code go-httpbin} + * echo backend the {@code UPSTREAM} topology alias resolves to — is the observable + * proof the mounted configuration was loaded; any other path staying {@code 404} + * confirms deny-by-default over exactly the mounted route set. + */ +class ConfigLoadedIntegrationIT extends BaseIntegrationTest { + + @Test + @DisplayName("mounted-config /proxy route forwards to the resolved upstream") + void mountedConfigRouteForwardsToUpstream() { + given() + .when() + .get("/proxy/get?probe=config") + .then() + .statusCode(200) + .contentType(containsString("application/json")) + .body("method", is("GET")) + .body("url", containsString("/anything/get")) + .body("args.probe[0]", is("config")); + } + + @Test + @DisplayName("only the mounted route set is served — unmatched paths deny by default") + void unmountedPathDeniedByDefault() { + given() + .when() + .get("/no-such-route/resource") + .then() + .statusCode(404); + } + + @Test + @DisplayName("container boots healthy from the mounted configuration") + void managementHealthReportsUp() { + given() + .baseUri(managementBaseUri()) + .when() + .get("/q/health/live") + .then() + .statusCode(200) + .body("status", is("UP")); + } +} From 3e9d72fbffa0706baea53bbb6ab6516580ee031d Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:15:55 +0200 Subject: [PATCH 12/21] docs(config): add Schemas section, upstream_defaults reference, config LogMessages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the two bundled JSON Schemas (gateway/endpoint, Draft 2020-12, additionalProperties:false) and the upstream_defaults retry / not-modified toggles with their three-level wholesale-replace inheritance, matching RouteTableBuilder. Existing endpoint id/enabled/allowed_methods sections are left untouched. Catalogue the config LogRecords in doc/LogMessages.adoc — ApiSheriff-002 CONFIG_LOADED (INFO), ApiSheriff-200 CONFIG_VALIDATION_FAILED and ApiSheriff-201 CONFIG_STARTUP_ABORTED (ERROR) — matching ConfigLogMessages. Co-Authored-By: Claude --- doc/LogMessages.adoc | 10 ++++++ doc/configuration.adoc | 77 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/doc/LogMessages.adoc b/doc/LogMessages.adoc index e2721cf..bc83876 100644 --- a/doc/LogMessages.adoc +++ b/doc/LogMessages.adoc @@ -30,6 +30,7 @@ diagnostics use the logger directly and are not catalogued. |=== |ID |Component |Message |Description |ApiSheriff-001 |PROXY |Proxy route registered: path-prefix='%s' upstream='%s' |Logged once at startup when the catch-all proxy route is registered on the data plane +|ApiSheriff-002 |CONFIG |Configuration loaded successfully (config_version='%s') |Logged once at startup after the file-based configuration is read, validated, and assembled into the route table |=== == WARN Level (100-199) @@ -39,3 +40,12 @@ diagnostics use the logger directly and are not catalogued. |ID |Component |Message |Description |ApiSheriff-100 |PROXY |Proxy request to upstream '%s' failed: %s |Logged when forwarding a request to the configured upstream fails; the gateway responds with 502 |=== + +== ERROR Level (200-299) + +[cols="1,1,2,2", options="header"] +|=== +|ID |Component |Message |Description +|ApiSheriff-200 |CONFIG |Invalid configuration in '%s' at '%s': %s |Logged once per collected configuration violation (offending file, JSON pointer, and detail) during fail-fast startup validation +|ApiSheriff-201 |CONFIG |Refusing to start — configuration is invalid: %s |Logged after the violations when startup is aborted; Quarkus then exits non-zero and never serves on partial configuration +|=== diff --git a/doc/configuration.adoc b/doc/configuration.adoc index 82eebc8..d1d586e 100644 --- a/doc/configuration.adoc +++ b/doc/configuration.adoc @@ -1,7 +1,9 @@ = API Sheriff -- Configuration Model -:toc: +:toc: left :toclevels: 3 +:toc-title: Table of Contents :sectnums: +:source-highlighter: highlight.js == Principles @@ -84,6 +86,38 @@ conf/ The manifest's dynamic `/api/gateway-config.json` discovery is deferred; `metadata.config_version` is retained now only as an audit stamp. +[[_schemas]] +== Schemas + +Both configuration artifacts are validated against bundled *JSON Schema* documents (Draft +2020-12) before binding, so a structurally malformed file is rejected with a precise +file-and-JSON-pointer error before any immutable value object is built: + +[cols="1,3"] +|=== +| Schema | Validates + +| `schema/gateway.schema.json` +| The global `gateway.yaml` document -- `version` (the only required key), `metadata`, `tls`, + `security_headers`, `security_defaults`, `allowed_methods`, `upstream_defaults`, `forwarded`, + `token_validation` and `oidc`. + +| `schema/endpoint.schema.json` +| A single `endpoints/*.yaml` file -- the `endpoint` block (`id`, `enabled`, `base_url`, `auth`, + `allowed_methods`, `upstream_defaults`) and its `routes[]`, each route's `match`, `auth`, + `security_filter`, `forward`, `upstream` and reserved `rate_limit`. +|=== + +Both schemas set `additionalProperties: false` at *every* object level, so an *unknown key* -- a +typo or a stale field -- fails the boot rather than being silently ignored. The schemas mirror +this document field-for-field and are the *structural* gate only. The *cross-cutting* rules a +schema cannot express are enforced in code and listed under +link:#_configuration_validation_rules_summary[Configuration Validation Rules]: endpoint- and +route-`id` uniqueness across files, `upstream_defaults` and `allowed_methods` wholesale-replacement, +`allowed_methods` verb membership, topology-alias resolvability, and same-prefix disjointness. Every +violation -- from the schema gate and the code rules alike -- is collected in a single pass and +reported together, never one at a time, and any violation refuses the boot. + == Gateway Configuration (`gateway.yaml`) [source,yaml] @@ -743,6 +777,47 @@ downstream call -- stateless, per node; `cui-http` supplies retry and timeouts b (see link:architecture.adoc#_downstream_client[Architecture -- Downstream Client]). The upstream host is never written literally here -- only the alias-relative `path`. +[[_upstream_defaults]] +=== `upstream_defaults` + +Two upstream behaviour toggles -- upstream *retry* and HTTP *304 Not Modified* handling -- +declarable at two scopes, `gateway.yaml` (global) and per `endpoint`, and overridable per route. +Each route's effective value is materialized *once* at boot into the route table, so the request +pipeline reads a single resolved `true`/`false` and never re-derives the inheritance. + +[cols="1,3"] +|=== +| Field | Meaning + +| `retry.enabled` +| Whether idempotent upstream calls for the route are retried. This toggle only *gates* retry; + the retry *policy* (attempt count, idempotent-only) lives in the route's `upstream.retry` -- + see link:#_upstream[`upstream`]. + +| `not_modified.enabled` +| Whether the route participates in conditional-request / HTTP `304 Not Modified` handling. +|=== + +*Resolution (wholesale, no merge).* Each route's effective `retry` / `not_modified` toggle is, +in precedence order: + +* the route's own `upstream.retry.enabled` / `upstream.not_modified.enabled` if declared; else +* the *endpoint-level* `upstream_defaults` block if the endpoint declares one -- it *replaces* + the global block wholesale for that endpoint's routes (the same no-merge rule `auth` and + link:#_allowed_methods[`allowed_methods`] use); else +* the *global* `gateway.upstream_defaults` block if declared; else +* the built-in default -- both toggles *enabled*. + +Both `upstream_defaults` blocks (global and per endpoint) share one shape; an omitted toggle +defaults to `true`: + +[source,yaml] +---- +upstream_defaults: + retry: { enabled: true } + not_modified: { enabled: false } +---- + === `rate_limit` Reserved schema for per-node, in-memory token-bucket rate limiting From 3baf4e7e72ae431c26cd0cff0da6dda82980fdc3 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:24:21 +0200 Subject: [PATCH 13/21] docs(plan): update cui-http 2.1 refs, relocate Plan 02, sweep dead links cui-http 2.1 is released: update every doc/plan cui-http:2.1-SNAPSHOT reference to cui-http:2.1 and drop the snapshot-risk caveats that no longer apply (token-sheriff stays 1.0.0-SNAPSHOT). Relocate the delivered Plan 02 spec out of doc/plan/ into the plan workspace and sweep all 5 link:02-configuration-management.adoc references across 4 files to plain-text 'Plan 02 (delivered)' notes, so no dangling link survives. Co-Authored-By: Claude --- doc/adr/0005-module-structure.adoc | 2 +- doc/plan/01-base-implementation.adoc | 4 +- doc/plan/02-configuration-management.adoc | 238 ---------------------- doc/plan/03-request-pipeline.adoc | 17 +- doc/plan/README.adoc | 8 +- 5 files changed, 14 insertions(+), 255 deletions(-) delete mode 100644 doc/plan/02-configuration-management.adoc diff --git a/doc/adr/0005-module-structure.adoc b/doc/adr/0005-module-structure.adoc index 89bcef8..873da87 100644 --- a/doc/adr/0005-module-structure.adoc +++ b/doc/adr/0005-module-structure.adoc @@ -12,7 +12,7 @@ API Sheriff's production code currently lives in a single deployable Quarkus mod (`api-sheriff`), alongside the `integration-tests` and `benchmarks` coordinator modules. As the request pipeline (link:../plan/03-request-pipeline.adoc[Plan 03]) lands, that module gains a substantial body of logic that is *not* inherently Quarkus-specific: the immutable -configuration model and validators (link:../plan/02-configuration-management.adoc[Plan 02]), +configuration model and validators (Plan 02, delivered), the event/counter system, the route table and matchers, the forward policy, and the boot-time assembly of per-route runtime objects. Only a thin outer layer is genuinely framework-bound: the CDI producers/observers, the Vert.x HTTP edge, the MicroProfile-config plumbing, and the diff --git a/doc/plan/01-base-implementation.adoc b/doc/plan/01-base-implementation.adoc index b335a42..0052ce0 100644 --- a/doc/plan/01-base-implementation.adoc +++ b/doc/plan/01-base-implementation.adoc @@ -122,7 +122,7 @@ One real end-to-end path, deliberately interim but on the final architecture lin * A Vert.x route handler (`quarkus-vertx-http`, already present transitively) registered as catch-all on the data plane, executing per-request on a *virtual thread*. * One route configured via MicroProfile config properties (interim; replaced by the real - config subsystem in link:02-configuration-management.adoc[Plan 02]): + config subsystem in Plan 02, delivered): `sheriff.proxy.path-prefix` and `sheriff.proxy.upstream-url`. * Forwarding: method + path remainder + query + body pass through; hop-by-hop headers stripped; JDK `HttpClient` blocking `send()` on the virtual thread (the `cui-http` @@ -207,7 +207,7 @@ must be green after every one. == Out of Scope -* Real configuration files/schemas (link:02-configuration-management.adoc[Plan 02]). +* Real configuration files/schemas (Plan 02, delivered). * Security filtering, token validation, forward policy, events/metrics/error contract (link:03-request-pipeline.adoc[Plan 03]). * gRPC/WebSocket/GraphQL processors, TLS passthrough, mTLS, BFF variants (later plans). diff --git a/doc/plan/02-configuration-management.adoc b/doc/plan/02-configuration-management.adoc deleted file mode 100644 index 98cdb5b..0000000 --- a/doc/plan/02-configuration-management.adoc +++ /dev/null @@ -1,238 +0,0 @@ -= Plan 02 -- Configuration Management -:toc: -:toclevels: 3 -:sectnums: - -== Goal - -Implement the complete configuration subsystem described in -link:../configuration.adoc[Configuration Model]: a JSON Schema per config artifact, -loading and resolution logic (`gateway.yaml`, `endpoints/*.yaml`, `topology.properties`), -full boot-time validation with fail-fast semantics, and immutable value objects the rest of -the gateway consumes. At the end of this plan, configuration management is *feature-complete* -for the initial (Static-mode) scope -- even where the features consuming a given block -(e.g. `oidc`) are not built yet, the block parses, validates, and freezes. - -== Required Reading (before implementation) - -Read these documents *in full* before touching code; the validation rules and semantics are -specified there, not here: - -. link:../configuration.adoc[Configuration Model] -- the authoritative schema, field - reference, defaults-for-omitted-blocks, route-ordering/disjointness rules, and the - link:../configuration.adoc#_configuration_validation_rules_summary[validation-rules summary]. -. link:../adr/0004-topology-indirection.adoc[ADR-0004] -- alias -> URL indirection, - `TOPOLOGY_` env override, decompose-on-read. -. link:../adr/0002-initial-scope.adoc[ADR-0002] -- Static mode only; `metadata.config_version` - is an audit stamp. -. link:../architecture.adoc[Architecture], section "Configuration Subsystem" -- immutability - as a security property, refuse-to-start semantics. -. link:01-base-implementation.adoc[Plan 01] -- the minimal proxy this plan replaces the - hardcoded configuration of. - -== Prerequisites - -* Plan 01 delivered: modules build locally and on CI; a minimal proxy exists with a - *hardcoded/property-based* single route that this plan replaces with real config files. - -== Design Decisions - -=== D1 -- Schema technology: JSON Schema (draft 2020-12), validated at boot - -One schema file per YAML artifact: - -[cols="1,2,2"] -|=== -| Artifact | Schema file | Referenced from - -| `gateway.yaml` -| `api-sheriff/src/main/resources/schema/gateway.schema.json` -| `# yaml-language-server: $schema=...` header comment in every sample/test config; linked - from `configuration.adoc` - -| `endpoints/*.yaml` -| `api-sheriff/src/main/resources/schema/endpoint.schema.json` -| same mechanism - -| `topology.properties` -| *no schema* -- `.properties` has no schema ecosystem; validated programmatically - (alias pattern `[A-Z][A-Z0-9_]*`, value must parse as URL) -| validation rules documented in `configuration.adoc` -|=== - -Schema posture: - -* `"additionalProperties": false` everywhere -- unknown keys fail the boot, per - link:../configuration.adoc[Configuration Model] ("unknown keys ... fail the boot"). -* The schema encodes *structure and per-field constraints* (types, enums such as - `require: none|bearer|session`, `emit: x-forwarded|both`, ranges, required blocks such as - `endpoint.auth`). *Cross-cutting rules* (route disjointness, alias resolution, - effective-auth -> required blocks, passthrough/host collisions, timeout whole-second rule, - trust-all CIDR rejection, `${ENV_VAR}`-only secrets) are code -- a `ConfigValidator` layer - that runs after schema validation and reports *all* violations, not just the first. -* The schema files are also the editor/IDE contract (yaml-language-server) and are linked - from the documentation -- one source of truth for structure. - -=== D2 -- Parsing/binding stack (requires dependency approval) - -Parse YAML with *Jackson* (`jackson-dataformat-yaml`, via the Quarkus-managed Jackson) into a -tree, validate the tree against the schema with *`com.networknt:json-schema-validator`*, then -bind to immutable Java records. Rationale: schema violations report JSON-Pointer paths (good -boot errors), Jackson is already on the classpath (`quarkus-resteasy-jackson`), and the -networknt validator is a plain library that works in native image. - -[IMPORTANT] -==== -New dependencies -- *require explicit user approval before adding* (CLAUDE.md rule): - -* `com.fasterxml.jackson.dataformat:jackson-dataformat-yaml` (version from the Quarkus BOM) -* `com.networknt:json-schema-validator` (schema validation at boot) - -Fallback if the networknt dependency is rejected: keep the schema files as documentation/IDE -artifacts only and express *all* structural rules in the programmatic validator. The plan's -deliverables do not change, only the implementation of the structural half. -==== - -=== D3 -- Configuration object model - -Immutable Java records under `de.cuioss.sheriff.api.config.model`: - -* `GatewayConfig` (root aggregate) -- `TlsConfig`, `SecurityHeadersConfig`, - `SecurityDefaultsConfig`, `ForwardedConfig`, `TokenValidationConfig` (+`IssuerConfig` - mirror), `OidcConfig` (parsed and validated now; consumed by later BFF plans), `Metadata`. -* `EndpointConfig` -- `id` (the unique endpoint name), `enabled` (default `true`), - `baseUrlAlias`, mandatory `AuthConfig`, optional `allowedMethods` (verb allowlist), and - `List`. -* `RouteConfig` -- `MatchConfig`, `AuthConfig` (nullable = inherit endpoint), - `SecurityFilterConfig`, `ForwardConfig`, `UpstreamConfig`, reserved `RateLimitConfig`, - `protocol` enum. -* `ResolvedTopology` -- alias -> `ResolvedUpstream{scheme, host, port, basePath}` - (decompose-once, ADR-0004). -* `RouteTable` -- the merged, disjointness-checked, longest-prefix-ordered immutable table - with the *effective auth* (endpoint default or wholesale route override) *and effective - `allowedMethods`* (endpoint list, else global, else the standard set -- no inheritance) - materialized per route, so downstream pipeline code never re-implements inheritance. Only - routes of *enabled* endpoints are included; a disabled endpoint (`enabled: false` or - `ENDPOINT__ENABLED=false`) contributes no rows and is exempt from alias resolution and - disjointness checks. - -Empty collections over null; `Optional` for optional scalar returns; Lombok only where records -don't fit. Every package gets `package-info.java`. - -=== D4 -- Loading pipeline and CDI wiring - ----- -config dir (property `sheriff.config.dir`, default `conf/`; overridable via -SHERIFF_CONFIG_DIR for the container) - | - v -1. read gateway.yaml, endpoints/*.yaml (sorted, for deterministic error output), - topology.properties -2. schema-validate each YAML document (D1/D2) -> collect ALL errors -3. bind to records (D3) -4. resolve ${ENV_VAR} secret references -> missing env var = boot error -5. resolve endpoint enablement (env ENDPOINT__ENABLED wins over file `enabled`); - drop disabled endpoints before the checks that only concern live routes -6. resolve topology aliases (env TOPOLOGY_ wins over file), decompose URLs - -- only for ENABLED endpoints' aliases -7. cross-validation (ConfigValidator: the full rule list from configuration.adoc - "Configuration Validation Rules (summary)") -> collect ALL errors -8. build RouteTable (enabled endpoints only; merge, disjointness check, longest-prefix - ordering, effective-auth + effective-allowed_methods materialization; weakening - overrides logged as boot-log WARNs) -9. freeze -> GatewayConfig + RouteTable exposed as @ApplicationScoped CDI beans ----- - -* Failure semantics: any error in steps 1-8 -> log every collected violation with - file/pointer context, then *fail startup* (throw from the CDI producer / startup observer; - Quarkus exits non-zero). Never serve traffic on partial config. -* `CONFIG_LOADED` INFO LogRecord (with `metadata.config_version` audit stamp) on success; - validation failures use structured ERROR LogRecords. Document all new records in - `doc/LogMessages.adoc` (create it -- CLAUDE.md requires it, it does not exist yet). -* The producer replaces Plan 01's hardcoded route: the minimal proxy now reads `RouteTable`. - -=== D5 -- What "reference the schema" means concretely - -* Sample config set under `api-sheriff/src/test/resources/config/valid/` (used by tests) and - a runnable example under `integration-tests/src/main/docker/sheriff-config/` (mounted into - the container) -- every YAML file carries the `$schema` header comment. -* `configuration.adoc` gains a short "Schemas" section linking the two schema files as the - machine-readable contract (doc change, same PR). - -== Work Breakdown - -. *Schemas* -- author `gateway.schema.json` + `endpoint.schema.json` mirroring - `configuration.adoc` exactly (every field of the two exhibits, enums, required lists, - `additionalProperties: false`). Review against the doc field-by-field; the doc governs. -. *Model records* (D3) + builders where needed; unit tests via `cui-test-value-objects` / - `cui-test-generator` contracts. -. *Loader* -- YAML read + schema validation + binding; error aggregation with - file/JSON-Pointer context. -. *Topology resolution* -- properties parsing, env override precedence, URL decomposition; - unit tests incl. malformed URL, missing alias, env-wins-over-file. -. *Endpoint enablement* -- resolve the `enabled` flag with `ENDPOINT__ENABLED` env - precedence (mirrors topology resolution: env wins over file, default `true`); disabled - endpoints are dropped before route-table assembly and exempt from alias resolution; unit - tests incl. env-disables-file-enabled, env-enables-file-disabled, disabled-endpoint-alias- - need-not-resolve. -. *ConfigValidator* -- one small rule class per bullet of the validation-rules summary in - `configuration.adoc`, each with parameterized tests (valid + violating fixtures): - endpoint-id uniqueness, route-id uniqueness, alias resolution (enabled endpoints only), - endpoint-auth mandatory, effective-auth -> `token_validation`/`oidc` presence, same-prefix - disjointness (host/methods/header value), `allowed_methods` TRACE/CONNECT rejection + - `match.methods` within the effective `allowed_methods`, passthrough-SNI vs `match.host` collision, - passthrough alias base-path rule, trusted-proxies presence + trust-all rejection, - whole-second timeouts, `${ENV_VAR}`-only secrets, cookie/server session-mode conditionals, - CORS wildcard+credentials rejection. -. *RouteTable* -- merge/ordering/disjointness + effective-auth + effective-`allowed_methods` - materialization (enabled endpoints only); property-based tests with `@GeneratorsSource` for - prefix/host/method permutations. -. *CDI wiring* -- producer, startup observer, fail-fast test (`QuarkusUnitTest` with broken - config asserting startup failure), `CONFIG_LOADED` log assertion via - `cui-test-juli-logger`. -. *Integration tests* -- mount the example `sheriff-config/` into the container - (docker-compose volume), point the minimal proxy route at the Plan 01 upstream target via - `topology.properties`; IT asserts (a) gateway boots with the mounted config, (b) the routed - request reaches the upstream, (c) readiness (`/q/health/ready`) reflects loaded config. Add - one negative script-level check: container with an invalid config exits non-zero. -. *Docs & cleanup* -- `doc/LogMessages.adoc`, schema references in `configuration.adoc`, - remove any remaining Plan 01 hardcoded-route configuration. - -== Execution Workflow - -. Create a feature branch from up-to-date `main`: `feature/plan-02-configuration-management`. -. Implement the work breakdown above. -. Verify everything locally -- both must pass with zero errors/warnings: - `./mvnw -Ppre-commit clean verify -DskipTests` (quality gate) and `./mvnw clean install`. -. Commit and push the branch. -. Create the PR (`gh pr create --repo cuioss/API-Sheriff --base main ...`). -. Wait ~5 minutes for the AI reviewers to complete, and `gh pr checks --watch` until all - checks are green. -. Handle *every* review comment: fix + commit + push + reply + resolve, or reply with the - reason for not fixing + resolve; when uncertain, ask the user before acting. -. Merge the PR, then `git checkout main && git pull`. - -If the plan has to be split into multiple PRs, this workflow applies to each PR; the build -must be green after every one. - -== Acceptance Criteria - -* Both schema files exist, match `configuration.adoc` field-for-field, and every sample/test - YAML references them. -* All validation rules from the summary section are enforced with at least one negative test - each; error output names file + path for every violation in one pass. -* `GatewayConfig`/`RouteTable` are immutable, CDI-injectable, and consumed by the proxy. -* Invalid config => non-zero exit, no traffic served (verified by unit-level Quarkus test and - container-level negative IT). -* `./mvnw -Ppre-commit clean verify -DskipTests` and `./mvnw clean install` pass; coverage of - the new `config` packages >= 80%. -* `./mvnw clean verify -Pintegration-tests -pl integration-tests -am` passes locally and via - `.github/workflows/integration-tests.yml`. - -== Out of Scope - -* Consuming `oidc`, `tls.passthrough_sni`, `mtls`, `security_filter`, `forwarded`, or - `token_validation` at runtime -- the blocks are *parsed and validated* here; their runtime - behaviour is link:03-request-pipeline.adoc[Plan 03] (+ later variant plans). -* Dynamic configuration / discovery (deferred, ADR-0002). -* `rate_limit` behaviour (reserved field; accepted and ignored). diff --git a/doc/plan/03-request-pipeline.adoc b/doc/plan/03-request-pipeline.adoc index 0a80ddd..e79f929 100644 --- a/doc/plan/03-request-pipeline.adoc +++ b/doc/plan/03-request-pipeline.adoc @@ -39,8 +39,7 @@ the event system, metrics, structured logging, and the RFC 9457 error contract. `TokenValidator`). . `cui-http/doc/forwarded-header-resolution.adoc` (in the cui-http repository) -- the authoritative resolver behaviour contract. -. link:02-configuration-management.adoc[Plan 02] -- the `GatewayConfig`/`RouteTable` model - this plan consumes. +. Plan 02 (delivered) -- the `GatewayConfig`/`RouteTable` model this plan consumes. == Prerequisites @@ -49,20 +48,18 @@ the event system, metrics, structured logging, and the RFC 9457 error contract. == Design Decisions -=== D1 -- cui-http 2.1-SNAPSHOT (mandatory) +=== D1 -- cui-http 2.1 (mandatory) The `de.cuioss.http.forwarded` package (`ForwardedHeaderResolver`, `ResolvedForwarding`, -`ForwardedResolverConfig`) exists only in `de.cuioss:cui-http:2.1-SNAPSHOT` -- *verified: it -is absent from the released 2.0.0 jar*. This plan therefore pins the latest cui-http -snapshot. +`ForwardedResolverConfig`) exists only in `de.cuioss:cui-http:2.1` -- *it is absent from the +released 2.0.0 jar*. This plan therefore pins `cui-http:2.1`. [IMPORTANT] ==== Dependency changes -- *require explicit user approval before adding*: -* `de.cuioss:cui-http:2.1-SNAPSHOT` (security pipelines, forwarded resolver, HttpHandler - stack). Snapshot risk: CI must resolve it (Sonatype snapshots repository or a version bump - to a 2.1 release when available); pin and re-verify before merge. +* `de.cuioss:cui-http:2.1` (security pipelines, forwarded resolver, HttpHandler + stack). * `de.cuioss.sheriff.token:token-sheriff-validation-quarkus:1.0.0-SNAPSHOT` (direct; core `token-sheriff-validation` arrives transitively). Same snapshot caveat -- TokenSheriff is unreleased. @@ -186,7 +183,7 @@ when any bearer route exists, the `TokenValidator`'s issuer/JWKS loading status == Work Breakdown -. *Dependencies* (after approval): cui-http 2.1-SNAPSHOT, +. *Dependencies* (after approval): cui-http 2.1, token-sheriff-validation-quarkus 1.0.0-SNAPSHOT; verify CI snapshot resolution first. . *Event system + error edge* (D4) -- pure unit-tested base package first; problem+json rendering tests for every row of the error-contract table. diff --git a/doc/plan/README.adoc b/doc/plan/README.adoc index ed9e4ed..e4ca9e4 100644 --- a/doc/plan/README.adoc +++ b/doc/plan/README.adoc @@ -21,7 +21,7 @@ The plans build strictly on one another: integration setup, a proxied-route WRK benchmark, stale scaffolding removed. | -- -| link:02-configuration-management.adoc[02 -- Configuration Management] +| 02 -- Configuration Management (delivered) | JSON Schemas for `gateway.yaml` / `endpoints/*.yaml`, the loading/resolution/validation pipeline (topology indirection, fail-fast boot), immutable `GatewayConfig` + `RouteTable`, config mounted into the integration containers. @@ -30,7 +30,7 @@ The plans build strictly on one another: | link:03-request-pipeline.adoc[03 -- Request Pipeline] | The data plane for standard HTTP verbs: cui-http inbound filtering (basic + per-route), route selection, protocol-processor loading, offline bearer validation, zero-trust forward - policy with regenerated forwarding headers (cui-http 2.1-SNAPSHOT), dispatch with + policy with regenerated forwarding headers (cui-http:2.1), dispatch with retry/circuit breaker, plus the event system, metrics, logging, and RFC 9457 error edge. | Plans 01 + 02 |=== @@ -48,6 +48,6 @@ BFF variants (`token-sheriff-client`), rate limiting. not only at plan completion. * *Dependency approvals*: every new Maven dependency a plan needs is flagged inside the plan with an IMPORTANT block and requires explicit user approval before it is added - (CLAUDE.md rule). Snapshot dependencies (`cui-http:2.1-SNAPSHOT`, - `token-sheriff-*:1.0.0-SNAPSHOT`) additionally need a CI-resolvability check before merge. + (CLAUDE.md rule). The remaining snapshot dependency (`token-sheriff-*:1.0.0-SNAPSHOT`) + additionally needs a CI-resolvability check before merge. * Pre-1.0 rules apply throughout: delete instead of deprecate, break freely, keep APIs clean. From b380e30124f564c55de48c8f0c8aee6b80102a49 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:36:54 +0200 Subject: [PATCH 14/21] chore(format): apply pre-commit import and throws normalizations OpenRewrite pre-commit normalizations for the config subsystem: canonical import ordering in ConfigProducer, an import-group blank line in three config tests, and broadened throws Exception on the ConfigProducer/ConfigFailFast test methods. Committed so the quality gate is idempotent on these files. Co-Authored-By: Claude --- .../de/cuioss/sheriff/api/quarkus/ConfigProducer.java | 3 +-- .../cuioss/sheriff/api/config/RouteTableBuilderTest.java | 1 + .../cuioss/sheriff/api/quarkus/ConfigFailFastTest.java | 7 ++++--- .../cuioss/sheriff/api/quarkus/ConfigProducerTest.java | 9 +++++---- 4 files changed, 11 insertions(+), 9 deletions(-) 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 index 74f9f7d..8cf6bfd 100644 --- 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 @@ -18,8 +18,6 @@ import java.nio.file.Path; import java.util.List; -import org.eclipse.microprofile.config.inject.ConfigProperty; - import de.cuioss.sheriff.api.config.ConfigLogMessages; import de.cuioss.sheriff.api.config.RouteTableBuilder; import de.cuioss.sheriff.api.config.load.ConfigError; @@ -39,6 +37,7 @@ 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 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 index ad84de0..c2a06a3 100644 --- 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 @@ -27,6 +27,7 @@ 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; diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java index 2ac1aad..cc60ca7 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigFailFastTest.java @@ -21,6 +21,7 @@ import java.net.URISyntaxException; import java.nio.file.Path; + import org.junit.jupiter.api.Test; import de.cuioss.test.juli.LogAsserts; @@ -45,7 +46,7 @@ private static ConfigProducer producerForBrokenConfig() throws URISyntaxExceptio } @Test - void shouldThrowFromProducerOnBrokenConfig() throws URISyntaxException { + void shouldThrowFromProducerOnBrokenConfig() throws Exception { ConfigProducer producer = producerForBrokenConfig(); assertThrows(IllegalStateException.class, producer::gatewayConfig, @@ -53,7 +54,7 @@ void shouldThrowFromProducerOnBrokenConfig() throws URISyntaxException { } @Test - void shouldFailStartupOnBrokenConfig() throws URISyntaxException { + void shouldFailStartupOnBrokenConfig() throws Exception { ConfigProducer producer = producerForBrokenConfig(); assertThrows(IllegalStateException.class, () -> producer.onStartup(null), @@ -61,7 +62,7 @@ void shouldFailStartupOnBrokenConfig() throws URISyntaxException { } @Test - void shouldLogEveryViolationAndTheAbort() throws URISyntaxException { + void shouldLogEveryViolationAndTheAbort() throws Exception { ConfigProducer producer = producerForBrokenConfig(); assertThrows(IllegalStateException.class, producer::gatewayConfig); diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java index 3b6c60a..1c32d34 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/api/quarkus/ConfigProducerTest.java @@ -25,6 +25,7 @@ import java.nio.file.Files; import java.nio.file.Path; + import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -59,7 +60,7 @@ private ConfigProducer producerForValidConfig() throws IOException { } @Test - void shouldProduceBeansFromValidConfig() throws IOException { + void shouldProduceBeansFromValidConfig() throws Exception { ConfigProducer producer = producerForValidConfig(); GatewayConfig gateway = producer.gatewayConfig(); @@ -71,7 +72,7 @@ void shouldProduceBeansFromValidConfig() throws IOException { } @Test - void shouldLogConfigLoadedOnSuccess() throws IOException { + void shouldLogConfigLoadedOnSuccess() throws Exception { ConfigProducer producer = producerForValidConfig(); producer.gatewayConfig(); @@ -80,7 +81,7 @@ void shouldLogConfigLoadedOnSuccess() throws IOException { } @Test - void shouldBuildOnceAndCacheTheResult() throws IOException { + void shouldBuildOnceAndCacheTheResult() throws Exception { ConfigProducer producer = producerForValidConfig(); GatewayConfig first = producer.gatewayConfig(); @@ -90,7 +91,7 @@ void shouldBuildOnceAndCacheTheResult() throws IOException { } @Test - void shouldAssembleEagerlyOnStartupForValidConfig() throws IOException { + void shouldAssembleEagerlyOnStartupForValidConfig() throws Exception { ConfigProducer producer = producerForValidConfig(); assertDoesNotThrow(() -> producer.onStartup(null), From 1527be4986c5204c969bd5abf548925588418ce6 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:10:55 +0200 Subject: [PATCH 15/21] fix(security): redact OIDC secrets in config model toString() Security-audit hardening: OidcConfig and its nested Session record used the default Java-record toString(), which printed the resolved clientSecret, encryptionKey, and previousKey verbatim. ConfigLoader substitutes ${ENV_VAR} references with real secret values before binding, so an unredacted toString() would leak them into any log line, exception message, or debugger view. Added redacting toString() overrides that preserve presence without exposing the values. Co-Authored-By: Claude --- .../sheriff/api/config/model/OidcConfig.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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 index 83df335..d9ffe0d 100644 --- 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 @@ -57,6 +57,33 @@ public record OidcConfig(Optional issuer, Optional clientId, Opt stepUp = stepUp == null ? Optional.empty() : stepUp; } + /** + * 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. * @@ -120,6 +147,21 @@ public record Session(Optional mode, Optional store, Optional Date: Mon, 13 Jul 2026 19:08:46 +0200 Subject: [PATCH 16/21] fix(config): register schemas + config model for native image The native Quarkus app failed to boot because the file-based config subsystem relied on three JVM-only behaviors: - Bundled JSON schemas (schema/*.json) were excluded from the native image, so ConfigLoader.getResourceAsStream returned null. - The immutable config model records lacked reflection registration, so native Jackson databind into them failed. - ObjectMapper.findAndAddModules() discovers the Jdk8 module via the ServiceLoader, which does not resolve in a native image, leaving Optional-typed record components unbindable. Add schema/*.json to quarkus.native.resources.includes, consolidate reflection registration of the Jackson-bound config records, nested records, and enums in one ConfigModelReflection holder, and register the Jdk8 module explicitly. No config semantics change. Verified: native integration-tests suite boots and passes (10 ITs); api-sheriff JVM module tests pass (131). Co-Authored-By: Claude --- .../sheriff/api/config/load/ConfigLoader.java | 6 +- .../api/quarkus/ConfigModelReflection.java | 94 +++++++++++++++++++ .../src/main/resources/application.properties | 2 +- 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/api/quarkus/ConfigModelReflection.java 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 index 6fa365b..df01ab3 100644 --- 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 @@ -40,6 +40,7 @@ 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; @@ -248,10 +249,13 @@ private static boolean hasErrorsFor(String file, List errors) { 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) - .findAndAddModules() + .addModule(new Jdk8Module()) .addModule(module) .build(); } 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/resources/application.properties b/api-sheriff/src/main/resources/application.properties index dc27a3e..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 From 5f0dc48cbe9f85cb372d20a00bfa0ec94d724695 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:24:06 +0200 Subject: [PATCH 17/21] fix(config): guard Optional access in header-distinguish check headersDistinguish called HeaderMatcher.value() separately for the isPresent() guard and the get() access, so static dataflow analysis could not prove the value was present at the get() call (Sonar java:S3655, new-code reliability rating C). Bind each header value to a local Optional so the presence check and the access target the same instance. No behavior change. Co-Authored-By: Claude --- .../de/cuioss/sheriff/api/config/RouteTableBuilder.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 index a9d69e3..f919e17 100644 --- 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 @@ -22,6 +22,7 @@ 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; @@ -164,9 +165,11 @@ private static boolean methodsOverlap(MatchConfig first, MatchConfig second) { private static boolean headersDistinguish(MatchConfig first, MatchConfig second) { for (HeaderMatcher headerA : first.headers()) { for (HeaderMatcher headerB : second.headers()) { + Optional valueA = headerA.value(); + Optional valueB = headerB.value(); if (headerA.name().equalsIgnoreCase(headerB.name()) - && headerA.value().isPresent() && headerB.value().isPresent() - && !headerA.value().get().equals(headerB.value().get())) { + && valueA.isPresent() && valueB.isPresent() + && !valueA.get().equals(valueB.get())) { return true; } } From b0413d9fdbd0852ff8e0ce03a38ed47d36872de4 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:40:10 +0200 Subject: [PATCH 18/21] fix(config): use requireNonNullElse for Optional normalization (Sonar S2789) Co-Authored-By: Claude --- .../api/config/model/EndpointConfig.java | 2 +- .../api/config/model/ForwardedConfig.java | 5 +- .../api/config/model/GatewayConfig.java | 17 ++++--- .../api/config/model/IssuerConfig.java | 8 +-- .../sheriff/api/config/model/MatchConfig.java | 6 +-- .../sheriff/api/config/model/Metadata.java | 3 +- .../sheriff/api/config/model/OidcConfig.java | 49 ++++++++++--------- .../api/config/model/RateLimitConfig.java | 5 +- .../sheriff/api/config/model/RouteConfig.java | 12 ++--- .../config/model/SecurityDefaultsConfig.java | 3 +- .../config/model/SecurityFilterConfig.java | 13 ++--- .../config/model/SecurityHeadersConfig.java | 17 ++++--- .../sheriff/api/config/model/TlsConfig.java | 7 +-- .../api/config/model/UpstreamConfig.java | 25 +++++----- 14 files changed, 91 insertions(+), 81 deletions(-) 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 index 900dc31..8049aae 100644 --- 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 @@ -58,7 +58,7 @@ public record EndpointConfig(String id, boolean enabled, String baseUrl, AuthCon baseUrl = Objects.requireNonNull(baseUrl, "baseUrl"); auth = Objects.requireNonNull(auth, "auth"); allowedMethods = allowedMethods == null ? List.of() : List.copyOf(allowedMethods); - upstreamDefaults = upstreamDefaults == null ? Optional.empty() : upstreamDefaults; + 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/ForwardedConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/ForwardedConfig.java index 08116b7..cea491c 100644 --- 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 @@ -16,6 +16,7 @@ package de.cuioss.sheriff.api.config.model; import java.util.List; +import java.util.Objects; import java.util.Optional; import lombok.Builder; @@ -46,7 +47,7 @@ public record ForwardedConfig(List trustedProxies, Optional tru */ public ForwardedConfig { trustedProxies = trustedProxies == null ? List.of() : List.copyOf(trustedProxies); - trustSchemeHost = trustSchemeHost == null ? Optional.empty() : trustSchemeHost; - emit = emit == null ? Optional.empty() : emit; + 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 index 3562d36..8ff6d0b 100644 --- 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 @@ -16,6 +16,7 @@ package de.cuioss.sheriff.api.config.model; import java.util.List; +import java.util.Objects; import java.util.Optional; import lombok.Builder; @@ -55,14 +56,14 @@ public record GatewayConfig(int version, Optional metadata, Optional audience public IssuerConfig { name = Objects.requireNonNull(name, "name"); issuer = Objects.requireNonNull(issuer, "issuer"); - audience = audience == null ? Optional.empty() : audience; - jwks = jwks == null ? Optional.empty() : jwks; + audience = Objects.requireNonNullElse(audience, Optional.empty()); + jwks = Objects.requireNonNullElse(jwks, Optional.empty()); } /** @@ -63,8 +63,8 @@ public record Jwks(String source, Optional url, Optional file) { */ public Jwks { source = Objects.requireNonNull(source, "source"); - url = url == null ? Optional.empty() : url; - file = file == null ? Optional.empty() : file; + 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 index ab5ffb4..3316592 100644 --- 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 @@ -43,7 +43,7 @@ public record MatchConfig(String pathPrefix, List methods, Optional< public MatchConfig { pathPrefix = Objects.requireNonNull(pathPrefix, "pathPrefix"); methods = methods == null ? List.of() : List.copyOf(methods); - host = host == null ? Optional.empty() : host; + host = Objects.requireNonNullElse(host, Optional.empty()); headers = headers == null ? List.of() : List.copyOf(headers); } @@ -65,8 +65,8 @@ public record HeaderMatcher(String name, Optional present, Optional configVersion) { * {@link Optional#empty()}. */ public Metadata { - configVersion = configVersion == null ? Optional.empty() : configVersion; + 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 index d9ffe0d..a267c1a 100644 --- 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 @@ -16,6 +16,7 @@ package de.cuioss.sheriff.api.config.model; import java.util.List; +import java.util.Objects; import java.util.Optional; import lombok.Builder; @@ -47,14 +48,14 @@ public record OidcConfig(Optional issuer, Optional clientId, Opt * absent components. */ public OidcConfig { - issuer = issuer == null ? Optional.empty() : issuer; - clientId = clientId == null ? Optional.empty() : clientId; - clientSecret = clientSecret == null ? Optional.empty() : clientSecret; + 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 = redirectUri == null ? Optional.empty() : redirectUri; - logout = logout == null ? Optional.empty() : logout; - session = session == null ? Optional.empty() : session; - stepUp = stepUp == null ? Optional.empty() : stepUp; + redirectUri = Objects.requireNonNullElse(redirectUri, Optional.empty()); + logout = Objects.requireNonNullElse(logout, Optional.empty()); + session = Objects.requireNonNullElse(session, Optional.empty()); + stepUp = Objects.requireNonNullElse(stepUp, Optional.empty()); } /** @@ -104,10 +105,10 @@ public record Logout(Optional path, Optional postLogoutRedirectU * Canonical constructor normalizing absent components to {@link Optional#empty()}. */ public Logout { - path = path == null ? Optional.empty() : path; - postLogoutRedirectUri = postLogoutRedirectUri == null ? Optional.empty() : postLogoutRedirectUri; - finalRedirect = finalRedirect == null ? Optional.empty() : finalRedirect; - backchannelPath = backchannelPath == null ? Optional.empty() : backchannelPath; + path = Objects.requireNonNullElse(path, Optional.empty()); + postLogoutRedirectUri = Objects.requireNonNullElse(postLogoutRedirectUri, Optional.empty()); + finalRedirect = Objects.requireNonNullElse(finalRedirect, Optional.empty()); + backchannelPath = Objects.requireNonNullElse(backchannelPath, Optional.empty()); } } @@ -138,14 +139,14 @@ public record Session(Optional mode, Optional store, Optional enabled, Optional leewaySeconds * Canonical constructor normalizing absent components to {@link Optional#empty()}. */ public Refresh { - enabled = enabled == null ? Optional.empty() : enabled; - leewaySeconds = leewaySeconds == null ? Optional.empty() : leewaySeconds; - onFailure = onFailure == null ? Optional.empty() : onFailure; + enabled = Objects.requireNonNullElse(enabled, Optional.empty()); + leewaySeconds = Objects.requireNonNullElse(leewaySeconds, Optional.empty()); + onFailure = Objects.requireNonNullElse(onFailure, Optional.empty()); } } @@ -222,8 +223,8 @@ public record StepUp(Optional enabled, Optional honorUpstreamC * Canonical constructor normalizing absent components to {@link Optional#empty()}. */ public StepUp { - enabled = enabled == null ? Optional.empty() : enabled; - honorUpstreamChallenge = honorUpstreamChallenge == null ? Optional.empty() : honorUpstreamChallenge; + 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/RateLimitConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/RateLimitConfig.java index 70dffc6..d4df545 100644 --- 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 @@ -15,6 +15,7 @@ */ package de.cuioss.sheriff.api.config.model; +import java.util.Objects; import java.util.Optional; import lombok.Builder; @@ -37,7 +38,7 @@ public record RateLimitConfig(Optional requestsPerSecond, Optional protocol, MatchConfig ma public RouteConfig { id = Objects.requireNonNull(id, "id"); match = Objects.requireNonNull(match, "match"); - protocol = protocol == null ? Optional.empty() : protocol; - auth = auth == null ? Optional.empty() : auth; - securityFilter = securityFilter == null ? Optional.empty() : securityFilter; - forward = forward == null ? Optional.empty() : forward; - upstream = upstream == null ? Optional.empty() : upstream; - rateLimit = rateLimit == null ? Optional.empty() : rateLimit; + 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/SecurityDefaultsConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/SecurityDefaultsConfig.java index 4080c8b..16248f0 100644 --- 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 @@ -15,6 +15,7 @@ */ package de.cuioss.sheriff.api.config.model; +import java.util.Objects; import java.util.Optional; /** @@ -36,6 +37,6 @@ public record SecurityDefaultsConfig(Optional profile) { * {@link Optional#empty()}. */ public SecurityDefaultsConfig { - profile = profile == null ? Optional.empty() : profile; + 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 index 682e248..df13ff1 100644 --- 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 @@ -16,6 +16,7 @@ package de.cuioss.sheriff.api.config.model; import java.util.List; +import java.util.Objects; import java.util.Optional; import lombok.Builder; @@ -54,13 +55,13 @@ public record SecurityFilterConfig(Optional profile, List allowe * components. */ public SecurityFilterConfig { - profile = profile == null ? Optional.empty() : profile; + profile = Objects.requireNonNullElse(profile, Optional.empty()); allowedPaths = allowedPaths == null ? List.of() : List.copyOf(allowedPaths); - maxHeaderCount = maxHeaderCount == null ? Optional.empty() : maxHeaderCount; - maxHeaderValueLength = maxHeaderValueLength == null ? Optional.empty() : maxHeaderValueLength; - maxQueryParams = maxQueryParams == null ? Optional.empty() : maxQueryParams; - maxParamValueLength = maxParamValueLength == null ? Optional.empty() : maxParamValueLength; - maxBodyBytes = maxBodyBytes == null ? Optional.empty() : maxBodyBytes; + 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 index 51e8edd..61b8865 100644 --- 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 @@ -16,6 +16,7 @@ package de.cuioss.sheriff.api.config.model; import java.util.List; +import java.util.Objects; import java.util.Optional; import lombok.Builder; @@ -41,10 +42,10 @@ public record SecurityHeadersConfig(Optional hsts, Optional conte * Canonical constructor normalizing absent components to {@link Optional#empty()}. */ public SecurityHeadersConfig { - hsts = hsts == null ? Optional.empty() : hsts; - contentTypeNosniff = contentTypeNosniff == null ? Optional.empty() : contentTypeNosniff; - frameDeny = frameDeny == null ? Optional.empty() : frameDeny; - cors = cors == null ? Optional.empty() : cors; + hsts = Objects.requireNonNullElse(hsts, Optional.empty()); + contentTypeNosniff = Objects.requireNonNullElse(contentTypeNosniff, Optional.empty()); + frameDeny = Objects.requireNonNullElse(frameDeny, Optional.empty()); + cors = Objects.requireNonNullElse(cors, Optional.empty()); } /** @@ -63,8 +64,8 @@ public record Hsts(Optional maxAge, Optional includeSubdomains * Canonical constructor normalizing absent components to {@link Optional#empty()}. */ public Hsts { - maxAge = maxAge == null ? Optional.empty() : maxAge; - includeSubdomains = includeSubdomains == null ? Optional.empty() : includeSubdomains; + maxAge = Objects.requireNonNullElse(maxAge, Optional.empty()); + includeSubdomains = Objects.requireNonNullElse(includeSubdomains, Optional.empty()); } } @@ -88,11 +89,11 @@ public record Cors(Optional enabled, List allowedOrigins, List< * absent components. */ public Cors { - enabled = enabled == null ? Optional.empty() : enabled; + 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 = allowCredentials == null ? Optional.empty() : allowCredentials; + 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 index 8ed5b88..3cf27cd 100644 --- 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 @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import lombok.Builder; @@ -41,10 +42,10 @@ public record TlsConfig(Optional minVersion, List alpn, Map clientCa) { * {@link Optional#empty()}. */ public Mtls { - clientCa = clientCa == null ? Optional.empty() : clientCa; + clientCa = Objects.requireNonNullElse(clientCa, Optional.empty()); } } } 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 index e1745e2..64f74ae 100644 --- 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 @@ -15,6 +15,7 @@ */ package de.cuioss.sheriff.api.config.model; +import java.util.Objects; import java.util.Optional; import lombok.Builder; @@ -45,12 +46,12 @@ public record UpstreamConfig(Optional path, Optional connectTim * Canonical constructor normalizing absent components to {@link Optional#empty()}. */ public UpstreamConfig { - path = path == null ? Optional.empty() : path; - connectTimeoutMs = connectTimeoutMs == null ? Optional.empty() : connectTimeoutMs; - readTimeoutMs = readTimeoutMs == null ? Optional.empty() : readTimeoutMs; - retry = retry == null ? Optional.empty() : retry; - notModified = notModified == null ? Optional.empty() : notModified; - circuitBreaker = circuitBreaker == null ? Optional.empty() : circuitBreaker; + 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()); } /** @@ -71,9 +72,9 @@ public record Retry(Optional enabled, Optional maxAttempts, Op * Canonical constructor normalizing absent components to {@link Optional#empty()}. */ public Retry { - enabled = enabled == null ? Optional.empty() : enabled; - maxAttempts = maxAttempts == null ? Optional.empty() : maxAttempts; - idempotentOnly = idempotentOnly == null ? Optional.empty() : idempotentOnly; + enabled = Objects.requireNonNullElse(enabled, Optional.empty()); + maxAttempts = Objects.requireNonNullElse(maxAttempts, Optional.empty()); + idempotentOnly = Objects.requireNonNullElse(idempotentOnly, Optional.empty()); } } @@ -92,7 +93,7 @@ public record NotModified(Optional enabled) { * {@link Optional#empty()}. */ public NotModified { - enabled = enabled == null ? Optional.empty() : enabled; + enabled = Objects.requireNonNullElse(enabled, Optional.empty()); } } @@ -111,8 +112,8 @@ public record CircuitBreaker(Optional failures, Optional reset * Canonical constructor normalizing absent components to {@link Optional#empty()}. */ public CircuitBreaker { - failures = failures == null ? Optional.empty() : failures; - resetMs = resetMs == null ? Optional.empty() : resetMs; + failures = Objects.requireNonNullElse(failures, Optional.empty()); + resetMs = Objects.requireNonNullElse(resetMs, Optional.empty()); } } } From bf48e0193548d5b3e9772fa3acff6f68eda1f0e8 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:00:23 +0200 Subject: [PATCH 19/21] fix(config): address review findings (prefix match, header disjointness, url trim, timeout validation, mtls schema) Co-Authored-By: Claude --- .../sheriff/api/config/RouteTableBuilder.java | 17 +++-- .../sheriff/api/config/model/RouteTable.java | 21 +++++- .../api/config/topology/TopologyResolver.java | 2 +- .../config/validation/ConfigValidator.java | 7 ++ .../main/resources/schema/gateway.schema.json | 1 + .../api/config/RouteTableBuilderTest.java | 65 +++++++++++++++++++ .../config/topology/TopologyResolverTest.java | 12 ++++ .../validation/ConfigValidatorTest.java | 58 +++++++++++++++++ 8 files changed, 177 insertions(+), 6 deletions(-) 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 index f919e17..cc255d6 100644 --- 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 @@ -165,11 +165,8 @@ private static boolean methodsOverlap(MatchConfig first, MatchConfig second) { private static boolean headersDistinguish(MatchConfig first, MatchConfig second) { for (HeaderMatcher headerA : first.headers()) { for (HeaderMatcher headerB : second.headers()) { - Optional valueA = headerA.value(); - Optional valueB = headerB.value(); if (headerA.name().equalsIgnoreCase(headerB.name()) - && valueA.isPresent() && valueB.isPresent() - && !valueA.get().equals(valueB.get())) { + && (valuesDistinguish(headerA, headerB) || presenceDistinguishes(headerA, headerB))) { return true; } } @@ -177,6 +174,18 @@ private static boolean headersDistinguish(MatchConfig first, MatchConfig second) 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 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 index c80b3ec..20abc6e 100644 --- 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 @@ -58,6 +58,25 @@ public record RouteTable(List routes) { */ public Optional lookup(String path) { Objects.requireNonNull(path, "path"); - return routes.stream().filter(route -> path.startsWith(route.pathPrefix())).findFirst(); + 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/topology/TopologyResolver.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/topology/TopologyResolver.java index 23d0552..4f4db08 100644 --- 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 @@ -103,7 +103,7 @@ public ResolvedTopology resolve(Path topologyFile, List enabledE "Unresolved topology alias '%s' referenced by enabled endpoint '%s'" .formatted(alias, endpoint.id())); } - resolved.put(alias, decompose(alias, value)); + resolved.put(alias, decompose(alias, value.trim())); } return new ResolvedTopology(resolved); } 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 index 605a65a..10f7f7b 100644 --- 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 @@ -195,6 +195,13 @@ private static void validateWholeSecondTimeouts(List endpoints, 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), diff --git a/api-sheriff/src/main/resources/schema/gateway.schema.json b/api-sheriff/src/main/resources/schema/gateway.schema.json index b3204a4..a1287ca 100644 --- a/api-sheriff/src/main/resources/schema/gateway.schema.json +++ b/api-sheriff/src/main/resources/schema/gateway.schema.json @@ -58,6 +58,7 @@ "mtls": { "type": "object", "additionalProperties": false, + "required": ["enabled"], "properties": { "enabled": { "type": "boolean" }, "client_ca": { "type": "string" } 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 index c2a06a3..1f8b201 100644 --- 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 @@ -37,6 +37,7 @@ 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; @@ -81,6 +82,11 @@ private static RouteConfig routeWithPrefix(String id, String pathPrefix, HttpMet 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) { @@ -193,6 +199,65 @@ void shouldLookUpMostSpecificPrefix() { 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 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 index b850e4c..2ce92b5 100644 --- 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 @@ -102,6 +102,18 @@ void environmentOverrideWinsOverFile() throws Exception { 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()); + } + @Test void rejectsMalformedUrl() throws Exception { Path file = topologyFile("ORDERS=http://orders internal:8443\n"); 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 index 74a47ee..5926321 100644 --- 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 @@ -224,6 +224,64 @@ void shouldRejectNonWholeSecondTimeout() { 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() { From cd6cc23155ebe511b16fc9be83edc542adb08470 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:21:41 +0200 Subject: [PATCH 20/21] refactor(config): resolve remaining Sonar maintainability findings Co-Authored-By: Claude --- .../sheriff/api/config/load/ConfigLoader.java | 32 +++++++++++-------- .../api/config/load/EnvSecretResolver.java | 8 ++--- .../sheriff/api/config/model/AuthConfig.java | 2 +- .../api/config/model/EndpointConfig.java | 6 ++-- .../api/config/model/IssuerConfig.java | 6 ++-- .../sheriff/api/config/model/MatchConfig.java | 4 +-- .../api/config/model/ResolvedRoute.java | 8 ++--- .../api/config/model/ResolvedUpstream.java | 4 +-- .../sheriff/api/config/model/RouteConfig.java | 4 +-- .../topology/EndpointEnablementResolver.java | 6 ++-- .../api/config/topology/TopologyResolver.java | 6 ++-- .../config/validation/ConfigValidator.java | 3 +- .../config/topology/TopologyResolverTest.java | 12 +++++-- 13 files changed, 56 insertions(+), 45 deletions(-) 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 index df01ab3..4193ff4 100644 --- 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 @@ -140,19 +140,7 @@ public LoadedConfig load() throws ConfigLoadException { private List loadEndpoints(List errors) { List endpoints = new ArrayList<>(); for (Path path : listEndpointFiles(errors)) { - String file = ENDPOINTS_DIR + "/" + path.getFileName(); - JsonNode root = readYaml(path, file, errors); - if (root == null) { - continue; - } - validate(endpointSchema, root, file, errors); - resolveSecrets(root, file, "", errors); - if (hasErrorsFor(file, errors)) { - continue; - } - JsonNode block = root.get(ENDPOINT_ROOT); - applyEnabledDefault(block); - EndpointConfig endpoint = bind(block, EndpointConfig.class, file, errors); + EndpointConfig endpoint = loadEndpoint(path, errors); if (endpoint != null) { endpoints.add(endpoint); } @@ -160,6 +148,22 @@ private List loadEndpoints(List errors) { 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)) { @@ -315,7 +319,7 @@ private static boolean readEnabled(JsonNode node, String block) { if (blockNode == null) { return true; } - JsonNode enabled = blockNode.get("enabled"); + 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 index da24009..4cf2c74 100644 --- 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 @@ -16,7 +16,7 @@ package de.cuioss.sheriff.api.config.load; import java.util.Objects; -import java.util.function.Function; +import java.util.function.UnaryOperator; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -42,9 +42,9 @@ */ public final class EnvSecretResolver { - private static final Pattern REFERENCE = Pattern.compile("\\$\\{([A-Za-z_][A-Za-z0-9_]*)}"); + private static final Pattern REFERENCE = Pattern.compile("\\$\\{([A-Za-z_]\\w*)}"); - private final Function lookup; + private final UnaryOperator<@Nullable String> lookup; /** * Creates a resolver backed by the process environment @@ -60,7 +60,7 @@ public EnvSecretResolver() { * @param lookup maps an environment-variable name to its value, or {@code null} * when the variable is undefined */ - public EnvSecretResolver(Function lookup) { + public EnvSecretResolver(UnaryOperator<@Nullable String> lookup) { this.lookup = Objects.requireNonNull(lookup, "lookup"); } 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 index 39c60c7..50a3727 100644 --- 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 @@ -42,7 +42,7 @@ public record AuthConfig(String require, List requiredScopes) { * unmodifiable list and normalizing an absent list to empty. */ public AuthConfig { - require = Objects.requireNonNull(require, "require"); + 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 index 8049aae..0c8e9eb 100644 --- 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 @@ -54,9 +54,9 @@ public record EndpointConfig(String id, boolean enabled, String baseUrl, AuthCon * collections, and normalizing absent components. */ public EndpointConfig { - id = Objects.requireNonNull(id, "id"); - baseUrl = Objects.requireNonNull(baseUrl, "baseUrl"); - auth = Objects.requireNonNull(auth, "auth"); + 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/IssuerConfig.java b/api-sheriff/src/main/java/de/cuioss/sheriff/api/config/model/IssuerConfig.java index d4c69d1..1fd7072 100644 --- 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 @@ -38,8 +38,8 @@ public record IssuerConfig(String name, String issuer, Optional audience * optionals to {@link Optional#empty()}. */ public IssuerConfig { - name = Objects.requireNonNull(name, "name"); - issuer = Objects.requireNonNull(issuer, "issuer"); + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(issuer, "issuer"); audience = Objects.requireNonNullElse(audience, Optional.empty()); jwks = Objects.requireNonNullElse(jwks, Optional.empty()); } @@ -62,7 +62,7 @@ public record Jwks(String source, Optional url, Optional file) { * optionals to {@link Optional#empty()}. */ public Jwks { - source = Objects.requireNonNull(source, "source"); + 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 index 3316592..c5d887d 100644 --- 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 @@ -41,7 +41,7 @@ public record MatchConfig(String pathPrefix, List methods, Optional< * collections, and normalizing absent components. */ public MatchConfig { - pathPrefix = Objects.requireNonNull(pathPrefix, "pathPrefix"); + 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); @@ -64,7 +64,7 @@ public record HeaderMatcher(String name, Optional present, Optional protocol, MatchConfig ma * absent optionals to {@link Optional#empty()}. */ public RouteConfig { - id = Objects.requireNonNull(id, "id"); - match = Objects.requireNonNull(match, "match"); + 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()); 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 index 7b3d3a9..69a401e 100644 --- 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 @@ -18,7 +18,7 @@ import java.util.List; import java.util.Locale; import java.util.Objects; -import java.util.function.Function; +import java.util.function.UnaryOperator; import de.cuioss.sheriff.api.config.model.EndpointConfig; import org.jspecify.annotations.Nullable; @@ -42,7 +42,7 @@ */ public final class EndpointEnablementResolver { - private final Function environment; + private final UnaryOperator<@Nullable String> environment; /** * Creates a resolver backed by the process environment @@ -58,7 +58,7 @@ public EndpointEnablementResolver() { * @param environment maps an environment-variable name to its value, or * {@code null} when undefined */ - public EndpointEnablementResolver(Function environment) { + public EndpointEnablementResolver(UnaryOperator<@Nullable String> environment) { this.environment = Objects.requireNonNull(environment, "environment"); } 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 index 4f4db08..2484b9a 100644 --- 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 @@ -28,7 +28,7 @@ import java.util.Map; import java.util.Objects; import java.util.Properties; -import java.util.function.Function; +import java.util.function.UnaryOperator; import java.util.regex.Pattern; import de.cuioss.sheriff.api.config.model.EndpointConfig; @@ -59,7 +59,7 @@ public final class TopologyResolver { private static final int HTTP_PORT = 80; private static final int HTTPS_PORT = 443; - private final Function environment; + private final UnaryOperator<@Nullable String> environment; /** * Creates a resolver backed by the process environment @@ -75,7 +75,7 @@ public TopologyResolver() { * @param environment maps an environment-variable name to its value, or * {@code null} when undefined */ - public TopologyResolver(Function environment) { + public TopologyResolver(UnaryOperator<@Nullable String> environment) { this.environment = Objects.requireNonNull(environment, "environment"); } 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 index 10f7f7b..234503e 100644 --- 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 @@ -27,6 +27,7 @@ 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; @@ -230,7 +231,7 @@ private static void validateCors(GatewayConfig gateway, List errors } private static void validateSessionMode(GatewayConfig gateway, List errors) { - gateway.oidc().flatMap(oidc -> oidc.session()).ifPresent(session -> + 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", 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 index 2ce92b5..b16dc49 100644 --- 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 @@ -117,25 +117,31 @@ void trimsSurroundingWhitespaceBeforeParsing() throws Exception { @Test void rejectsMalformedUrl() throws Exception { Path file = topologyFile("ORDERS=http://orders internal:8443\n"); + TopologyResolver resolver = resolverWith(Map.of()); + List endpoints = List.of(endpointFor("ORDERS")); assertThrows(TopologyResolutionException.class, - () -> resolverWith(Map.of()).resolve(file, List.of(endpointFor("ORDERS")))); + () -> resolver.resolve(file, endpoints)); } @Test void rejectsUrlWithoutSchemeOrHost() throws Exception { Path file = topologyFile("ORDERS=orders.internal:8443\n"); + TopologyResolver resolver = resolverWith(Map.of()); + List endpoints = List.of(endpointFor("ORDERS")); assertThrows(TopologyResolutionException.class, - () -> resolverWith(Map.of()).resolve(file, List.of(endpointFor("ORDERS")))); + () -> resolver.resolve(file, endpoints)); } @Test void rejectsMissingAliasReferencedByEnabledEndpoint() throws Exception { Path file = topologyFile("OTHER=https://other.internal\n"); + TopologyResolver resolver = resolverWith(Map.of()); + List endpoints = List.of(endpointFor("MISSING")); assertThrows(TopologyResolutionException.class, - () -> resolverWith(Map.of()).resolve(file, List.of(endpointFor("MISSING")))); + () -> resolver.resolve(file, endpoints)); } @Test From 89bddf1507fd9c29c2873e83b81c05827396fdf7 Mon Sep 17 00:00:00 2001 From: Oliver Wolff <23139298+cuioss@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:34:59 +0200 Subject: [PATCH 21/21] test(config): parameterize topology-rejection tests (Sonar S5976) The three near-identical rejects* topology tests are consolidated into one @ParameterizedTest over the malformed-input scenarios, clearing Sonar S5976 ("replace these 3 tests with a single parameterized one"). Each scenario keeps a descriptive display name; assertions are unchanged. Co-Authored-By: Claude --- .../config/topology/TopologyResolverTest.java | 37 ++++++++----------- 1 file changed, 15 insertions(+), 22 deletions(-) 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 index b16dc49..e76a84f 100644 --- 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 @@ -25,10 +25,14 @@ 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; @@ -114,34 +118,23 @@ void trimsSurroundingWhitespaceBeforeParsing() throws Exception { assertEquals("/api", upstream.basePath()); } - @Test - void rejectsMalformedUrl() throws Exception { - Path file = topologyFile("ORDERS=http://orders internal:8443\n"); + @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("ORDERS")); + List endpoints = List.of(endpointFor(endpointAlias)); assertThrows(TopologyResolutionException.class, () -> resolver.resolve(file, endpoints)); } - @Test - void rejectsUrlWithoutSchemeOrHost() throws Exception { - Path file = topologyFile("ORDERS=orders.internal:8443\n"); - TopologyResolver resolver = resolverWith(Map.of()); - List endpoints = List.of(endpointFor("ORDERS")); - - assertThrows(TopologyResolutionException.class, - () -> resolver.resolve(file, endpoints)); - } - - @Test - void rejectsMissingAliasReferencedByEnabledEndpoint() throws Exception { - Path file = topologyFile("OTHER=https://other.internal\n"); - TopologyResolver resolver = resolverWith(Map.of()); - List endpoints = List.of(endpointFor("MISSING")); - - 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