Skip to main content

waf_schema_enforcement/
api_schema.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! API Security — OpenAPI **positive-security** (§4-B pt3). Validate each incoming REST/JSON request
5//! against the app's REAL OpenAPI spec: a path/method/parameter/body shape the app's own contract
6//! does not declare is a request a spec-compliant client never sends — a probing/abuse signal — so it
7//! contributes to the SAME cumulative anomaly score the core already uses (`Decision::Scores`,
8//! severity resolved by the pipeline). This is **governance of the contract**, NOT baseline detection:
9//! value-level injection (SQLi/XSS in a parameter *value*) stays in the OPEN §6 channel.
10//!
11//! **STEP A1 (scaffold + spec load).** This step stands up the crate module, the [`rules::ApiSchema`]
12//! namespace (A1) and the pure, boot-fatal spec load — including the OpenAPI **3.0 → JSON-Schema
13//! normalization** that A0 proved necessary (`jsonschema` does not understand 3.0's `nullable`, so a
14//! nullable field set to `null` would be a false positive). The path router (A2), per-operation
15//! parameter/body validation (A3) and the [`waf_core::WafModule`] impl (A4) land in later steps.
16//!
17//! [`rules::ApiSchema`]: crate::rules::Namespace::ApiSchema
18
19use std::collections::{BTreeSet, HashMap};
20use std::path::{Path, PathBuf};
21
22use jsonschema::error::ValidationErrorKind;
23use jsonschema::Validator;
24use serde_json::Value;
25use waf_core::{Config, Decision, Phase, RequestContext, ScoreItem, Severity, WafModule};
26
27/// Configuration for the API schema-enforcement module. The core `Config` carries no enterprise
28/// fields, so the enterprise wiring builds this from `[enterprise.api_schema]` (A5) and hands it to
29/// the module constructor (A4).
30#[derive(Debug, Clone)]
31pub struct ApiSchemaEnforcementConfig {
32    /// Off by default → the module is inert (contributes nothing).
33    pub enabled: bool,
34    /// Path prefix(es) this module guards. A request whose path starts with none of these is left
35    /// alone, so a non-API surface (static assets, a different app) is never measured against the
36    /// spec. Empty ⇒ guard nothing (still inert).
37    pub paths: Vec<String>,
38    /// The app's OpenAPI 3.0/3.1 spec, as a **JSON** file (v1 is JSON-only — see A0: the YAML path
39    /// would pull a c2rust-transpiled libyaml, deliberately avoided).
40    pub spec_path: PathBuf,
41    /// Severity for a request to an endpoint/method the spec does not declare (higher — targeted
42    /// probing, the strong signal).
43    pub unknown_route_severity: Severity,
44    /// Severity for a request to a KNOWN operation that violates its contract (param/body shape).
45    /// Lower/tunable because schema drift (app deployed a newer spec than the WAF's copy) is the one
46    /// legitimate source of these — handled by severity, not by loosening validation.
47    pub violation_severity: Severity,
48    /// Flag query/header parameters not declared for the operation (`schema-api-unknown-param`).
49    pub strict_params: bool,
50    /// Enforce `additionalProperties:false` on the request body (`schema-api-unknown-property`) even
51    /// where the spec is silent. When false, only violations the spec itself declares are flagged.
52    pub strict_body: bool,
53}
54
55impl Default for ApiSchemaEnforcementConfig {
56    fn default() -> Self {
57        Self {
58            enabled: false,
59            paths: vec!["/api".to_string()],
60            spec_path: PathBuf::new(),
61            unknown_route_severity: Severity::Critical,
62            violation_severity: Severity::Warning,
63            strict_params: true,
64            strict_body: true,
65        }
66    }
67}
68
69/// A parsed, structurally-valid OpenAPI spec held in memory (immutable — no hot-reload in v1; the
70/// core `module_factory` re-runs the load on SIGHUP, A5). The spec is kept as raw JSON so the router
71/// (A2) and validation (A3) navigate it and hand JSON-Schema fragments straight to `jsonschema`.
72#[derive(Debug, Clone)]
73pub struct LoadedSpec {
74    /// The whole spec document, as JSON. `#/components/...` `$ref`s resolve within this one resource
75    /// (no HTTP/file retriever — A0: `jsonschema` with `default-features = false`).
76    pub doc: Value,
77    /// True when the `openapi` version is `3.0.x`. Drives the [`normalize_schema_3_0`] pass at
78    /// schema-compile time (A3): 3.0 is a JSON-Schema *dialect*, 3.1 is draft-2020-12 proper.
79    pub is_3_0: bool,
80    /// Path router built from the spec's `paths` (A2): resolves a request (path, method) to a declared
81    /// operation, or to [`Resolution::UnknownRoute`] / [`Resolution::MethodNotAllowed`].
82    pub router: Router,
83}
84
85/// Pure, re-entrant spec parse+structural-validate (A5 reload-readiness): spec **JSON text** →
86/// [`LoadedSpec`], no global state, no I/O. `oas3` is the structural gate (it covers OpenAPI 3.0 AND
87/// 3.1); the raw JSON is retained for navigation. Boot-fatal at the caller on `Err`.
88pub fn parse_spec(json: &str) -> Result<LoadedSpec, String> {
89    // Structural gate: must deserialize as a well-formed OpenAPI 3.x document.
90    oas3::from_json(json).map_err(|e| format!("invalid OpenAPI spec: {e}"))?;
91    let doc: Value = serde_json::from_str(json).map_err(|e| format!("spec is not valid JSON: {e}"))?;
92    let is_3_0 = doc
93        .get("openapi")
94        .and_then(Value::as_str)
95        .is_some_and(|v| v.starts_with("3.0"));
96    let router = Router::build(&doc);
97    Ok(LoadedSpec { doc, is_3_0, router })
98}
99
100/// Load and structurally-validate a spec **from a file** (A5 reload-ready entry point): read →
101/// [`parse_spec`]. Called at construction so the boot can fail-closed on a missing/invalid spec.
102pub fn load_spec(path: &Path) -> Result<LoadedSpec, String> {
103    let json = std::fs::read_to_string(path)
104        .map_err(|e| format!("cannot read OpenAPI spec {}: {e}", path.display()))?;
105    parse_spec(&json)
106}
107
108/// Convert an OpenAPI **3.0** Schema Object (a JSON-Schema *dialect*) into standard JSON Schema that
109/// `jsonschema` validates correctly. A0 proved this is not cosmetic: `jsonschema` ignores 3.0's
110/// `nullable` keyword, so a `nullable:true` field carrying `null` is otherwise a false positive
111/// ("null is not of type string"). Also normalizes 3.0's boolean `exclusiveMinimum/Maximum` to the
112/// numeric 2020-12 form. Recurses into every nested schema; idempotent and a no-op on 3.1 schemas.
113pub fn normalize_schema_3_0(v: &mut Value) {
114    match v {
115        Value::Object(map) => {
116            // `nullable: true`  →  admit `null` in `type`.
117            if map.get("nullable") == Some(&Value::Bool(true)) {
118                if let Some(t) = map.get_mut("type") {
119                    add_null_to_type(t);
120                }
121            }
122            map.remove("nullable");
123            // 3.0 `exclusiveMinimum/Maximum` are booleans paired with `minimum/maximum`; 2020-12
124            // makes them the numeric bound itself.
125            fix_exclusive_bound(map, "exclusiveMinimum", "minimum");
126            fix_exclusive_bound(map, "exclusiveMaximum", "maximum");
127            for child in map.values_mut() {
128                normalize_schema_3_0(child);
129            }
130        }
131        Value::Array(items) => {
132            for child in items {
133                normalize_schema_3_0(child);
134            }
135        }
136        _ => {}
137    }
138}
139
140/// Widen a `type` (string or array) to also admit `"null"`.
141fn add_null_to_type(t: &mut Value) {
142    let null = Value::String("null".to_string());
143    match t {
144        Value::String(s) => {
145            *t = Value::Array(vec![Value::String(std::mem::take(s)), null]);
146        }
147        Value::Array(arr) if !arr.contains(&null) => arr.push(null),
148        _ => {}
149    }
150}
151
152/// Rewrite a 3.0 boolean `exclusive*` keyword to the numeric 2020-12 form: `exclusiveMinimum:true`
153/// with `minimum:N` becomes `exclusiveMinimum:N` (and drops `minimum`); `false` just drops the
154/// boolean, leaving the inclusive bound in place.
155fn fix_exclusive_bound(map: &mut serde_json::Map<String, Value>, excl: &str, bound: &str) {
156    if let Some(Value::Bool(is_exclusive)) = map.get(excl).cloned() {
157        map.remove(excl);
158        if is_exclusive {
159            if let Some(bound_val) = map.remove(bound) {
160                map.insert(excl.to_string(), bound_val);
161            }
162        }
163    }
164}
165
166// ── A2: path router ──────────────────────────────────────────────────────────────
167// OpenAPI paths are templated (`/users/{id}`); a request path must resolve to the ONE most-specific
168// declared operation. The router owns only owned data (no borrow of the spec doc), so it composes
169// into the module state (A4) without self-referential lifetimes.
170
171/// The OpenAPI path-item HTTP method keys. Everything else in a path item (`parameters`, `summary`,
172/// `$ref`, `servers`, `description`…) is not an operation.
173const HTTP_METHODS: &[&str] = &["get", "put", "post", "delete", "options", "head", "patch", "trace"];
174
175/// One segment of a path template.
176#[derive(Debug, Clone, PartialEq, Eq)]
177enum Seg {
178    /// A literal segment, matched case-sensitively (OpenAPI paths are case-sensitive).
179    Static(String),
180    /// A `{name}` template segment, matching any single non-empty segment.
181    Param(String),
182}
183
184/// One declared route: a path template + its declared HTTP methods.
185#[derive(Debug, Clone)]
186struct Route {
187    template: String,
188    segments: Vec<Seg>,
189    methods: std::collections::BTreeSet<String>,
190}
191
192/// Outcome of resolving a request (path, method) against the spec.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum Resolution {
195    /// No path template matches the request path → targeted probing (Critical).
196    UnknownRoute,
197    /// The path matches an operation's template, but not for this HTTP method (Critical).
198    MethodNotAllowed,
199    /// A declared operation. `template` + `path_params` let A3 look up and validate the operation.
200    Matched {
201        /// The matched OpenAPI path template (key into `doc["paths"]`).
202        template: String,
203        /// `{name}` → observed value, for A3 path-parameter validation.
204        path_params: Vec<(String, String)>,
205    },
206}
207
208/// A path router built once from the spec (A2).
209#[derive(Debug, Clone, Default)]
210pub struct Router {
211    /// Routes, pre-sorted most-specific first (static segments before template segments).
212    routes: Vec<Route>,
213}
214
215impl Router {
216    /// Build the router from an OpenAPI document's `paths`. Routes are ordered most-specific first,
217    /// so the first matching route is the correct (most specific) one.
218    pub fn build(doc: &Value) -> Self {
219        let mut routes = Vec::new();
220        if let Some(paths) = doc.get("paths").and_then(Value::as_object) {
221            for (template, item) in paths {
222                let Some(item) = item.as_object() else { continue };
223                let methods: std::collections::BTreeSet<String> = HTTP_METHODS
224                    .iter()
225                    .filter(|m| item.contains_key(**m))
226                    .map(|m| (*m).to_string())
227                    .collect();
228                if methods.is_empty() {
229                    continue; // a path item with no operations declares no route
230                }
231                routes.push(Route { segments: parse_template(template), template: template.clone(), methods });
232            }
233        }
234        // Most-specific first: compare the per-segment specificity vector (Static=0, Param=1), then
235        // template text for a stable order.
236        routes.sort_by(|a, b| {
237            specificity(&a.segments).cmp(&specificity(&b.segments)).then(a.template.cmp(&b.template))
238        });
239        Self { routes }
240    }
241
242    /// Resolve a request path + HTTP method against the spec.
243    pub fn resolve(&self, path: &str, method: &str) -> Resolution {
244        let req = split_segments(path);
245        let m = method.to_ascii_lowercase();
246        for route in &self.routes {
247            if let Some(path_params) = route.match_path(&req) {
248                // The most-specific matching path decides; the method is checked on THAT path only.
249                return if route.methods.contains(&m) {
250                    Resolution::Matched { template: route.template.clone(), path_params }
251                } else {
252                    Resolution::MethodNotAllowed
253                };
254            }
255        }
256        Resolution::UnknownRoute
257    }
258}
259
260impl Route {
261    /// Match this route's template against request segments, extracting path params on success.
262    fn match_path(&self, req: &[&str]) -> Option<Vec<(String, String)>> {
263        if self.segments.len() != req.len() {
264            return None;
265        }
266        let mut params = Vec::new();
267        for (seg, val) in self.segments.iter().zip(req) {
268            match seg {
269                Seg::Static(s) if s == val => {}
270                Seg::Static(_) => return None,
271                Seg::Param(_) if val.is_empty() => return None,
272                Seg::Param(name) => params.push((name.clone(), (*val).to_string())),
273            }
274        }
275        Some(params)
276    }
277}
278
279/// Split a path into non-empty segments (a stray query/fragment is dropped, and trailing/duplicate
280/// slashes are ignored — a WAF should treat `/users` and `/users/` as the same resource).
281fn split_segments(path: &str) -> Vec<&str> {
282    let path = path.split(['?', '#']).next().unwrap_or(path);
283    path.split('/').filter(|s| !s.is_empty()).collect()
284}
285
286/// Parse a template into segments: a fully `{...}`-wrapped segment is a param, everything else is a
287/// literal.
288fn parse_template(template: &str) -> Vec<Seg> {
289    split_segments(template)
290        .into_iter()
291        .map(|s| match s.strip_prefix('{').and_then(|inner| inner.strip_suffix('}')) {
292            Some(name) if !name.is_empty() => Seg::Param(name.to_string()),
293            _ => Seg::Static(s.to_string()),
294        })
295        .collect()
296}
297
298/// Per-segment specificity vector: Static=0, Param=1. Lexicographically smaller = more specific.
299fn specificity(segs: &[Seg]) -> Vec<u8> {
300    segs.iter().map(|s| u8::from(matches!(s, Seg::Param(_)))).collect()
301}
302
303// ── A3: parameter + body validation ──────────────────────────────────────────────
304// A matched operation (A2) is validated against its contract: declared parameters (path/query/header)
305// and the JSON request body, via `jsonschema`. Schemas are compiled ONCE at load (boot-fatal on a
306// schema that won't compile), with the OpenAPI 3.0 → JSON-Schema normalization (A0) applied so 3.0
307// `nullable` never yields a false positive.
308
309/// A contract violation of a matched operation. Route/method mismatches come from the router
310/// ([`Resolution`]); these are the per-operation contract failures. `Ord` so a request's violations
311/// dedup into a set — one score contribution per kind (the batch-dedup discipline of §4-B).
312#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
313pub enum Violation {
314    /// A query parameter not declared for the operation (`strict_params`).
315    UnknownParam,
316    /// A required parameter or body (or required body property) is absent.
317    MissingRequired,
318    /// A parameter/body value fails its schema (type/format/enum/range).
319    TypeViolation,
320    /// A body property the schema does not permit (`additionalProperties`/`unevaluatedProperties`).
321    UnknownProperty,
322    /// The request Content-Type is not one the operation declares.
323    ContentType,
324    /// The body is JSON per its Content-Type but does not parse.
325    BodyParseError,
326}
327
328impl Violation {
329    /// The registry rule this violation emits (A4 turns it into a `ScoreItem`).
330    pub fn rule(self) -> crate::rules::Rule {
331        use crate::rules;
332        match self {
333            Violation::UnknownParam => rules::API_UNKNOWN_PARAM,
334            Violation::MissingRequired => rules::API_MISSING_REQUIRED,
335            Violation::TypeViolation => rules::API_TYPE_VIOLATION,
336            Violation::UnknownProperty => rules::API_UNKNOWN_PROPERTY,
337            Violation::ContentType => rules::API_CONTENT_TYPE,
338            Violation::BodyParseError => rules::API_BODY_PARSE_ERROR,
339        }
340    }
341}
342
343/// The already-extracted request data A3 validates (A4 fills this from the `RequestContext`).
344pub struct RequestParts<'a> {
345    /// `{name}` → value, from the router match ([`Resolution::Matched`]).
346    pub path_params: &'a [(String, String)],
347    /// Query parameters, in order.
348    pub query_params: &'a [(String, String)],
349    /// Request headers, names lowercased.
350    pub headers: &'a [(String, String)],
351    /// The request Content-Type header value, if any.
352    pub content_type: Option<&'a str>,
353    /// The raw request body, if any.
354    pub body: Option<&'a [u8]>,
355}
356
357/// Where a declared parameter lives (v1 covers path/query/header; `cookie` is out of scope).
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
359enum ParamIn {
360    Path,
361    Query,
362    Header,
363}
364
365/// A scalar `type` we validate by coercing the (string) wire value into it. Array/object parameters
366/// are presence-only in v1 (style/explode of collection params would risk false positives).
367#[derive(Debug, Clone, Copy)]
368enum ScalarType {
369    Integer,
370    Number,
371    Boolean,
372    String,
373}
374
375/// A compiled declared parameter.
376struct CompiledParam {
377    name: String,
378    location: ParamIn,
379    required: bool,
380    scalar: Option<ScalarType>,
381    validator: Option<Validator>,
382}
383
384/// A compiled request body.
385struct CompiledBody {
386    required: bool,
387    /// Declared media types (lowercased, parameters stripped).
388    media_types: Vec<String>,
389    /// Validator for the JSON media type's schema (`None` ⇒ no JSON schema to enforce).
390    json: Option<Validator>,
391}
392
393/// A compiled operation: its declared parameters and request body.
394struct CompiledOp {
395    params: Vec<CompiledParam>,
396    body: Option<CompiledBody>,
397}
398
399/// A fully compiled spec: the A2 router + per-operation compiled parameter/body validators. Built
400/// once (boot-fatal on a schema `jsonschema` cannot compile), then validates each request cheaply.
401pub struct CompiledSpec {
402    router: Router,
403    strict_params: bool,
404    ops: HashMap<(String, String), CompiledOp>,
405}
406
407impl CompiledSpec {
408    /// Compile every operation's parameter + body schemas. **Boot-fatal** on a schema that won't
409    /// compile — surfaces a broken spec at boot, like the GraphQL/gRPC modules.
410    pub fn compile(spec: &LoadedSpec, cfg: &ApiSchemaEnforcementConfig) -> Result<Self, String> {
411        let doc = &spec.doc;
412        let mut ops = HashMap::new();
413        if let Some(paths) = doc.get("paths").and_then(Value::as_object) {
414            for (template, item) in paths {
415                let Some(item_obj) = item.as_object() else { continue };
416                let shared = item_obj.get("parameters");
417                for m in HTTP_METHODS {
418                    let Some(op) = item_obj.get(*m) else { continue };
419                    let compiled = compile_op(doc, op, shared, spec.is_3_0, cfg.strict_body)
420                        .map_err(|e| format!("{} {template}: {e}", m.to_uppercase()))?;
421                    ops.insert((template.clone(), (*m).to_string()), compiled);
422                }
423            }
424        }
425        Ok(Self { router: spec.router.clone(), strict_params: cfg.strict_params, ops })
426    }
427
428    /// Resolve a request path+method (delegates to the A2 router).
429    pub fn resolve(&self, path: &str, method: &str) -> Resolution {
430        self.router.resolve(path, method)
431    }
432
433    /// Validate a matched operation's request against its contract, returning a deduped set of
434    /// violations (one per kind). `template`/`method` come from a [`Resolution::Matched`].
435    pub fn validate(&self, template: &str, method: &str, req: &RequestParts) -> BTreeSet<Violation> {
436        let mut out = BTreeSet::new();
437        let Some(op) = self.ops.get(&(template.to_string(), method.to_ascii_lowercase())) else {
438            return out;
439        };
440
441        // ── parameters ──
442        for p in &op.params {
443            let observed = match p.location {
444                ParamIn::Path => find(req.path_params, &p.name, false),
445                ParamIn::Query => find(req.query_params, &p.name, false),
446                ParamIn::Header => find(req.headers, &p.name, true),
447            };
448            match observed {
449                None if p.required => {
450                    out.insert(Violation::MissingRequired);
451                }
452                None => {}
453                Some(val) => {
454                    if let (Some(scalar), Some(v)) = (p.scalar, &p.validator) {
455                        if !v.is_valid(&coerce(val, scalar)) {
456                            out.insert(Violation::TypeViolation);
457                        }
458                    }
459                }
460            }
461        }
462
463        // ── unknown query parameters ──
464        if self.strict_params {
465            let declared: Vec<&str> = op
466                .params
467                .iter()
468                .filter(|p| p.location == ParamIn::Query)
469                .map(|p| p.name.as_str())
470                .collect();
471            if req.query_params.iter().any(|(k, _)| !declared.contains(&k.as_str())) {
472                out.insert(Violation::UnknownParam);
473            }
474        }
475
476        // ── request body ──
477        if let Some(body) = &op.body {
478            let present = req.body.is_some_and(|b| !b.is_empty());
479            if !present {
480                if body.required {
481                    out.insert(Violation::MissingRequired);
482                }
483            } else {
484                let ct = req.content_type.map(media_type_key).unwrap_or_default();
485                if !body.media_types.is_empty()
486                    && !body.media_types.iter().any(|m| m.as_str() == ct || m == "*/*")
487                {
488                    out.insert(Violation::ContentType);
489                }
490                if is_json_media_type(&ct) {
491                    if let Some(v) = &body.json {
492                        match serde_json::from_slice::<Value>(req.body.unwrap()) {
493                            Err(_) => {
494                                out.insert(Violation::BodyParseError);
495                            }
496                            Ok(instance) => {
497                                for err in v.iter_errors(&instance) {
498                                    out.insert(classify(err.kind()));
499                                }
500                            }
501                        }
502                    }
503                }
504            }
505        }
506        out
507    }
508}
509
510/// Find a parameter value by name (case-insensitively for headers).
511fn find<'a>(pairs: &'a [(String, String)], name: &str, ci: bool) -> Option<&'a str> {
512    pairs
513        .iter()
514        .find(|(k, _)| if ci { k.eq_ignore_ascii_case(name) } else { k == name })
515        .map(|(_, v)| v.as_str())
516}
517
518/// Map a failing JSON-Schema keyword to a violation kind.
519fn classify(kind: &ValidationErrorKind) -> Violation {
520    match kind {
521        ValidationErrorKind::AdditionalProperties { .. }
522        | ValidationErrorKind::UnevaluatedProperties { .. } => Violation::UnknownProperty,
523        ValidationErrorKind::Required { .. } => Violation::MissingRequired,
524        _ => Violation::TypeViolation,
525    }
526}
527
528/// Coerce a string wire value into the parameter's declared scalar type so JSON-Schema type/format
529/// checks apply. A value that will not parse is left as a string, which the validator then flags.
530fn coerce(val: &str, scalar: ScalarType) -> Value {
531    match scalar {
532        ScalarType::String => Value::String(val.to_string()),
533        ScalarType::Boolean => match val {
534            "true" => Value::Bool(true),
535            "false" => Value::Bool(false),
536            _ => Value::String(val.to_string()),
537        },
538        ScalarType::Integer => {
539            val.parse::<i64>().map(Value::from).unwrap_or_else(|_| Value::String(val.to_string()))
540        }
541        ScalarType::Number => val
542            .parse::<f64>()
543            .ok()
544            .and_then(serde_json::Number::from_f64)
545            .map(Value::Number)
546            .unwrap_or_else(|| Value::String(val.to_string())),
547    }
548}
549
550/// The media type without parameters, lowercased (`application/json; charset=utf-8` → `application/json`).
551fn media_type_key(ct: &str) -> String {
552    ct.split(';').next().unwrap_or("").trim().to_ascii_lowercase()
553}
554
555fn is_json_media_type(ct: &str) -> bool {
556    ct == "application/json" || ct.ends_with("+json")
557}
558
559/// Compile one operation: merge path-item + operation parameters (operation wins on `(name, in)`),
560/// then compile each parameter's + the request body's schema.
561fn compile_op(
562    doc: &Value,
563    op: &Value,
564    shared: Option<&Value>,
565    is_3_0: bool,
566    strict_body: bool,
567) -> Result<CompiledOp, String> {
568    let mut params = Vec::new();
569    let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
570    // Operation-level parameters take precedence over path-item-level ones on the same `(name, in)`.
571    for src in [op.get("parameters"), shared] {
572        let Some(list) = src.and_then(Value::as_array) else { continue };
573        for raw in list {
574            let pobj = resolve_ref(doc, raw);
575            let (Some(name), Some(loc)) =
576                (pobj.get("name").and_then(Value::as_str), pobj.get("in").and_then(Value::as_str))
577            else {
578                continue;
579            };
580            let location = match loc {
581                "path" => ParamIn::Path,
582                "query" => ParamIn::Query,
583                "header" => ParamIn::Header,
584                _ => continue, // cookie & unknown: out of scope in v1
585            };
586            if !seen.insert((name.to_string(), loc.to_string())) {
587                continue; // already defined at higher precedence
588            }
589            let required =
590                loc == "path" || pobj.get("required").and_then(Value::as_bool).unwrap_or(false);
591            let (scalar, validator) = match pobj.get("schema") {
592                Some(schema) => {
593                    let resolved = resolve_ref(doc, schema);
594                    match scalar_type(resolved) {
595                        Some(s) => (Some(s), Some(compile_validator(doc, resolved, is_3_0, false)?)),
596                        None => (None, None), // array/object/absent → presence-only in v1
597                    }
598                }
599                None => (None, None),
600            };
601            params.push(CompiledParam { name: name.to_string(), location, required, scalar, validator });
602        }
603    }
604
605    let body = match op.get("requestBody") {
606        Some(rb) => Some(compile_body(doc, resolve_ref(doc, rb), is_3_0, strict_body)?),
607        None => None,
608    };
609
610    Ok(CompiledOp { params, body })
611}
612
613fn compile_body(
614    doc: &Value,
615    rb: &Value,
616    is_3_0: bool,
617    strict_body: bool,
618) -> Result<CompiledBody, String> {
619    let required = rb.get("required").and_then(Value::as_bool).unwrap_or(false);
620    let mut media_types = Vec::new();
621    let mut json = None;
622    if let Some(content) = rb.get("content").and_then(Value::as_object) {
623        for (mt, media) in content {
624            let key = media_type_key(mt);
625            if json.is_none() && is_json_media_type(&key) {
626                if let Some(schema) = media.get("schema") {
627                    json = Some(compile_validator(doc, resolve_ref(doc, schema), is_3_0, strict_body)?);
628                }
629            }
630            media_types.push(key);
631        }
632    }
633    Ok(CompiledBody { required, media_types, json })
634}
635
636/// The declared scalar `type`, if the schema is a scalar we validate.
637fn scalar_type(schema: &Value) -> Option<ScalarType> {
638    match schema.get("type").and_then(Value::as_str) {
639        Some("integer") => Some(ScalarType::Integer),
640        Some("number") => Some(ScalarType::Number),
641        Some("boolean") => Some(ScalarType::Boolean),
642        Some("string") => Some(ScalarType::String),
643        _ => None,
644    }
645}
646
647/// Compile a JSON-Schema fragment into a reusable validator: apply the 3.0 dialect normalization
648/// (A0), optionally enforce `additionalProperties:false` (strict body), and attach the spec's
649/// `components` so internal `$ref`s resolve within this one resource (no external retriever — A0).
650fn compile_validator(
651    doc: &Value,
652    schema: &Value,
653    is_3_0: bool,
654    strict_object: bool,
655) -> Result<Validator, String> {
656    let mut s = schema.clone();
657    if strict_object {
658        enforce_additional_properties(&mut s);
659    }
660    if let (Value::Object(map), Some(comps)) = (&mut s, doc.get("components")) {
661        map.entry("components".to_string()).or_insert_with(|| comps.clone());
662    }
663    if is_3_0 {
664        normalize_schema_3_0(&mut s);
665    }
666    jsonschema::validator_for(&s).map_err(|e| format!("schema does not compile: {e}"))
667}
668
669/// Enforce `additionalProperties:false` on a **top-level** plain object schema that is silent about
670/// it (strict body). Guarded: never applied to a composed schema (`allOf`/`anyOf`/`oneOf`/`$ref`/
671/// `not`), whose semantics an injected keyword would break.
672fn enforce_additional_properties(s: &mut Value) {
673    let Value::Object(map) = s else { return };
674    let composed = ["allOf", "anyOf", "oneOf", "$ref", "not"].iter().any(|k| map.contains_key(*k));
675    let is_object =
676        map.get("type").and_then(Value::as_str) == Some("object") || map.contains_key("properties");
677    if is_object && !composed && !map.contains_key("additionalProperties") {
678        map.insert("additionalProperties".to_string(), Value::Bool(false));
679    }
680}
681
682/// Follow an internal `$ref` chain (`#/...`) within the document; returns the node unchanged if it
683/// carries no `$ref` or the pointer does not resolve. Bounded against pathological cycles.
684fn resolve_ref<'a>(doc: &'a Value, mut node: &'a Value) -> &'a Value {
685    for _ in 0..32 {
686        match node.get("$ref").and_then(Value::as_str) {
687            Some(r) => match r.strip_prefix('#').and_then(|p| doc.pointer(p)) {
688                Some(target) => node = target,
689                None => break,
690            },
691            None => break,
692        }
693    }
694    node
695}
696
697// ── A4: the WafModule ─────────────────────────────────────────────────────────────
698// The datapath module: injected via the core's `.add_module()` seam, `Phase::Body`, `structural()`.
699// It mirrors the OPEN normalization — routing/gating on `ctx.normalized.path` (decoded, traversal-
700// resolved: evasion-resistant), reading query/headers from `ctx.normalized`, and the JSON body from
701// the raw `ctx.body` — then turns route/method/contract failures into additive `Decision::Scores`.
702
703/// OpenAPI positive-security module. Holds the compiled spec (immutable in-memory — no hot-reload in
704/// v1; the core `module_factory` re-runs `from_config` on SIGHUP, A5) and scores each request that
705/// falls under a guarded path prefix.
706pub struct ApiSchemaModule {
707    compiled: CompiledSpec,
708    enabled: bool,
709    /// Path prefixes this module guards (a request outside them is left alone).
710    paths: Vec<String>,
711    unknown_route_severity: Severity,
712    violation_severity: Severity,
713}
714
715impl ApiSchemaModule {
716    /// Production constructor: load + compile the spec from `cfg.spec_path`. **Boot-fatal** on a
717    /// missing/invalid/uncompilable spec (like the GraphQL/gRPC modules): `init(&Config)` gets the
718    /// core config (no spec path), so the fail-closed load MUST happen here at construction.
719    pub fn from_config(cfg: &ApiSchemaEnforcementConfig) -> Result<Self, String> {
720        Self::build(load_spec(&cfg.spec_path)?, cfg)
721    }
722
723    /// Test/embedding constructor from inline spec **JSON text** (no file).
724    pub fn from_spec_json(json: &str, cfg: &ApiSchemaEnforcementConfig) -> Result<Self, String> {
725        Self::build(parse_spec(json)?, cfg)
726    }
727
728    fn build(spec: LoadedSpec, cfg: &ApiSchemaEnforcementConfig) -> Result<Self, String> {
729        Ok(Self {
730            compiled: CompiledSpec::compile(&spec, cfg)?,
731            enabled: cfg.enabled,
732            paths: cfg.paths.clone(),
733            unknown_route_severity: cfg.unknown_route_severity,
734            violation_severity: cfg.violation_severity,
735        })
736    }
737
738    /// True when the (normalized) path falls under a guarded prefix.
739    fn guards(&self, path: &str) -> bool {
740        self.paths.iter().any(|p| path.starts_with(p.as_str()))
741    }
742}
743
744/// One-item `Decision::Scores` for a route/method failure.
745fn one(rule: crate::rules::Rule, severity: Severity) -> Decision {
746    Decision::Scores(vec![ScoreItem { rule_id: rule.id(), severity }])
747}
748
749impl WafModule for ApiSchemaModule {
750    fn id(&self) -> &str {
751        "schema-api"
752    }
753
754    fn phase(&self) -> Phase {
755        Phase::Body
756    }
757
758    /// Structural: its verdict is not a content-rule match, so the content fast-path must not skip it.
759    fn structural(&self) -> bool {
760        true
761    }
762
763    fn init(&mut self, _cfg: &Config) {}
764
765    fn inspect(&self, ctx: &RequestContext) -> Decision {
766        if !self.enabled {
767            return Decision::Allow;
768        }
769        let path = ctx.normalized.path.as_str();
770        if !self.guards(path) {
771            return Decision::Allow;
772        }
773        match self.compiled.resolve(path, &ctx.method) {
774            Resolution::UnknownRoute => {
775                one(crate::rules::API_UNKNOWN_ROUTE, self.unknown_route_severity)
776            }
777            Resolution::MethodNotAllowed => {
778                one(crate::rules::API_METHOD_NOT_ALLOWED, self.unknown_route_severity)
779            }
780            Resolution::Matched { template, path_params } => {
781                let content_type = ctx
782                    .normalized
783                    .headers
784                    .iter()
785                    .find(|(k, _)| k == "content-type")
786                    .map(|(_, v)| v.as_str());
787                let body = (!ctx.body.is_empty()).then(|| ctx.body.as_ref());
788                let req = RequestParts {
789                    path_params: &path_params,
790                    query_params: &ctx.normalized.query_params,
791                    headers: &ctx.normalized.headers,
792                    content_type,
793                    body,
794                };
795                let violations = self.compiled.validate(&template, &ctx.method, &req);
796                if violations.is_empty() {
797                    Decision::Allow
798                } else {
799                    Decision::Scores(
800                        violations
801                            .into_iter()
802                            .map(|v| ScoreItem { rule_id: v.rule().id(), severity: self.violation_severity })
803                            .collect(),
804                    )
805                }
806            }
807        }
808    }
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use serde_json::json;
815
816    const SPEC_30: &str = r#"{
817        "openapi": "3.0.3",
818        "info": { "title": "t", "version": "1" },
819        "paths": { "/users": { "post": { "responses": { "200": { "description": "ok" } } } } }
820    }"#;
821
822    const SPEC_31: &str = r#"{
823        "openapi": "3.1.0",
824        "info": { "title": "t", "version": "1" },
825        "paths": {}
826    }"#;
827
828    fn temp_path(tag: &str) -> PathBuf {
829        let uniq = format!(
830            "waf-oas-{tag}-{}-{:?}.json",
831            std::process::id(),
832            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
833        );
834        std::env::temp_dir().join(uniq)
835    }
836
837    #[test]
838    fn parses_a_valid_3_0_spec_and_flags_the_dialect() {
839        let loaded = parse_spec(SPEC_30).expect("valid 3.0 spec");
840        assert!(loaded.is_3_0, "3.0.x must be flagged for the normalization pass");
841    }
842
843    #[test]
844    fn parses_a_valid_3_1_spec_without_the_3_0_flag() {
845        let loaded = parse_spec(SPEC_31).expect("valid 3.1 spec");
846        assert!(!loaded.is_3_0, "3.1 is draft-2020-12 — no dialect normalization needed");
847    }
848
849    #[test]
850    fn rejects_non_json() {
851        assert!(parse_spec("openapi: 3.0.3").is_err(), "YAML/non-JSON must be rejected (v1 JSON-only)");
852    }
853
854    #[test]
855    fn rejects_a_structurally_invalid_spec() {
856        // Valid JSON, but not an OpenAPI document (no `openapi`/`info`/`paths`).
857        assert!(parse_spec(r#"{"not":"a spec"}"#).is_err());
858    }
859
860    #[test]
861    fn default_config_is_off() {
862        assert!(!ApiSchemaEnforcementConfig::default().enabled);
863    }
864
865    #[test]
866    fn load_spec_reads_and_validates_a_file() {
867        let path = temp_path("ok");
868        std::fs::write(&path, SPEC_30).unwrap();
869        let loaded = load_spec(&path);
870        let _ = std::fs::remove_file(&path);
871        assert!(loaded.is_ok());
872    }
873
874    #[test]
875    fn load_spec_missing_file_is_err() {
876        assert!(load_spec(&temp_path("missing")).is_err());
877    }
878
879    // ── OpenAPI 3.0 → JSON-Schema normalization (the A0 finding) ─────────────────
880
881    #[test]
882    fn nullable_string_becomes_a_type_union() {
883        let mut s = json!({ "type": "string", "nullable": true });
884        normalize_schema_3_0(&mut s);
885        assert_eq!(s, json!({ "type": ["string", "null"] }));
886    }
887
888    #[test]
889    fn nullable_is_applied_deep_in_nested_schemas() {
890        let mut s = json!({
891            "type": "object",
892            "properties": {
893                "user": {
894                    "type": "object",
895                    "properties": { "nickname": { "type": "string", "nullable": true } }
896                }
897            }
898        });
899        normalize_schema_3_0(&mut s);
900        assert_eq!(
901            s["properties"]["user"]["properties"]["nickname"],
902            json!({ "type": ["string", "null"] })
903        );
904    }
905
906    #[test]
907    fn nullable_on_an_array_type_appends_null_once() {
908        let mut s = json!({ "type": ["string", "integer"], "nullable": true });
909        normalize_schema_3_0(&mut s);
910        assert_eq!(s, json!({ "type": ["string", "integer", "null"] }));
911        // Idempotent: a second pass must not append a second "null".
912        normalize_schema_3_0(&mut s);
913        assert_eq!(s, json!({ "type": ["string", "integer", "null"] }));
914    }
915
916    #[test]
917    fn boolean_exclusive_minimum_becomes_numeric() {
918        let mut s = json!({ "type": "integer", "minimum": 1, "exclusiveMinimum": true });
919        normalize_schema_3_0(&mut s);
920        assert_eq!(s, json!({ "type": "integer", "exclusiveMinimum": 1 }));
921
922        // `false` keeps the inclusive bound and just drops the boolean.
923        let mut inclusive = json!({ "type": "integer", "minimum": 1, "exclusiveMinimum": false });
924        normalize_schema_3_0(&mut inclusive);
925        assert_eq!(inclusive, json!({ "type": "integer", "minimum": 1 }));
926    }
927
928    #[test]
929    fn a_3_1_schema_is_left_unchanged() {
930        let original = json!({ "type": ["string", "null"], "properties": { "n": { "type": "string" } } });
931        let mut s = original.clone();
932        normalize_schema_3_0(&mut s);
933        assert_eq!(s, original, "a standard 2020-12 schema must be a no-op");
934    }
935
936    // ── A2: path router ─────────────────────────────────────────────────────────
937
938    const ROUTER_SPEC: &str = r#"{
939        "openapi": "3.0.3",
940        "info": { "title": "t", "version": "1" },
941        "paths": {
942            "/users": {
943                "get":  { "responses": { "200": { "description": "ok" } } },
944                "post": { "responses": { "200": { "description": "ok" } } }
945            },
946            "/users/{id}": {
947                "get":    { "responses": { "200": { "description": "ok" } } },
948                "delete": { "responses": { "200": { "description": "ok" } } }
949            },
950            "/users/me": {
951                "get": { "responses": { "200": { "description": "ok" } } }
952            }
953        }
954    }"#;
955
956    fn router() -> Router {
957        parse_spec(ROUTER_SPEC).expect("valid spec").router
958    }
959
960    fn matched(template: &str, params: &[(&str, &str)]) -> Resolution {
961        Resolution::Matched {
962            template: template.to_string(),
963            path_params: params.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
964        }
965    }
966
967    #[test]
968    fn resolves_static_route_and_method() {
969        assert_eq!(router().resolve("/users", "GET"), matched("/users", &[]));
970        assert_eq!(router().resolve("/users", "POST"), matched("/users", &[]));
971    }
972
973    #[test]
974    fn method_not_declared_is_method_not_allowed() {
975        assert_eq!(router().resolve("/users", "DELETE"), Resolution::MethodNotAllowed);
976    }
977
978    #[test]
979    fn resolves_templated_route_and_extracts_path_param() {
980        assert_eq!(router().resolve("/users/123", "GET"), matched("/users/{id}", &[("id", "123")]));
981        assert_eq!(router().resolve("/users/123", "DELETE"), matched("/users/{id}", &[("id", "123")]));
982    }
983
984    #[test]
985    fn static_route_beats_templated_at_same_position() {
986        // `/users/me` (static) must win over `/users/{id}` (templated).
987        assert_eq!(router().resolve("/users/me", "GET"), matched("/users/me", &[]));
988        // And the method is checked on the STATIC path only: DELETE isn't declared there → 405,
989        // even though `/users/{id}` would allow DELETE.
990        assert_eq!(router().resolve("/users/me", "DELETE"), Resolution::MethodNotAllowed);
991    }
992
993    #[test]
994    fn unknown_path_and_wrong_arity_are_unknown_route() {
995        assert_eq!(router().resolve("/nope", "GET"), Resolution::UnknownRoute);
996        assert_eq!(router().resolve("/users/123/posts", "GET"), Resolution::UnknownRoute);
997    }
998
999    #[test]
1000    fn trailing_slash_is_ignored_but_case_is_significant() {
1001        assert_eq!(router().resolve("/users/", "GET"), matched("/users", &[]));
1002        // OpenAPI paths are case-sensitive: `/Users` is a different (undeclared) resource.
1003        assert_eq!(router().resolve("/Users", "GET"), Resolution::UnknownRoute);
1004    }
1005
1006    #[test]
1007    fn method_matching_is_case_insensitive() {
1008        assert_eq!(router().resolve("/users", "get"), matched("/users", &[]));
1009    }
1010
1011    // ── A3: parameter + body validation ─────────────────────────────────────────
1012
1013    // A 3.0 spec exercising: a typed path param, a typed optional query param, and a $ref'd JSON body
1014    // with a required field, additionalProperties:false, a constrained integer, and a NULLABLE field.
1015    const VALIDATION_SPEC: &str = r##"{
1016        "openapi": "3.0.3",
1017        "info": { "title": "t", "version": "1" },
1018        "components": {
1019            "schemas": {
1020                "User": {
1021                    "type": "object",
1022                    "additionalProperties": false,
1023                    "required": ["name"],
1024                    "properties": {
1025                        "name": { "type": "string" },
1026                        "age": { "type": "integer", "minimum": 0 },
1027                        "nickname": { "type": "string", "nullable": true }
1028                    }
1029                }
1030            }
1031        },
1032        "paths": {
1033            "/users/{id}": {
1034                "parameters": [
1035                    { "name": "id", "in": "path", "required": true, "schema": { "type": "integer" } }
1036                ],
1037                "get": {
1038                    "parameters": [
1039                        { "name": "verbose", "in": "query", "required": false, "schema": { "type": "boolean" } }
1040                    ],
1041                    "responses": { "200": { "description": "ok" } }
1042                },
1043                "post": {
1044                    "requestBody": {
1045                        "required": true,
1046                        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
1047                    },
1048                    "responses": { "200": { "description": "ok" } }
1049                }
1050            }
1051        }
1052    }"##;
1053
1054    fn compiled() -> CompiledSpec {
1055        let spec = parse_spec(VALIDATION_SPEC).expect("valid spec");
1056        CompiledSpec::compile(&spec, &ApiSchemaEnforcementConfig::default()).expect("compiles")
1057    }
1058
1059    fn v(items: &[Violation]) -> BTreeSet<Violation> {
1060        items.iter().copied().collect()
1061    }
1062
1063    fn post_body(spec: &CompiledSpec, ct: &str, body: &str) -> BTreeSet<Violation> {
1064        let pp = vec![("id".to_string(), "1".to_string())];
1065        let req = RequestParts {
1066            path_params: &pp,
1067            query_params: &[],
1068            headers: &[],
1069            content_type: Some(ct),
1070            body: Some(body.as_bytes()),
1071        };
1072        spec.validate("/users/{id}", "post", &req)
1073    }
1074
1075    fn get_users(spec: &CompiledSpec, id: &str, query: &[(&str, &str)]) -> BTreeSet<Violation> {
1076        match spec.resolve(&format!("/users/{id}"), "GET") {
1077            Resolution::Matched { template, path_params } => {
1078                let q: Vec<(String, String)> =
1079                    query.iter().map(|(k, val)| (k.to_string(), val.to_string())).collect();
1080                let req = RequestParts {
1081                    path_params: &path_params,
1082                    query_params: &q,
1083                    headers: &[],
1084                    content_type: None,
1085                    body: None,
1086                };
1087                spec.validate(&template, "get", &req)
1088            }
1089            other => panic!("expected Matched, got {other:?}"),
1090        }
1091    }
1092
1093    #[test]
1094    fn compile_is_boot_fatal_on_a_broken_schema() {
1095        // An invalid regex `pattern`: structurally fine to oas3 (just a string), but `jsonschema`
1096        // fails to compile the regex → the compile step must surface it as boot-fatal.
1097        let broken = r#"{
1098            "openapi": "3.0.3", "info": { "title": "t", "version": "1" },
1099            "paths": { "/x": { "get": {
1100                "parameters": [ { "name": "q", "in": "query", "schema": { "type": "string", "pattern": "[" } } ],
1101                "responses": { "200": { "description": "ok" } }
1102            } } }
1103        }"#;
1104        let spec = parse_spec(broken).expect("spec parses structurally");
1105        assert!(
1106            CompiledSpec::compile(&spec, &ApiSchemaEnforcementConfig::default()).is_err(),
1107            "a parameter schema jsonschema cannot compile must be boot-fatal"
1108        );
1109    }
1110
1111    #[test]
1112    fn valid_request_has_no_violations() {
1113        assert!(post_body(&compiled(), "application/json", r#"{"name":"ada","age":30}"#).is_empty());
1114        assert!(get_users(&compiled(), "5", &[("verbose", "true")]).is_empty());
1115    }
1116
1117    #[test]
1118    fn nullable_body_field_set_to_null_is_clean() {
1119        // THE end-to-end proof that the 3.0 normalization is wired into compile: without it, `null`
1120        // on a `nullable:true` field would be a false TypeViolation.
1121        assert!(post_body(&compiled(), "application/json", r#"{"name":"ada","nickname":null}"#).is_empty());
1122    }
1123
1124    #[test]
1125    fn missing_required_body_field_is_flagged() {
1126        assert_eq!(
1127            post_body(&compiled(), "application/json", r#"{"age":30}"#),
1128            v(&[Violation::MissingRequired])
1129        );
1130    }
1131
1132    #[test]
1133    fn wrong_body_type_is_a_type_violation() {
1134        assert_eq!(
1135            post_body(&compiled(), "application/json", r#"{"name":123}"#),
1136            v(&[Violation::TypeViolation])
1137        );
1138    }
1139
1140    #[test]
1141    fn unknown_body_property_is_flagged_via_additional_properties() {
1142        assert_eq!(
1143            post_body(&compiled(), "application/json", r#"{"name":"ada","isAdmin":true}"#),
1144            v(&[Violation::UnknownProperty])
1145        );
1146    }
1147
1148    #[test]
1149    fn undeclared_content_type_is_flagged_and_body_not_json_validated() {
1150        // text/plain is not declared → ContentType; and not JSON, so no schema validation runs.
1151        assert_eq!(post_body(&compiled(), "text/plain", "hello"), v(&[Violation::ContentType]));
1152    }
1153
1154    #[test]
1155    fn unparseable_json_body_is_a_parse_error() {
1156        assert_eq!(
1157            post_body(&compiled(), "application/json", "{not json"),
1158            v(&[Violation::BodyParseError])
1159        );
1160    }
1161
1162    #[test]
1163    fn missing_required_body_altogether_is_flagged() {
1164        let pp = vec![("id".to_string(), "1".to_string())];
1165        let req = RequestParts {
1166            path_params: &pp,
1167            query_params: &[],
1168            headers: &[],
1169            content_type: None,
1170            body: None,
1171        };
1172        assert_eq!(compiled().validate("/users/{id}", "post", &req), v(&[Violation::MissingRequired]));
1173    }
1174
1175    #[test]
1176    fn path_param_type_is_coerced_and_validated() {
1177        assert!(get_users(&compiled(), "5", &[]).is_empty());
1178        assert_eq!(get_users(&compiled(), "abc", &[]), v(&[Violation::TypeViolation]));
1179    }
1180
1181    #[test]
1182    fn undeclared_query_param_is_flagged_when_strict() {
1183        assert_eq!(get_users(&compiled(), "5", &[("foo", "bar")]), v(&[Violation::UnknownParam]));
1184    }
1185
1186    #[test]
1187    fn wrong_query_param_type_is_a_type_violation() {
1188        assert_eq!(
1189            get_users(&compiled(), "5", &[("verbose", "maybe")]),
1190            v(&[Violation::TypeViolation])
1191        );
1192    }
1193
1194    #[test]
1195    fn strict_params_off_allows_undeclared_query_params() {
1196        let spec = parse_spec(VALIDATION_SPEC).unwrap();
1197        let cfg = ApiSchemaEnforcementConfig { strict_params: false, ..Default::default() };
1198        let compiled = CompiledSpec::compile(&spec, &cfg).unwrap();
1199        match compiled.resolve("/users/5", "GET") {
1200            Resolution::Matched { template, path_params } => {
1201                let q = vec![("foo".to_string(), "bar".to_string())];
1202                let req = RequestParts {
1203                    path_params: &path_params,
1204                    query_params: &q,
1205                    headers: &[],
1206                    content_type: None,
1207                    body: None,
1208                };
1209                assert!(compiled.validate(&template, "get", &req).is_empty());
1210            }
1211            other => panic!("expected Matched, got {other:?}"),
1212        }
1213    }
1214}