Skip to main content

mockforge_openapi/
openapi_routes.rs

1//! OpenAPI-based route generation for MockForge
2//!
3//! The authoritative `OpenApiRouteRegistry` and all `build_router_*` methods
4//! are defined in this file. Sub-modules provide additional utilities:
5//! - `builder`: Helper functions for building routers from specs
6//! - `generation`: Route generation utilities
7//! - `validation`: Request/response validation types and logic
8//!
9//! Note: `registry` sub-module contains an abandoned partial refactoring with a
10//! duplicate `OpenApiRouteRegistry` type. Use the one from this module.
11
12pub mod builder;
13pub mod generation;
14#[doc(hidden)]
15pub mod registry;
16pub mod validation;
17
18use crate::response::AiGenerator;
19use crate::response_rewriter::ResponseRewriter;
20use crate::{OpenApiOperation, OpenApiRoute, OpenApiSchema, OpenApiSpec};
21use axum::extract::{DefaultBodyLimit, Path as AxumPath, RawQuery};
22use axum::http::HeaderMap;
23use axum::response::IntoResponse;
24use axum::routing::*;
25use axum::{Json, Router};
26pub use builder::*;
27use chrono::Utc;
28pub use generation::*;
29use mockforge_foundation::ai_response::RequestContext;
30use mockforge_foundation::error::{Error, Result};
31use mockforge_foundation::latency::LatencyInjector;
32use mockforge_foundation::response_generation_trace::ResponseGenerationTrace;
33use mockforge_foundation::schema_diff::validation_diff;
34use once_cell::sync::Lazy;
35use openapiv3::ParameterSchemaOrContent;
36use serde_json::{json, Map, Value};
37use std::collections::{HashMap, HashSet, VecDeque};
38use std::sync::{Arc, Mutex};
39use tracing;
40pub use validation::*;
41
42/// OpenAPI route registry that manages generated routes
43#[derive(Clone)]
44pub struct OpenApiRouteRegistry {
45    /// The OpenAPI specification
46    spec: Arc<OpenApiSpec>,
47    /// Generated routes
48    routes: Vec<OpenApiRoute>,
49    /// Validation options
50    options: ValidationOptions,
51    /// Custom fixture loader (optional)
52    custom_fixture_loader: Option<Arc<crate::custom_fixture::CustomFixtureLoader>>,
53}
54
55/// Validation mode for request/response validation
56#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)]
57pub enum ValidationMode {
58    /// Validation is disabled (no checks performed)
59    Disabled,
60    /// Validation warnings are logged but do not fail requests
61    #[default]
62    Warn,
63    /// Validation failures return error responses
64    Enforce,
65}
66
67/// Options for configuring validation behavior
68#[derive(Debug, Clone)]
69pub struct ValidationOptions {
70    /// Validation mode for incoming requests
71    pub request_mode: ValidationMode,
72    /// Whether to aggregate multiple validation errors into a single response
73    pub aggregate_errors: bool,
74    /// Whether to validate outgoing responses against schemas
75    pub validate_responses: bool,
76    /// Per-operation validation mode overrides (operation ID -> mode)
77    pub overrides: HashMap<String, ValidationMode>,
78    /// Skip validation for request paths starting with any of these prefixes
79    pub admin_skip_prefixes: Vec<String>,
80    /// Expand templating tokens in responses/examples after generation
81    pub response_template_expand: bool,
82    /// HTTP status code to return for validation failures (e.g., 400 or 422)
83    pub validation_status: Option<u16>,
84}
85
86impl Default for ValidationOptions {
87    fn default() -> Self {
88        Self {
89            request_mode: ValidationMode::Enforce,
90            aggregate_errors: true,
91            validate_responses: false,
92            overrides: HashMap::new(),
93            admin_skip_prefixes: Vec::new(),
94            response_template_expand: false,
95            validation_status: None,
96        }
97    }
98}
99
100/// Shared context for all route handlers, encapsulating optional features.
101///
102/// Each `build_router_*` variant constructs a `RouterContext` with the appropriate
103/// features enabled, then delegates to `build_router_with_context`.
104#[derive(Clone)]
105pub struct RouterContext {
106    /// Custom fixture loader (highest priority response source)
107    pub custom_fixture_loader: Option<Arc<crate::custom_fixture::CustomFixtureLoader>>,
108    /// Latency injector (per-operation-tag latency simulation)
109    pub latency_injector: Option<LatencyInjector>,
110    /// Failure injector (per-tag fault injection)
111    pub failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
112    /// Response-body mutation hook — used for template token expansion
113    /// and override rule application. Core's concrete impl is
114    /// [`crate::openapi_rewriter::CoreResponseRewriter`].
115    pub response_rewriter: Option<Arc<dyn ResponseRewriter>>,
116    /// Whether override application is active (gates the
117    /// [`ResponseRewriter::apply_overrides`] call).
118    pub overrides_enabled: bool,
119    /// AI response generator
120    pub ai_generator: Option<Arc<dyn AiGenerator + Send + Sync>>,
121    /// MockAI intelligent behavior handle (type-erased via
122    /// [`mockforge_foundation::intelligent_behavior::MockAiBehavior`] so
123    /// the OpenAPI router doesn't depend on core's concrete `MockAI`).
124    pub mockai: Option<
125        Arc<
126            tokio::sync::RwLock<
127                dyn mockforge_foundation::intelligent_behavior::MockAiBehavior + Send + Sync,
128            >,
129        >,
130    >,
131    /// Enable full validation (422 enhanced responses, response validation, trace)
132    pub enable_full_validation: bool,
133    /// Enable template token expansion
134    pub enable_template_expand: bool,
135    /// Whether to add /openapi.json endpoint
136    pub add_spec_endpoint: bool,
137}
138
139impl Default for RouterContext {
140    fn default() -> Self {
141        Self {
142            custom_fixture_loader: None,
143            latency_injector: None,
144            failure_injector: None,
145            response_rewriter: None,
146            overrides_enabled: false,
147            ai_generator: None,
148            mockai: None,
149            enable_full_validation: false,
150            enable_template_expand: false,
151            add_spec_endpoint: true,
152        }
153    }
154}
155
156/// Maximum body bytes the OpenAPI router accepts. Axum's `DefaultBodyLimit`
157/// is 2 MiB out of the box; for an HTTP **mock** that's far too low —
158/// users routinely send fixture-sized JSON, multipart uploads, or
159/// chunked-transfer test payloads in the tens of MB. When the body
160/// exceeds the limit, axum's `Bytes` / `Option<Json<Value>>` extractors
161/// truncate the body and the handler runs without ever consuming the
162/// rest of the request, so hyper sends the response and SSL Close
163/// Notify *while the client is still uploading* — which is exactly the
164/// "200 OK before all chunk requests arrived" behaviour Srikanth caught
165/// on the 10 MB chunked PCAP for Issue #79.
166///
167/// Configurable via `MOCKFORGE_HTTP_BODY_LIMIT_MB`. Default 50 MiB,
168/// which covers the realistic mock-traffic range without giving an
169/// untrusted client an unlimited memory-fill vector.
170fn openapi_body_limit_bytes() -> usize {
171    const DEFAULT_MB: usize = 50;
172    std::env::var("MOCKFORGE_HTTP_BODY_LIMIT_MB")
173        .ok()
174        .and_then(|v| v.parse::<usize>().ok())
175        .unwrap_or(DEFAULT_MB)
176        .saturating_mul(1024 * 1024)
177}
178
179impl OpenApiRouteRegistry {
180    /// Create a new registry from an OpenAPI spec
181    pub fn new(spec: OpenApiSpec) -> Self {
182        Self::new_with_env(spec)
183    }
184
185    /// Create a new registry from an OpenAPI spec with environment-based validation options
186    ///
187    /// Options are read from environment variables:
188    /// - `MOCKFORGE_REQUEST_VALIDATION`: "off"/"warn"/"enforce" (default: "enforce")
189    /// - `MOCKFORGE_AGGREGATE_ERRORS`: "1"/"true" to aggregate errors (default: true)
190    /// - `MOCKFORGE_RESPONSE_VALIDATION`: "1"/"true" to validate responses (default: false)
191    /// - `MOCKFORGE_RESPONSE_TEMPLATE_EXPAND`: "1"/"true" to expand templates (default: false)
192    /// - `MOCKFORGE_VALIDATION_STATUS`: HTTP status code for validation failures (optional)
193    pub fn new_with_env(spec: OpenApiSpec) -> Self {
194        Self::new_with_env_and_persona(spec, None)
195    }
196
197    /// Create a new registry from an OpenAPI spec with environment-based validation options and persona
198    pub fn new_with_env_and_persona(
199        spec: OpenApiSpec,
200        persona: Option<Arc<mockforge_foundation::intelligent_behavior::Persona>>,
201    ) -> Self {
202        tracing::debug!("Creating OpenAPI route registry");
203        let spec = Arc::new(spec);
204        let routes = Self::generate_routes_with_persona(&spec, persona);
205        let options = ValidationOptions {
206            request_mode: match std::env::var("MOCKFORGE_REQUEST_VALIDATION")
207                .unwrap_or_else(|_| "enforce".into())
208                .to_ascii_lowercase()
209                .as_str()
210            {
211                "off" | "disable" | "disabled" => ValidationMode::Disabled,
212                "warn" | "warning" => ValidationMode::Warn,
213                _ => ValidationMode::Enforce,
214            },
215            aggregate_errors: std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
216                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
217                .unwrap_or(true),
218            validate_responses: std::env::var("MOCKFORGE_RESPONSE_VALIDATION")
219                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
220                .unwrap_or(false),
221            overrides: HashMap::new(),
222            admin_skip_prefixes: Vec::new(),
223            response_template_expand: std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
224                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
225                .unwrap_or(false),
226            validation_status: std::env::var("MOCKFORGE_VALIDATION_STATUS")
227                .ok()
228                .and_then(|s| s.parse::<u16>().ok()),
229        };
230        Self {
231            spec,
232            routes,
233            options,
234            custom_fixture_loader: None,
235        }
236    }
237
238    /// Construct with explicit options
239    pub fn new_with_options(spec: OpenApiSpec, options: ValidationOptions) -> Self {
240        Self::new_with_options_and_persona(spec, options, None)
241    }
242
243    /// Construct with explicit options and persona
244    pub fn new_with_options_and_persona(
245        spec: OpenApiSpec,
246        options: ValidationOptions,
247        persona: Option<Arc<mockforge_foundation::intelligent_behavior::Persona>>,
248    ) -> Self {
249        tracing::debug!("Creating OpenAPI route registry with custom options");
250        let spec = Arc::new(spec);
251        let routes = Self::generate_routes_with_persona(&spec, persona);
252        Self {
253            spec,
254            routes,
255            options,
256            custom_fixture_loader: None,
257        }
258    }
259
260    /// Set custom fixture loader
261    pub fn with_custom_fixture_loader(
262        mut self,
263        loader: Arc<crate::custom_fixture::CustomFixtureLoader>,
264    ) -> Self {
265        self.custom_fixture_loader = Some(loader);
266        self
267    }
268
269    /// Clone this registry for validation purposes (creates an independent copy)
270    ///
271    /// This is useful when you need a separate registry instance for validation
272    /// that won't interfere with the main registry's state.
273    pub fn clone_for_validation(&self) -> Self {
274        OpenApiRouteRegistry {
275            spec: self.spec.clone(),
276            routes: self.routes.clone(),
277            options: self.options.clone(),
278            custom_fixture_loader: self.custom_fixture_loader.clone(),
279        }
280    }
281
282    /// Generate routes from the OpenAPI specification with optional persona
283    fn generate_routes_with_persona(
284        spec: &Arc<OpenApiSpec>,
285        persona: Option<Arc<mockforge_foundation::intelligent_behavior::Persona>>,
286    ) -> Vec<OpenApiRoute> {
287        let mut routes = Vec::new();
288
289        let all_paths_ops = spec.all_paths_and_operations();
290        tracing::debug!("Generating routes from OpenAPI spec with {} paths", all_paths_ops.len());
291
292        for (path, operations) in all_paths_ops {
293            tracing::debug!("Processing path: {}", path);
294            for (method, operation) in operations {
295                routes.push(OpenApiRoute::from_operation_with_persona(
296                    &method,
297                    path.clone(),
298                    &operation,
299                    spec.clone(),
300                    persona.clone(),
301                ));
302            }
303        }
304
305        tracing::debug!("Generated {} total routes from OpenAPI spec", routes.len());
306        routes
307    }
308
309    /// Get all routes
310    pub fn routes(&self) -> &[OpenApiRoute] {
311        &self.routes
312    }
313
314    /// Get the OpenAPI specification
315    pub fn spec(&self) -> &OpenApiSpec {
316        &self.spec
317    }
318
319    /// Normalize an Axum path for dedup by replacing all `{param}` with `{_}`.
320    /// This ensures paths like `/func/{period}` and `/func/{date}` are treated as duplicates,
321    /// since Axum/matchit treats all path parameters as equivalent for routing.
322    fn normalize_path_for_dedup(path: &str) -> String {
323        let mut result = String::with_capacity(path.len());
324        let mut in_brace = false;
325        for ch in path.chars() {
326            if ch == '{' {
327                in_brace = true;
328                result.push_str("{_}");
329            } else if ch == '}' {
330                in_brace = false;
331            } else if !in_brace {
332                result.push(ch);
333            }
334        }
335        result
336    }
337
338    /// Returns deduplicated routes with their resolved Axum-compatible paths.
339    ///
340    /// Handles path validation, canonical param-name resolution (to prevent matchit panics
341    /// when two OpenAPI paths differ only in param names), and duplicate detection.
342    /// This is the shared preamble extracted from all `build_router_*` variants.
343    fn deduplicated_routes(&self) -> Vec<(String, &OpenApiRoute)> {
344        let mut result = Vec::new();
345        let mut registered_routes: HashSet<(String, String)> = HashSet::new();
346        let mut canonical_paths: HashMap<String, String> = HashMap::new();
347
348        for route in &self.routes {
349            if !route.is_valid_axum_path() {
350                tracing::warn!(
351                    "Skipping route with unsupported path syntax: {} {}",
352                    route.method,
353                    route.path
354                );
355                continue;
356            }
357            let axum_path = route.axum_path();
358            let normalized = Self::normalize_path_for_dedup(&axum_path);
359            let axum_path = canonical_paths
360                .entry(normalized.clone())
361                .or_insert_with(|| axum_path.clone())
362                .clone();
363            let route_key = (route.method.clone(), normalized);
364            if !registered_routes.insert(route_key) {
365                tracing::debug!(
366                    "Skipping duplicate route: {} {} (axum path: {})",
367                    route.method,
368                    route.path,
369                    axum_path
370                );
371                continue;
372            }
373            result.push((axum_path, route));
374        }
375        result
376    }
377
378    /// Register a handler on a router for the given HTTP method.
379    ///
380    /// Shared epilogue extracted from all `build_router_*` variants.
381    fn route_for_method<H, T>(router: Router, path: &str, method: &str, handler: H) -> Router
382    where
383        H: axum::handler::Handler<T, ()>,
384        T: 'static,
385    {
386        match method {
387            "GET" => router.route(path, get(handler)),
388            "POST" => router.route(path, post(handler)),
389            "PUT" => router.route(path, put(handler)),
390            "DELETE" => router.route(path, delete(handler)),
391            "PATCH" => router.route(path, patch(handler)),
392            "HEAD" => router.route(path, head(handler)),
393            "OPTIONS" => router.route(path, options(handler)),
394            _ => router,
395        }
396    }
397
398    /// Build an Axum router from the OpenAPI spec (simplified)
399    pub fn build_router(self) -> Router {
400        let ctx = RouterContext {
401            custom_fixture_loader: self.custom_fixture_loader.clone(),
402            enable_full_validation: true,
403            enable_template_expand: true,
404            add_spec_endpoint: true,
405            ..Default::default()
406        };
407        self.build_router_with_context(ctx)
408    }
409
410    /// Build an Axum router using a shared RouterContext.
411    ///
412    /// This is the unified router builder that all `build_router_*` variants
413    /// delegate to. The RouterContext controls which features are active.
414    fn build_router_with_context(self, ctx: RouterContext) -> Router {
415        let mut router = Router::new();
416        tracing::debug!("Building router from {} routes", self.routes.len());
417
418        let deduped = self.deduplicated_routes();
419        let ctx = Arc::new(ctx);
420        // Issue #79 round 14 hotfix — share ONE validator across all
421        // route handlers via Arc. Previously each of N route closures
422        // captured its own `clone_for_validation()` (which deep-clones
423        // the entire N-element routes Vec), making router construction
424        // O(N²) in memory — ~260 GB resident for an 11,422-operation
425        // spec (Microsoft Graph), which the OOM killer reaped right
426        // after "Stored N routes". An Arc clone is 8 bytes.
427        let validator = Arc::new(self.clone_for_validation());
428        for (axum_path, route) in &deduped {
429            tracing::debug!("Adding route: {} {}", route.method, route.path);
430            let operation = route.operation.clone();
431            let method = route.method.clone();
432            let path_template = route.path.clone();
433            let validator = validator.clone();
434            let route_clone = (*route).clone();
435            let ctx = ctx.clone();
436
437            // Extract tags from operation for latency and failure injection
438            let mut operation_tags = operation.tags.clone();
439            if let Some(operation_id) = &operation.operation_id {
440                operation_tags.push(operation_id.clone());
441            }
442
443            // Unified handler: fixture -> failure -> latency -> scenario/override -> mock -> validate -> expand -> overrides -> trace -> response
444            let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
445                                RawQuery(raw_query): RawQuery,
446                                headers: HeaderMap,
447                                body: axum::body::Bytes| async move {
448                tracing::debug!("Handling OpenAPI request: {} {}", method, path_template);
449
450                // (a) Check for custom fixture first (highest priority)
451                if let Some(ref loader) = ctx.custom_fixture_loader {
452                    use crate::request_fingerprint::RequestFingerprint;
453                    use axum::http::{Method, Uri};
454
455                    // Reconstruct the full path from template and params
456                    let mut request_path = path_template.clone();
457                    for (key, value) in &path_params {
458                        request_path = request_path.replace(&format!("{{{}}}", key), value);
459                    }
460
461                    // Normalize the path to match fixture normalization
462                    let normalized_request_path =
463                        crate::custom_fixture::CustomFixtureLoader::normalize_path(&request_path);
464
465                    // Build query string
466                    let query_string =
467                        raw_query.as_ref().map(|q| q.to_string()).unwrap_or_default();
468
469                    // Create URI for fingerprint
470                    // IMPORTANT: Use normalized path to match fixture paths
471                    let uri_str = if query_string.is_empty() {
472                        normalized_request_path.clone()
473                    } else {
474                        format!("{}?{}", normalized_request_path, query_string)
475                    };
476
477                    if let Ok(uri) = uri_str.parse::<Uri>() {
478                        let http_method =
479                            Method::from_bytes(method.as_bytes()).unwrap_or(Method::GET);
480                        let body_slice = if body.is_empty() {
481                            None
482                        } else {
483                            Some(body.as_ref())
484                        };
485                        let fingerprint =
486                            RequestFingerprint::new(http_method, &uri, &headers, body_slice);
487
488                        // Debug logging for fixture matching
489                        tracing::debug!(
490                            "Checking fixture for {} {} (template: '{}', request_path: '{}', normalized: '{}', fingerprint.path: '{}')",
491                            method,
492                            path_template,
493                            path_template,
494                            request_path,
495                            normalized_request_path,
496                            fingerprint.path
497                        );
498
499                        if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
500                            tracing::debug!(
501                                "Using custom fixture for {} {}",
502                                method,
503                                path_template
504                            );
505
506                            // Apply delay if specified
507                            if custom_fixture.delay_ms > 0 {
508                                tokio::time::sleep(tokio::time::Duration::from_millis(
509                                    custom_fixture.delay_ms,
510                                ))
511                                .await;
512                            }
513
514                            // Convert response to JSON string if needed
515                            let response_body = if custom_fixture.response.is_string() {
516                                custom_fixture.response.as_str().unwrap().to_string()
517                            } else {
518                                serde_json::to_string(&custom_fixture.response)
519                                    .unwrap_or_else(|_| "{}".to_string())
520                            };
521
522                            // Parse response body as JSON
523                            let json_value: Value = serde_json::from_str(&response_body)
524                                .unwrap_or_else(|_| serde_json::json!({}));
525
526                            // Build response with status and JSON body
527                            let status = axum::http::StatusCode::from_u16(custom_fixture.status)
528                                .unwrap_or(axum::http::StatusCode::OK);
529
530                            let mut response = (status, Json(json_value)).into_response();
531
532                            // Add custom headers to response
533                            let response_headers = response.headers_mut();
534                            for (key, value) in &custom_fixture.headers {
535                                if let (Ok(header_name), Ok(header_value)) = (
536                                    axum::http::HeaderName::from_bytes(key.as_bytes()),
537                                    axum::http::HeaderValue::from_str(value),
538                                ) {
539                                    response_headers.insert(header_name, header_value);
540                                }
541                            }
542
543                            // Ensure content-type is set if not already present
544                            if !custom_fixture.headers.contains_key("content-type") {
545                                response_headers.insert(
546                                    axum::http::header::CONTENT_TYPE,
547                                    axum::http::HeaderValue::from_static("application/json"),
548                                );
549                            }
550
551                            return response;
552                        }
553                    }
554                }
555
556                // (b) Failure injection (if configured)
557                if let Some(ref failure_injector) = ctx.failure_injector {
558                    if let Some((status_code, error_message)) =
559                        failure_injector.process_request(&operation_tags)
560                    {
561                        let payload = serde_json::json!({
562                            "error": error_message,
563                            "injected_failure": true
564                        });
565                        let body_bytes = serde_json::to_vec(&payload)
566                            .unwrap_or_else(|_| br#"{"error":"injected failure"}"#.to_vec());
567                        return axum::http::Response::builder()
568                            .status(
569                                axum::http::StatusCode::from_u16(status_code)
570                                    .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
571                            )
572                            .header(axum::http::header::CONTENT_TYPE, "application/json")
573                            .body(axum::body::Body::from(body_bytes))
574                            .expect("Response builder should create valid response");
575                    }
576                }
577
578                // (c) Latency injection (if configured)
579                if let Some(ref injector) = ctx.latency_injector {
580                    if let Err(e) = injector.inject_latency(&operation_tags).await {
581                        tracing::warn!("Failed to inject latency: {}", e);
582                    }
583                }
584
585                // (d) Scenario/status override from headers
586                let scenario = headers
587                    .get("X-Mockforge-Scenario")
588                    .and_then(|v| v.to_str().ok())
589                    .map(|s| s.to_string())
590                    .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
591
592                let status_override = headers
593                    .get("X-Mockforge-Response-Status")
594                    .and_then(|v| v.to_str().ok())
595                    .and_then(|s| s.parse::<u16>().ok());
596
597                // (e) Generate mock response for this request with scenario support
598                let (selected_status, mock_response) = route_clone
599                    .mock_response_with_status_and_scenario_and_override(
600                        scenario.as_deref(),
601                        status_override,
602                    );
603
604                // (f) Validation (if full validation is enabled)
605                if ctx.enable_full_validation {
606                    // Build params maps
607                    let mut path_map = Map::new();
608                    for (k, v) in &path_params {
609                        path_map.insert(k.clone(), Value::String(v.clone()));
610                    }
611
612                    // Query
613                    let mut query_map = Map::new();
614                    if let Some(ref q) = raw_query {
615                        for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
616                            query_map.insert(k.to_string(), Value::String(v.to_string()));
617                        }
618                    }
619
620                    // Headers: only capture those declared on this operation
621                    let mut header_map = Map::new();
622                    for p_ref in &operation.parameters {
623                        if let Some(openapiv3::Parameter::Header { parameter_data, .. }) =
624                            p_ref.as_item()
625                        {
626                            let name_lc = parameter_data.name.to_ascii_lowercase();
627                            if let Ok(hn) = axum::http::HeaderName::from_bytes(name_lc.as_bytes()) {
628                                if let Some(val) = headers.get(hn) {
629                                    if let Ok(s) = val.to_str() {
630                                        header_map.insert(
631                                            parameter_data.name.clone(),
632                                            Value::String(s.to_string()),
633                                        );
634                                    }
635                                }
636                            }
637                        }
638                    }
639
640                    // Cookies: parse Cookie header
641                    let mut cookie_map = Map::new();
642                    if let Some(val) = headers.get(axum::http::header::COOKIE) {
643                        if let Ok(s) = val.to_str() {
644                            for part in s.split(';') {
645                                let part = part.trim();
646                                if let Some((k, v)) = part.split_once('=') {
647                                    cookie_map.insert(k.to_string(), Value::String(v.to_string()));
648                                }
649                            }
650                        }
651                    }
652
653                    // Check if this is a multipart request
654                    let is_multipart = headers
655                        .get(axum::http::header::CONTENT_TYPE)
656                        .and_then(|v| v.to_str().ok())
657                        .map(|ct| ct.starts_with("multipart/form-data"))
658                        .unwrap_or(false);
659
660                    // Extract multipart data if applicable
661                    #[allow(unused_assignments)]
662                    let mut multipart_fields = HashMap::new();
663                    let mut _multipart_files = HashMap::new();
664                    let mut body_json: Option<Value> = None;
665
666                    if is_multipart {
667                        // For multipart requests, extract fields and files
668                        match extract_multipart_from_bytes(&body, &headers).await {
669                            Ok((fields, files)) => {
670                                multipart_fields = fields;
671                                _multipart_files = files;
672                                // Also create a JSON representation for validation
673                                let mut body_obj = Map::new();
674                                for (k, v) in &multipart_fields {
675                                    body_obj.insert(k.clone(), v.clone());
676                                }
677                                if !body_obj.is_empty() {
678                                    body_json = Some(Value::Object(body_obj));
679                                }
680                            }
681                            Err(e) => {
682                                tracing::warn!("Failed to parse multipart data: {}", e);
683                            }
684                        }
685                    } else {
686                        // Body: try JSON when present
687                        body_json = if !body.is_empty() {
688                            serde_json::from_slice(&body).ok()
689                        } else {
690                            None
691                        };
692                    }
693
694                    // Round 28 — content-type-mismatch check before the
695                    // body schema validator. Srikanth's
696                    // 0.3.171 trace: bench sent `Content-Type:
697                    // application/xml` against a JSON-only endpoint,
698                    // server happily 204'd (because the body still
699                    // parsed as JSON) and the conformance buffer never
700                    // saw a violation. Now the validator surfaces the
701                    // mismatch explicitly so the TUI Conformance tab
702                    // shows it and the server returns the configured
703                    // validation status. The body block below still
704                    // runs for cases where Content-Type matched but
705                    // the body shape doesn't.
706                    let actual_ct =
707                        headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok());
708                    if let Err(ct_err) =
709                        validator.check_request_content_type(&path_template, &method, actual_ct)
710                    {
711                        let status_code =
712                            validator.options.validation_status.unwrap_or_else(|| {
713                                std::env::var("MOCKFORGE_VALIDATION_STATUS")
714                                    .ok()
715                                    .and_then(|s| s.parse::<u16>().ok())
716                                    .unwrap_or(415)
717                            });
718                        let (client_mockforge_version, client_sent_at) =
719                            mockforge_foundation::conformance_violations::read_client_stamps(
720                                |name| {
721                                    headers
722                                        .get(name)
723                                        .and_then(|v| v.to_str().ok())
724                                        .map(|s| s.to_string())
725                                },
726                            );
727                        mockforge_foundation::conformance_violations::record(
728                            mockforge_foundation::conformance_violations::ServerConformanceViolation {
729                                timestamp: Utc::now(),
730                                method: method.to_string(),
731                                path: path_template.clone(),
732                                client_ip: "unknown".to_string(),
733                                status: status_code,
734                                reason: ct_err.clone(),
735                                category: "content-types".to_string(),
736                                occurrences: 1,
737                                client_mockforge_version,
738                                client_sent_at,
739                            },
740                        );
741                        let status = axum::http::StatusCode::from_u16(status_code)
742                            .unwrap_or(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
743                        let payload = serde_json::json!({
744                            "error": "content_type_not_allowed",
745                            "message": ct_err,
746                        });
747                        let body_bytes = serde_json::to_vec(&payload)
748                            .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
749                        return axum::response::Response::builder()
750                            .status(status)
751                            .header("content-type", "application/json")
752                            .body(axum::body::Body::from(body_bytes))
753                            .unwrap_or_else(|_| {
754                                axum::response::Response::builder()
755                                    .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
756                                    .body(axum::body::Body::empty())
757                                    .unwrap()
758                            });
759                    }
760
761                    if let Err(e) = validator.validate_request_with_all(
762                        &path_template,
763                        &method,
764                        &path_map,
765                        &query_map,
766                        &header_map,
767                        &cookie_map,
768                        body_json.as_ref(),
769                    ) {
770                        // Choose status: prefer options.validation_status, fallback to env, else 400
771                        let status_code =
772                            validator.options.validation_status.unwrap_or_else(|| {
773                                std::env::var("MOCKFORGE_VALIDATION_STATUS")
774                                    .ok()
775                                    .and_then(|s| s.parse::<u16>().ok())
776                                    .unwrap_or(400)
777                            });
778
779                        let payload = if status_code == 422 {
780                            // For 422 responses, use enhanced schema validation with detailed errors
781                            generate_enhanced_422_response(
782                                &validator,
783                                &path_template,
784                                &method,
785                                body_json.as_ref(),
786                                &path_map,
787                                &query_map,
788                                &header_map,
789                                &cookie_map,
790                            )
791                        } else {
792                            // For other status codes, use generic error format
793                            let msg = format!("{}", e);
794                            let detail_val = serde_json::from_str::<Value>(&msg)
795                                .unwrap_or(serde_json::json!(msg));
796                            json!({
797                                "error": "request validation failed",
798                                "detail": detail_val,
799                                "method": method,
800                                "path": path_template,
801                                "timestamp": Utc::now().to_rfc3339(),
802                            })
803                        };
804
805                        record_validation_error(&payload);
806
807                        // Issue #79 round 12 — also push to the workspace-wide
808                        // server-conformance violation ring buffer so the TUI's
809                        // "Conformance" screen can surface incoming spec
810                        // violations. Best-effort, bounded; the existing
811                        // `record_validation_error` keeps writing to its
812                        // tenant-scoped persistent store unchanged.
813                        let reason = payload
814                            .get("detail")
815                            .and_then(|d| {
816                                if d.is_string() {
817                                    d.as_str().map(|s| s.to_string())
818                                } else {
819                                    serde_json::to_string(d).ok()
820                                }
821                            })
822                            .unwrap_or_else(|| {
823                                payload
824                                    .get("error")
825                                    .and_then(|v| v.as_str())
826                                    .unwrap_or("request validation failed")
827                                    .to_string()
828                            });
829                        let category = classify_validation_reason(&reason);
830                        let (client_mockforge_version, client_sent_at) =
831                            mockforge_foundation::conformance_violations::read_client_stamps(
832                                |name| {
833                                    headers
834                                        .get(name)
835                                        .and_then(|v| v.to_str().ok())
836                                        .map(|s| s.to_string())
837                                },
838                            );
839                        mockforge_foundation::conformance_violations::record(
840                            mockforge_foundation::conformance_violations::ServerConformanceViolation {
841                                timestamp: Utc::now(),
842                                method: method.to_string(),
843                                path: path_template.clone(),
844                                client_ip: "unknown".to_string(),
845                                status: status_code,
846                                reason,
847                                category,
848                                occurrences: 1,
849                                client_mockforge_version,
850                                client_sent_at,
851                            },
852                        );
853
854                        let status = axum::http::StatusCode::from_u16(status_code)
855                            .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
856
857                        // Serialize payload with fallback for serialization errors
858                        let body_bytes = serde_json::to_vec(&payload)
859                            .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
860
861                        return axum::http::Response::builder()
862                            .status(status)
863                            .header(axum::http::header::CONTENT_TYPE, "application/json")
864                            .body(axum::body::Body::from(body_bytes))
865                            .expect("Response builder should create valid response with valid headers and body");
866                    }
867                }
868
869                // (g) Template expansion (if enabled via context or env var).
870                //
871                // The `MOCKFORGE_RESPONSE_TEMPLATE_EXPAND` env var is tri-state:
872                //   * unset  -> fall back to context/options flags
873                //   * "true" / "1" -> force expansion on
874                //   * "false" / anything else -> force expansion OFF
875                // Treating it as a forcing override (not just OR) lets tests and
876                // ad-hoc operator overrides disable token expansion explicitly.
877                let mut final_response = mock_response.clone();
878                let env_expand: Option<bool> = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
879                    .ok()
880                    .map(|v| v == "1" || v.eq_ignore_ascii_case("true"));
881                let expand = match env_expand {
882                    Some(v) => v,
883                    None => {
884                        ctx.enable_template_expand || validator.options.response_template_expand
885                    }
886                };
887                if expand {
888                    if let Some(ref rewriter) = ctx.response_rewriter {
889                        rewriter.expand_tokens(&mut final_response);
890                    }
891                }
892
893                // (h) Apply overrides if a rewriter is wired up and overrides are enabled.
894                if ctx.overrides_enabled {
895                    if let Some(ref rewriter) = ctx.response_rewriter {
896                        let op_tags =
897                            operation.operation_id.clone().map(|id| vec![id]).unwrap_or_default();
898                        rewriter.apply_overrides(
899                            &operation.operation_id.clone().unwrap_or_default(),
900                            &op_tags,
901                            &path_template,
902                            &mut final_response,
903                        );
904                    }
905                }
906
907                // (i) Response validation and trace (if full validation is enabled)
908                if ctx.enable_full_validation {
909                    // Optional response validation
910                    if validator.options.validate_responses {
911                        // Find the first 2xx response in the operation
912                        if let Some((status_code, _response)) = operation
913                            .responses
914                            .responses
915                            .iter()
916                            .filter_map(|(status, resp)| match status {
917                                openapiv3::StatusCode::Code(code)
918                                    if *code >= 200 && *code < 300 =>
919                                {
920                                    resp.as_item().map(|r| ((*code), r))
921                                }
922                                openapiv3::StatusCode::Range(range)
923                                    if *range >= 200 && *range < 300 =>
924                                {
925                                    resp.as_item().map(|r| (200, r))
926                                }
927                                _ => None,
928                            })
929                            .next()
930                        {
931                            // Basic response validation - check if response is valid JSON
932                            if serde_json::from_value::<Value>(final_response.clone()).is_err() {
933                                tracing::warn!(
934                                    "Response validation failed: invalid JSON for status {}",
935                                    status_code
936                                );
937                            }
938                        }
939                    }
940
941                    // Capture final payload and run schema validation for trace
942                    let mut trace = ResponseGenerationTrace::new();
943                    trace.set_final_payload(final_response.clone());
944
945                    // Extract response schema and run validation diff
946                    if let Some((_status_code, response_ref)) = operation
947                        .responses
948                        .responses
949                        .iter()
950                        .filter_map(|(status, resp)| match status {
951                            openapiv3::StatusCode::Code(code) if *code == selected_status => {
952                                resp.as_item().map(|r| ((*code), r))
953                            }
954                            openapiv3::StatusCode::Range(range)
955                                if *range >= 200 && *range < 300 =>
956                            {
957                                resp.as_item().map(|r| (200, r))
958                            }
959                            _ => None,
960                        })
961                        .next()
962                        .or_else(|| {
963                            // Fallback to first 2xx response
964                            operation
965                                .responses
966                                .responses
967                                .iter()
968                                .filter_map(|(status, resp)| match status {
969                                    openapiv3::StatusCode::Code(code)
970                                        if *code >= 200 && *code < 300 =>
971                                    {
972                                        resp.as_item().map(|r| ((*code), r))
973                                    }
974                                    _ => None,
975                                })
976                                .next()
977                        })
978                    {
979                        // response_ref is already a Response, not a ReferenceOr
980                        let response_item = response_ref;
981                        // Extract schema from application/json content
982                        if let Some(content) = response_item.content.get("application/json") {
983                            if let Some(schema_ref) = &content.schema {
984                                // Convert OpenAPI schema to JSON Schema Value
985                                if let Some(schema) = schema_ref.as_item() {
986                                    if let Ok(schema_json) = serde_json::to_value(schema) {
987                                        // Run validation diff
988                                        let validation_errors =
989                                            validation_diff(&schema_json, &final_response);
990                                        trace.set_schema_validation_diff(validation_errors);
991                                    }
992                                }
993                            }
994                        }
995                    }
996
997                    // Store trace in response extensions for later retrieval by logging middleware
998                    let mut response = Json(final_response).into_response();
999                    response.extensions_mut().insert(trace);
1000                    *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
1001                        .unwrap_or(axum::http::StatusCode::OK);
1002                    return response;
1003                }
1004
1005                // (j) Return response (non-full-validation path)
1006                let mut response = Json(final_response).into_response();
1007                *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
1008                    .unwrap_or(axum::http::StatusCode::OK);
1009                response
1010            };
1011
1012            router = Self::route_for_method(router, axum_path, &route.method, handler);
1013        }
1014
1015        // Add OpenAPI documentation endpoint if configured
1016        if ctx.add_spec_endpoint {
1017            let spec_json = serde_json::to_value(&self.spec.spec).unwrap_or(Value::Null);
1018            router = router.route("/openapi.json", get(move || async move { Json(spec_json) }));
1019        }
1020
1021        // Issue #79 — raise the body-size cap above axum's 2 MiB default so
1022        // large chunked uploads don't get truncated mid-extraction. See
1023        // `openapi_body_limit_bytes` for the rationale.
1024        router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
1025    }
1026
1027    /// Build an Axum router from the OpenAPI spec with latency injection support
1028    pub fn build_router_with_latency(self, latency_injector: LatencyInjector) -> Router {
1029        self.build_router_with_injectors(latency_injector, None)
1030    }
1031
1032    /// Build an Axum router from the OpenAPI spec with both latency and failure injection support
1033    pub fn build_router_with_injectors(
1034        self,
1035        latency_injector: LatencyInjector,
1036        failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1037    ) -> Router {
1038        self.build_router_with_injectors_and_overrides(
1039            latency_injector,
1040            failure_injector,
1041            None,
1042            false,
1043        )
1044    }
1045
1046    /// Build an Axum router from the OpenAPI spec with latency, failure
1047    /// injection, and a response-rewriter hook (typically wrapping
1048    /// core's `Overrides` + `templating::expand_tokens`).
1049    pub fn build_router_with_injectors_and_overrides(
1050        self,
1051        latency_injector: LatencyInjector,
1052        failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1053        response_rewriter: Option<Arc<dyn ResponseRewriter>>,
1054        overrides_enabled: bool,
1055    ) -> Router {
1056        let ctx = RouterContext {
1057            custom_fixture_loader: self.custom_fixture_loader.clone(),
1058            latency_injector: Some(latency_injector),
1059            failure_injector,
1060            response_rewriter,
1061            overrides_enabled,
1062            enable_full_validation: true,
1063            enable_template_expand: true,
1064            add_spec_endpoint: true,
1065            ..Default::default()
1066        };
1067        self.build_router_with_context(ctx)
1068    }
1069
1070    /// Get route by path and method
1071    pub fn get_route(&self, path: &str, method: &str) -> Option<&OpenApiRoute> {
1072        self.routes.iter().find(|route| route.path == path && route.method == method)
1073    }
1074
1075    /// Get all routes for a specific path
1076    pub fn get_routes_for_path(&self, path: &str) -> Vec<&OpenApiRoute> {
1077        self.routes.iter().filter(|route| route.path == path).collect()
1078    }
1079
1080    /// Validate request against OpenAPI spec (legacy body-only)
1081    pub fn validate_request(&self, path: &str, method: &str, body: Option<&Value>) -> Result<()> {
1082        self.validate_request_with(path, method, &Map::new(), &Map::new(), body)
1083    }
1084
1085    /// Round 28 — Srikanth's content-type-mismatch finding on 0.3.171:
1086    /// the bench-side probe was correctly sending `Content-Type:
1087    /// application/xml` against a JSON-only endpoint, but mockforge's
1088    /// server-side conformance validator was accepting it because the
1089    /// existing path checked the BODY against the JSON schema directly
1090    /// (ignoring the actual Content-Type header). This method gives
1091    /// the route handler a way to flag Content-Type mismatches before
1092    /// the body validation runs.
1093    ///
1094    /// Returns `Err(message)` when the operation's request body
1095    /// declares one or more `content` keys AND the actual
1096    /// Content-Type doesn't match any of them; `Ok(())` otherwise
1097    /// (no requestBody declared, no Content-Type sent, or a match
1098    /// found). The comparison is type/subtype only (parameters like
1099    /// `; charset=...` and `; boundary=...` are stripped).
1100    pub fn check_request_content_type(
1101        &self,
1102        path: &str,
1103        method: &str,
1104        actual_content_type: Option<&str>,
1105    ) -> std::result::Result<(), String> {
1106        let Some(route) = self.get_route(path, method) else {
1107            return Ok(());
1108        };
1109        let Some(rb_ref) = &route.operation.request_body else {
1110            return Ok(());
1111        };
1112        let request_body = match rb_ref {
1113            openapiv3::ReferenceOr::Item(rb) => rb,
1114            openapiv3::ReferenceOr::Reference { reference } => {
1115                let resolved = self
1116                    .spec
1117                    .spec
1118                    .components
1119                    .as_ref()
1120                    .and_then(|components| {
1121                        components
1122                            .request_bodies
1123                            .get(reference.trim_start_matches("#/components/requestBodies/"))
1124                    })
1125                    .and_then(|rb_ref| rb_ref.as_item());
1126                let Some(rb) = resolved else { return Ok(()) };
1127                rb
1128            }
1129        };
1130        if request_body.content.is_empty() {
1131            return Ok(());
1132        }
1133        let actual = actual_content_type
1134            .and_then(|s| s.split(';').next())
1135            .map(|s| s.trim().to_ascii_lowercase());
1136        let Some(actual) = actual else {
1137            // No Content-Type sent at all. If a body is required and
1138            // content is declared, the body validator catches it via
1139            // a different path; we don't flag here so that
1140            // bodyless-but-required cases don't double-report.
1141            return Ok(());
1142        };
1143        let allowed: Vec<String> = request_body
1144            .content
1145            .keys()
1146            .map(|k| k.split(';').next().unwrap_or(k).trim().to_ascii_lowercase())
1147            .collect();
1148        if allowed.iter().any(|a| a == &actual) {
1149            return Ok(());
1150        }
1151        Err(format!(
1152            "Content-Type '{actual}' not allowed; spec declares: [{}]",
1153            allowed.join(", ")
1154        ))
1155    }
1156
1157    /// Validate request against OpenAPI spec with path/query params
1158    pub fn validate_request_with(
1159        &self,
1160        path: &str,
1161        method: &str,
1162        path_params: &Map<String, Value>,
1163        query_params: &Map<String, Value>,
1164        body: Option<&Value>,
1165    ) -> Result<()> {
1166        self.validate_request_with_all(
1167            path,
1168            method,
1169            path_params,
1170            query_params,
1171            &Map::new(),
1172            &Map::new(),
1173            body,
1174        )
1175    }
1176
1177    /// Issue #79 round 13 — run the standard request-validation bookend
1178    /// (validate → build error payload → record to the conformance ring
1179    /// buffer) and return `Ok(())` if validation passed, or
1180    /// `Err((status_code, payload))` if it failed. Centralises the logic
1181    /// that previously lived inline at `build_router_with_context`
1182    /// (line ~686) so the MockAI and AI handlers can share it instead
1183    /// of silently bypassing validation.
1184    ///
1185    /// Callers should short-circuit with the returned status + payload
1186    /// on `Err`; the violation has already been recorded to
1187    /// `mockforge_foundation::conformance_violations` by the time this
1188    /// function returns.
1189    #[allow(clippy::too_many_arguments)]
1190    pub fn run_validation_with_recording(
1191        &self,
1192        path_template: &str,
1193        method: &str,
1194        path_params: &Map<String, Value>,
1195        query_params: &Map<String, Value>,
1196        header_map: &Map<String, Value>,
1197        cookie_map: &Map<String, Value>,
1198        body: Option<&Value>,
1199    ) -> std::result::Result<(), (u16, Value)> {
1200        let e = match self.validate_request_with_all(
1201            path_template,
1202            method,
1203            path_params,
1204            query_params,
1205            header_map,
1206            cookie_map,
1207            body,
1208        ) {
1209            Ok(()) => {
1210                // Round 17.1 — track conformant requests alongside
1211                // violations so the TUI can show the real pass/fail
1212                // ratio (Srikanth's (f) follow-up).
1213                mockforge_foundation::conformance_violations::record_ok();
1214                return Ok(());
1215            }
1216            Err(e) => e,
1217        };
1218
1219        let status_code = self.options.validation_status.unwrap_or_else(|| {
1220            std::env::var("MOCKFORGE_VALIDATION_STATUS")
1221                .ok()
1222                .and_then(|s| s.parse::<u16>().ok())
1223                .unwrap_or(400)
1224        });
1225
1226        let payload = if status_code == 422 {
1227            generate_enhanced_422_response(
1228                self,
1229                path_template,
1230                method,
1231                body,
1232                path_params,
1233                query_params,
1234                header_map,
1235                cookie_map,
1236            )
1237        } else {
1238            let msg = format!("{}", e);
1239            let detail_val = serde_json::from_str::<Value>(&msg).unwrap_or(serde_json::json!(msg));
1240            json!({
1241                "error": "request validation failed",
1242                "detail": detail_val,
1243                "method": method,
1244                "path": path_template,
1245                "timestamp": Utc::now().to_rfc3339(),
1246            })
1247        };
1248
1249        record_validation_error(&payload);
1250
1251        let reason = payload
1252            .get("detail")
1253            .and_then(|d| {
1254                if d.is_string() {
1255                    d.as_str().map(|s| s.to_string())
1256                } else {
1257                    serde_json::to_string(d).ok()
1258                }
1259            })
1260            .unwrap_or_else(|| {
1261                payload
1262                    .get("error")
1263                    .and_then(|v| v.as_str())
1264                    .unwrap_or("request validation failed")
1265                    .to_string()
1266            });
1267        let category = classify_validation_reason(&reason);
1268        // Issue #79 round 15 — Srikanth asked for server-side logs of
1269        // *why* a request was a violation. Emit one line per violation
1270        // under a dedicated target so it can be enabled precisely
1271        // (`RUST_LOG=mockforge::conformance=debug`) without turning on
1272        // firehose debug logging. DEBUG (not WARN) so it doesn't spam
1273        // the default log under load — the aggregate is in the
1274        // Conformance tab / API; this is the opt-in detail channel.
1275        tracing::debug!(
1276            target: "mockforge::conformance",
1277            method = %method,
1278            path = %path_template,
1279            status = status_code,
1280            category = %category,
1281            reason = %reason,
1282            "request conformance violation"
1283        );
1284        let (client_mockforge_version, client_sent_at) =
1285            mockforge_foundation::conformance_violations::read_client_stamps(|name| {
1286                header_map
1287                    .iter()
1288                    .find(|(k, _)| k.eq_ignore_ascii_case(name))
1289                    .and_then(|(_, v)| v.as_str().map(|s| s.to_string()))
1290            });
1291        mockforge_foundation::conformance_violations::record(
1292            mockforge_foundation::conformance_violations::ServerConformanceViolation {
1293                timestamp: Utc::now(),
1294                method: method.to_string(),
1295                path: path_template.to_string(),
1296                client_ip: "unknown".to_string(),
1297                status: status_code,
1298                reason,
1299                category,
1300                occurrences: 1,
1301                client_mockforge_version,
1302                client_sent_at,
1303            },
1304        );
1305
1306        // Issue #79 round 14 — shadow mode: the violation is recorded
1307        // above (so the Conformance tab still shows it, with its real
1308        // 400/422 classification), but we return Ok so the handler
1309        // proceeds to synthesise a normal 2xx response. Lets a proxy
1310        // replay run flow through non-blocking while capturing every
1311        // violation.
1312        if mockforge_foundation::unknown_paths::shadow_mode_enabled() {
1313            return Ok(());
1314        }
1315
1316        Err((status_code, payload))
1317    }
1318
1319    /// Validate request against OpenAPI spec with path/query/header/cookie params
1320    #[allow(clippy::too_many_arguments)]
1321    pub fn validate_request_with_all(
1322        &self,
1323        path: &str,
1324        method: &str,
1325        path_params: &Map<String, Value>,
1326        query_params: &Map<String, Value>,
1327        header_params: &Map<String, Value>,
1328        cookie_params: &Map<String, Value>,
1329        body: Option<&Value>,
1330    ) -> Result<()> {
1331        // Skip validation for any configured admin prefixes
1332        for pref in &self.options.admin_skip_prefixes {
1333            if !pref.is_empty() && path.starts_with(pref) {
1334                return Ok(());
1335            }
1336        }
1337        // Runtime env overrides
1338        let env_mode = std::env::var("MOCKFORGE_REQUEST_VALIDATION").ok().map(|v| {
1339            match v.to_ascii_lowercase().as_str() {
1340                "off" | "disable" | "disabled" => ValidationMode::Disabled,
1341                "warn" | "warning" => ValidationMode::Warn,
1342                _ => ValidationMode::Enforce,
1343            }
1344        });
1345        let aggregate = std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
1346            .ok()
1347            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1348            .unwrap_or(self.options.aggregate_errors);
1349        // Per-route runtime overrides via JSON env var
1350        let env_overrides: Option<Map<String, Value>> =
1351            std::env::var("MOCKFORGE_VALIDATION_OVERRIDES_JSON")
1352                .ok()
1353                .and_then(|s| serde_json::from_str::<Value>(&s).ok())
1354                .and_then(|v| v.as_object().cloned());
1355        // Response validation is handled in HTTP layer now
1356        let mut effective_mode = env_mode.unwrap_or(self.options.request_mode.clone());
1357        // Apply runtime overrides first if present
1358        if let Some(map) = &env_overrides {
1359            if let Some(v) = map.get(&format!("{} {}", method, path)) {
1360                if let Some(m) = v.as_str() {
1361                    effective_mode = match m {
1362                        "off" => ValidationMode::Disabled,
1363                        "warn" => ValidationMode::Warn,
1364                        _ => ValidationMode::Enforce,
1365                    };
1366                }
1367            }
1368        }
1369        // Then static options overrides
1370        if let Some(override_mode) = self.options.overrides.get(&format!("{} {}", method, path)) {
1371            effective_mode = override_mode.clone();
1372        }
1373        if matches!(effective_mode, ValidationMode::Disabled) {
1374            return Ok(());
1375        }
1376        if let Some(route) = self.get_route(path, method) {
1377            if matches!(effective_mode, ValidationMode::Disabled) {
1378                return Ok(());
1379            }
1380            let mut errors: Vec<String> = Vec::new();
1381            let mut details: Vec<Value> = Vec::new();
1382            // Validate request body if required
1383            if let Some(schema) = &route.operation.request_body {
1384                if let Some(value) = body {
1385                    // First resolve the request body reference if it's a reference
1386                    let request_body = match schema {
1387                        openapiv3::ReferenceOr::Item(rb) => Some(rb),
1388                        openapiv3::ReferenceOr::Reference { reference } => {
1389                            // Try to resolve request body reference through spec
1390                            self.spec
1391                                .spec
1392                                .components
1393                                .as_ref()
1394                                .and_then(|components| {
1395                                    components.request_bodies.get(
1396                                        reference.trim_start_matches("#/components/requestBodies/"),
1397                                    )
1398                                })
1399                                .and_then(|rb_ref| rb_ref.as_item())
1400                        }
1401                    };
1402
1403                    if let Some(rb) = request_body {
1404                        if let Some(content) = rb.content.get("application/json") {
1405                            if let Some(schema_ref) = &content.schema {
1406                                // Issue #79 round 19 — every body validator on
1407                                // this path used `OpenApiSchema::new(...).validate()`
1408                                // which builds a naked jsonschema validator with
1409                                // no `components` context. Nested `$ref` strings
1410                                // to `#/components/schemas/X` (especially
1411                                // dotted vCenter names) then fail with
1412                                // "Pointer does not exist". Round 18.3 fixed
1413                                // the bench-side + the `validate_request_body`
1414                                // sibling; this is the live-server route
1415                                // handler, the third path. Switch to
1416                                // `schema_ref_resolver::build_validator` which
1417                                // inlines the spec's components.
1418                                let root_schema = match schema_ref {
1419                                    openapiv3::ReferenceOr::Item(s) => Some((*s).clone()),
1420                                    openapiv3::ReferenceOr::Reference { reference } => {
1421                                        self.spec.get_schema(reference).map(|s| s.schema.clone())
1422                                    }
1423                                };
1424                                if let Some(root_schema) = root_schema {
1425                                    let result = crate::schema_ref_resolver::build_validator(
1426                                        &root_schema,
1427                                        &self.spec.spec,
1428                                    )
1429                                    .and_then(|validator| {
1430                                        let errs: Vec<String> = validator
1431                                            .iter_errors(value)
1432                                            .map(|e| e.to_string())
1433                                            .collect();
1434                                        if errs.is_empty() {
1435                                            Ok(())
1436                                        } else {
1437                                            Err(errs.join("; "))
1438                                        }
1439                                    });
1440                                    if let Err(error_msg) = result {
1441                                        errors
1442                                            .push(format!("body validation failed: {}", error_msg));
1443                                        if aggregate {
1444                                            details.push(serde_json::json!({"path":"body","code":"schema_validation","message":error_msg}));
1445                                        }
1446                                    }
1447                                } else if let openapiv3::ReferenceOr::Reference { reference } =
1448                                    schema_ref
1449                                {
1450                                    // Schema reference couldn't be resolved
1451                                    errors.push(format!("body validation failed: could not resolve schema reference {}", reference));
1452                                    if aggregate {
1453                                        details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve schema reference"}));
1454                                    }
1455                                }
1456                            }
1457                        }
1458                    } else {
1459                        // Request body reference couldn't be resolved or no application/json content
1460                        errors.push("body validation failed: could not resolve request body or no application/json content".to_string());
1461                        if aggregate {
1462                            details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve request body reference"}));
1463                        }
1464                    }
1465                } else {
1466                    errors.push("body: Request body is required but not provided".to_string());
1467                    details.push(serde_json::json!({"path":"body","code":"required","message":"Request body is required"}));
1468                }
1469            } else if body.is_some() {
1470                // No body expected but provided — not an error by default, but log it
1471                tracing::debug!("Body provided for operation without requestBody; accepting");
1472            }
1473
1474            // Validate path/query parameters
1475            for p_ref in &route.operation.parameters {
1476                if let Some(p) = p_ref.as_item() {
1477                    match p {
1478                        openapiv3::Parameter::Path { parameter_data, .. } => {
1479                            validate_parameter(
1480                                parameter_data,
1481                                path_params,
1482                                "path",
1483                                aggregate,
1484                                &mut errors,
1485                                &mut details,
1486                            );
1487                        }
1488                        openapiv3::Parameter::Query {
1489                            parameter_data,
1490                            style,
1491                            ..
1492                        } => {
1493                            // For deepObject style, reconstruct nested value from keys like name[prop]
1494                            // e.g., filter[name]=John&filter[age]=30 -> {"name":"John","age":"30"}
1495                            let deep_value = if matches!(style, openapiv3::QueryStyle::DeepObject) {
1496                                let prefix_bracket = format!("{}[", parameter_data.name);
1497                                let mut obj = Map::new();
1498                                for (key, val) in query_params.iter() {
1499                                    if let Some(rest) = key.strip_prefix(&prefix_bracket) {
1500                                        if let Some(prop) = rest.strip_suffix(']') {
1501                                            obj.insert(prop.to_string(), val.clone());
1502                                        }
1503                                    }
1504                                }
1505                                if obj.is_empty() {
1506                                    None
1507                                } else {
1508                                    Some(Value::Object(obj))
1509                                }
1510                            } else {
1511                                None
1512                            };
1513                            let style_str = match style {
1514                                openapiv3::QueryStyle::Form => Some("form"),
1515                                openapiv3::QueryStyle::SpaceDelimited => Some("spaceDelimited"),
1516                                openapiv3::QueryStyle::PipeDelimited => Some("pipeDelimited"),
1517                                openapiv3::QueryStyle::DeepObject => Some("deepObject"),
1518                            };
1519                            validate_parameter_with_deep_object(
1520                                parameter_data,
1521                                query_params,
1522                                "query",
1523                                deep_value,
1524                                style_str,
1525                                aggregate,
1526                                &mut errors,
1527                                &mut details,
1528                            );
1529                        }
1530                        openapiv3::Parameter::Header { parameter_data, .. } => {
1531                            validate_parameter(
1532                                parameter_data,
1533                                header_params,
1534                                "header",
1535                                aggregate,
1536                                &mut errors,
1537                                &mut details,
1538                            );
1539                        }
1540                        openapiv3::Parameter::Cookie { parameter_data, .. } => {
1541                            validate_parameter(
1542                                parameter_data,
1543                                cookie_params,
1544                                "cookie",
1545                                aggregate,
1546                                &mut errors,
1547                                &mut details,
1548                            );
1549                        }
1550                    }
1551                }
1552            }
1553            if errors.is_empty() {
1554                return Ok(());
1555            }
1556            match effective_mode {
1557                ValidationMode::Disabled => Ok(()),
1558                ValidationMode::Warn => {
1559                    tracing::warn!("Request validation warnings: {:?}", errors);
1560                    Ok(())
1561                }
1562                ValidationMode::Enforce => Err(Error::validation(
1563                    serde_json::json!({"errors": errors, "details": details}).to_string(),
1564                )),
1565            }
1566        } else {
1567            Err(Error::internal(format!("Route {} {} not found in OpenAPI spec", method, path)))
1568        }
1569    }
1570
1571    // Legacy helper removed (mock + status selection happens in handler via route.mock_response_with_status)
1572
1573    /// Get all paths defined in the spec
1574    pub fn paths(&self) -> Vec<String> {
1575        let mut paths: Vec<String> = self.routes.iter().map(|route| route.path.clone()).collect();
1576        paths.sort();
1577        paths.dedup();
1578        paths
1579    }
1580
1581    /// Get all HTTP methods supported
1582    pub fn methods(&self) -> Vec<String> {
1583        let mut methods: Vec<String> =
1584            self.routes.iter().map(|route| route.method.clone()).collect();
1585        methods.sort();
1586        methods.dedup();
1587        methods
1588    }
1589
1590    /// Get operation details for a route
1591    pub fn get_operation(&self, path: &str, method: &str) -> Option<OpenApiOperation> {
1592        self.get_route(path, method).map(|route| {
1593            OpenApiOperation::from_operation(
1594                &route.method,
1595                route.path.clone(),
1596                &route.operation,
1597                &self.spec,
1598            )
1599        })
1600    }
1601
1602    /// Extract path parameters from a request path by matching against known routes
1603    pub fn extract_path_parameters(&self, path: &str, method: &str) -> HashMap<String, String> {
1604        // Among all routes that match, prefer the most *specific* — the one with
1605        // the most static (non-parameter) segments — so an exact literal route
1606        // wins over a same-arity `{param}` route regardless of declaration order
1607        // (e.g. `/users/me` matches `/users/me`, not `/users/{id}`). Returning
1608        // the first match meant spec order silently decided this (#757).
1609        let mut best: Option<(usize, HashMap<String, String>)> = None;
1610        for route in &self.routes {
1611            if route.method != method {
1612                continue;
1613            }
1614
1615            if let Some(params) = self.match_path_to_route(path, &route.path) {
1616                let static_segments = route
1617                    .path
1618                    .trim_start_matches('/')
1619                    .split('/')
1620                    .filter(|s| !(s.starts_with('{') && s.ends_with('}')))
1621                    .count();
1622                let is_more_specific = match &best {
1623                    None => true,
1624                    Some((score, _)) => static_segments > *score,
1625                };
1626                if is_more_specific {
1627                    best = Some((static_segments, params));
1628                }
1629            }
1630        }
1631        best.map(|(_, params)| params).unwrap_or_default()
1632    }
1633
1634    /// Match a request path against a route pattern and extract parameters
1635    fn match_path_to_route(
1636        &self,
1637        request_path: &str,
1638        route_pattern: &str,
1639    ) -> Option<HashMap<String, String>> {
1640        let mut params = HashMap::new();
1641
1642        // Split both paths into segments
1643        let request_segments: Vec<&str> = request_path.trim_start_matches('/').split('/').collect();
1644        let pattern_segments: Vec<&str> =
1645            route_pattern.trim_start_matches('/').split('/').collect();
1646
1647        if request_segments.len() != pattern_segments.len() {
1648            return None;
1649        }
1650
1651        for (req_seg, pat_seg) in request_segments.iter().zip(pattern_segments.iter()) {
1652            if pat_seg.starts_with('{') && pat_seg.ends_with('}') {
1653                // This is a parameter. Reject an *empty* captured segment (e.g.
1654                // a trailing slash: `/users/` would otherwise match
1655                // `/users/{id}` with id=""), which downstream treats as a real
1656                // value (#757).
1657                if req_seg.is_empty() {
1658                    return None;
1659                }
1660                let param_name = &pat_seg[1..pat_seg.len() - 1];
1661                params.insert(param_name.to_string(), req_seg.to_string());
1662            } else if req_seg != pat_seg {
1663                // Static segment doesn't match
1664                return None;
1665            }
1666        }
1667
1668        Some(params)
1669    }
1670
1671    /// Convert OpenAPI path to Axum-compatible path
1672    /// This is a utility function for converting path parameters from {param} to :param format
1673    pub fn convert_path_to_axum(openapi_path: &str) -> String {
1674        // Axum v0.7+ uses {param} format, same as OpenAPI
1675        openapi_path.to_string()
1676    }
1677
1678    /// Build router with AI generator support
1679    pub fn build_router_with_ai(
1680        &self,
1681        ai_generator: Option<Arc<dyn AiGenerator + Send + Sync>>,
1682    ) -> Router {
1683        let mut router = Router::new();
1684        let deduped = self.deduplicated_routes();
1685        tracing::debug!("Building router with AI support from {} routes", self.routes.len());
1686
1687        // Issue #79 round 14 hotfix — one shared validator (Arc) instead
1688        // of a per-route deep clone. See `build_router_with_context` for
1689        // the O(N²)/OOM rationale.
1690        let validator = Arc::new(self.clone_for_validation());
1691        for (axum_path, route) in &deduped {
1692            tracing::debug!("Adding AI-enabled route: {} {}", route.method, route.path);
1693
1694            let route_clone = (*route).clone();
1695            let ai_generator_clone = ai_generator.clone();
1696            // Issue #79 round 13 — same validation bypass as
1697            // `build_router_with_mockai`. Run validation before AI
1698            // response generation; the validator is shared via Arc.
1699            let validator_clone = validator.clone();
1700
1701            // Create async handler that extracts request data and builds context
1702            let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
1703                                axum::extract::Query(query_params): axum::extract::Query<
1704                HashMap<String, String>,
1705            >,
1706                                headers: HeaderMap,
1707                                body: Option<Json<Value>>| {
1708                let route = route_clone.clone();
1709                let ai_generator = ai_generator_clone.clone();
1710                let validator = validator_clone.clone();
1711
1712                async move {
1713                    // (a-pre) Run request validation against the spec
1714                    // before AI response synthesis. On failure this
1715                    // also records to the foundation conformance ring
1716                    // buffer surfaced by the TUI Conformance tab.
1717                    let mut path_map = Map::new();
1718                    for (k, v) in &path_params {
1719                        path_map.insert(k.clone(), Value::String(v.clone()));
1720                    }
1721                    let mut query_map = Map::new();
1722                    for (k, v) in &query_params {
1723                        query_map.insert(k.clone(), Value::String(v.clone()));
1724                    }
1725                    let mut header_map = Map::new();
1726                    for (k, v) in headers.iter() {
1727                        if let Ok(s) = v.to_str() {
1728                            header_map.insert(k.to_string(), Value::String(s.to_string()));
1729                        }
1730                    }
1731                    let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
1732                    if let Err((status_code, payload)) = validator.run_validation_with_recording(
1733                        &route.path,
1734                        &route.method,
1735                        &path_map,
1736                        &query_map,
1737                        &header_map,
1738                        &Map::new(),
1739                        body_val,
1740                    ) {
1741                        let status = axum::http::StatusCode::from_u16(status_code)
1742                            .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
1743                        return (status, Json(payload));
1744                    }
1745
1746                    tracing::debug!(
1747                        "Handling AI request for route: {} {}",
1748                        route.method,
1749                        route.path
1750                    );
1751
1752                    // Build request context
1753                    let mut context = RequestContext::new(route.method.clone(), route.path.clone());
1754
1755                    // Extract headers
1756                    context.headers = headers
1757                        .iter()
1758                        .map(|(k, v)| {
1759                            (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string()))
1760                        })
1761                        .collect();
1762
1763                    // Extract body if present
1764                    context.body = body.map(|Json(b)| b);
1765
1766                    // Generate AI response if AI generator is available and route has AI config
1767                    let (status, response) = if let (Some(generator), Some(_ai_config)) =
1768                        (ai_generator, &route.ai_config)
1769                    {
1770                        route
1771                            .mock_response_with_status_async(&context, Some(generator.as_ref()))
1772                            .await
1773                    } else {
1774                        // No AI support, use static response
1775                        route.mock_response_with_status()
1776                    };
1777
1778                    (
1779                        axum::http::StatusCode::from_u16(status)
1780                            .unwrap_or(axum::http::StatusCode::OK),
1781                        Json(response),
1782                    )
1783                }
1784            };
1785
1786            router = Self::route_for_method(router, axum_path, &route.method, handler);
1787        }
1788
1789        // Issue #79 — same body-limit raise as `build_router_with_context`;
1790        // the AI handler also uses `Option<Json<Value>>` so axum's 2 MiB
1791        // default truncates large bodies and the handler responds early.
1792        router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
1793    }
1794
1795    /// Build router with MockAI (Behavioral Mock Intelligence) support
1796    ///
1797    /// This method integrates MockAI for intelligent, context-aware response generation,
1798    /// mutation detection, validation error generation, and pagination intelligence.
1799    ///
1800    /// # Arguments
1801    /// * `mockai` - Optional MockAI instance for intelligent behavior
1802    ///
1803    /// # Returns
1804    /// Axum router with MockAI-powered response generation
1805    pub fn build_router_with_mockai(
1806        &self,
1807        mockai: Option<
1808            Arc<
1809                tokio::sync::RwLock<
1810                    dyn mockforge_foundation::intelligent_behavior::MockAiBehavior + Send + Sync,
1811                >,
1812            >,
1813        >,
1814    ) -> Router {
1815        use mockforge_foundation::intelligent_behavior::Request as MockAIRequest;
1816
1817        let mut router = Router::new();
1818        let deduped = self.deduplicated_routes();
1819        tracing::debug!("Building router with MockAI support from {} routes", self.routes.len());
1820
1821        let custom_loader = self.custom_fixture_loader.clone();
1822        // Issue #79 round 14 hotfix — one shared validator (Arc) instead
1823        // of a per-route deep clone. See `build_router_with_context` for
1824        // the O(N²)/OOM rationale.
1825        let validator = Arc::new(self.clone_for_validation());
1826        for (axum_path, route) in &deduped {
1827            tracing::debug!("Adding MockAI-enabled route: {} {}", route.method, route.path);
1828
1829            let route_clone = (*route).clone();
1830            let mockai_clone = mockai.clone();
1831            let custom_loader_clone = custom_loader.clone();
1832            // Issue #79 round 13 — the MockAI handler was bypassing
1833            // request validation entirely, so spec violations never
1834            // populated the conformance ring buffer. Run
1835            // `run_validation_with_recording` before fixture/MockAI/
1836            // mock-response synthesis; the validator is shared via Arc.
1837            let validator_clone = validator.clone();
1838
1839            // Create async handler that processes requests through MockAI
1840            // Query params are extracted via Query extractor with HashMap
1841            // Round 32 — Srikanth on 0.3.176: bench's content-type-swap
1842            // probes return 415 client-side but the server-side
1843            // conformance buffer only ever shows 400s. Root cause: this
1844            // handler used to take `body: Option<Json<Value>>`, and
1845            // axum's `Json` extractor 415s a request whose
1846            // `Content-Type` isn't `application/json` BEFORE the
1847            // handler runs — so the buffer never had a chance to
1848            // record the violation, and client/server logs got out of
1849            // sync on the same URI. Extract raw bytes instead; we now
1850            // do the content-type check ourselves (recording to the
1851            // buffer with category `content-types` and the configured
1852            // validation status, default 415) and parse the body as
1853            // JSON manually for the validator + MockAI paths below.
1854            let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
1855                                query: axum::extract::Query<HashMap<String, String>>,
1856                                headers: HeaderMap,
1857                                body_bytes: axum::body::Bytes| {
1858                let route = route_clone.clone();
1859                let mockai = mockai_clone.clone();
1860                let validator = validator_clone.clone();
1861
1862                async move {
1863                    let mut path_map = Map::new();
1864                    for (k, v) in &path_params {
1865                        path_map.insert(k.clone(), Value::String(v.clone()));
1866                    }
1867                    let mut query_map = Map::new();
1868                    for (k, v) in &query.0 {
1869                        query_map.insert(k.clone(), Value::String(v.clone()));
1870                    }
1871                    let mut header_map = Map::new();
1872                    for (k, v) in headers.iter() {
1873                        if let Ok(s) = v.to_str() {
1874                            header_map.insert(k.to_string(), Value::String(s.to_string()));
1875                        }
1876                    }
1877
1878                    // (a-pre1) Round 32 — content-type mismatch check
1879                    // BEFORE body parsing. Mirrors the existing check
1880                    // in `build_router_with_context`; both must record
1881                    // because at any given moment exactly one of the
1882                    // two router builders is wired up.
1883                    if !body_bytes.is_empty() {
1884                        let actual_ct = headers
1885                            .get(axum::http::header::CONTENT_TYPE)
1886                            .and_then(|v| v.to_str().ok());
1887                        if let Err(ct_err) = validator.check_request_content_type(
1888                            &route.path,
1889                            &route.method,
1890                            actual_ct,
1891                        ) {
1892                            let status_code =
1893                                validator.options.validation_status.unwrap_or_else(|| {
1894                                    std::env::var("MOCKFORGE_VALIDATION_STATUS")
1895                                        .ok()
1896                                        .and_then(|s| s.parse::<u16>().ok())
1897                                        .unwrap_or(415)
1898                                });
1899                            let (client_mockforge_version, client_sent_at) =
1900                                mockforge_foundation::conformance_violations::read_client_stamps(
1901                                    |name| {
1902                                        headers
1903                                            .get(name)
1904                                            .and_then(|v| v.to_str().ok())
1905                                            .map(|s| s.to_string())
1906                                    },
1907                                );
1908                            mockforge_foundation::conformance_violations::record(
1909                                mockforge_foundation::conformance_violations::ServerConformanceViolation {
1910                                    timestamp: Utc::now(),
1911                                    method: route.method.clone(),
1912                                    path: route.path.clone(),
1913                                    client_ip: "unknown".to_string(),
1914                                    status: status_code,
1915                                    reason: ct_err.clone(),
1916                                    category: "content-types".to_string(),
1917                                    occurrences: 1,
1918                                    client_mockforge_version,
1919                                    client_sent_at,
1920                                },
1921                            );
1922                            let status = axum::http::StatusCode::from_u16(status_code)
1923                                .unwrap_or(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
1924                            return (
1925                                status,
1926                                Json(serde_json::json!({
1927                                    "error": "content_type_not_allowed",
1928                                    "message": ct_err,
1929                                })),
1930                            );
1931                        }
1932                    }
1933
1934                    // (a-pre2) Parse body as JSON when present so the
1935                    // validator, fingerprint, and MockAI paths can all
1936                    // see it. We deliberately don't fail on JSON parse
1937                    // errors here — the body validator below produces
1938                    // the right shape of error message in that case.
1939                    let body: Option<Json<Value>> = if body_bytes.is_empty() {
1940                        None
1941                    } else {
1942                        serde_json::from_slice::<Value>(&body_bytes).ok().map(Json)
1943                    };
1944
1945                    // (a-pre3) Run request validation against the spec
1946                    // before any response synthesis. On failure this
1947                    // also records to the foundation conformance ring
1948                    // buffer surfaced by the TUI Conformance tab.
1949                    let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
1950                    if let Err((status_code, payload)) = validator.run_validation_with_recording(
1951                        &route.path,
1952                        &route.method,
1953                        &path_map,
1954                        &query_map,
1955                        &header_map,
1956                        &Map::new(),
1957                        body_val,
1958                    ) {
1959                        let status = axum::http::StatusCode::from_u16(status_code)
1960                            .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
1961                        return (status, Json(payload));
1962                    }
1963
1964                    tracing::info!(
1965                        "[FIXTURE DEBUG] Starting fixture check for {} {} (custom_loader available: {})",
1966                        route.method,
1967                        route.path,
1968                        custom_loader_clone.is_some()
1969                    );
1970
1971                    // Check for custom fixture first (highest priority, before MockAI)
1972                    if let Some(ref loader) = custom_loader_clone {
1973                        use crate::request_fingerprint::RequestFingerprint;
1974                        use axum::http::{Method, Uri};
1975
1976                        // Build query string from parsed query params
1977                        let query_string = if query.0.is_empty() {
1978                            String::new()
1979                        } else {
1980                            query
1981                                .0
1982                                .iter()
1983                                .map(|(k, v)| format!("{}={}", k, v))
1984                                .collect::<Vec<_>>()
1985                                .join("&")
1986                        };
1987
1988                        // Normalize the path to match fixture normalization
1989                        let normalized_request_path =
1990                            crate::custom_fixture::CustomFixtureLoader::normalize_path(&route.path);
1991
1992                        tracing::info!(
1993                            "[FIXTURE DEBUG] Path normalization: original='{}', normalized='{}'",
1994                            route.path,
1995                            normalized_request_path
1996                        );
1997
1998                        // Create URI for fingerprint
1999                        let uri_str = if query_string.is_empty() {
2000                            normalized_request_path.clone()
2001                        } else {
2002                            format!("{}?{}", normalized_request_path, query_string)
2003                        };
2004
2005                        tracing::info!(
2006                            "[FIXTURE DEBUG] URI construction: uri_str='{}', query_string='{}'",
2007                            uri_str,
2008                            query_string
2009                        );
2010
2011                        if let Ok(uri) = uri_str.parse::<Uri>() {
2012                            let http_method =
2013                                Method::from_bytes(route.method.as_bytes()).unwrap_or(Method::GET);
2014
2015                            // Convert body to bytes for fingerprint
2016                            let body_bytes =
2017                                body.as_ref().and_then(|Json(b)| serde_json::to_vec(b).ok());
2018                            let body_slice = body_bytes.as_deref();
2019
2020                            let fingerprint =
2021                                RequestFingerprint::new(http_method, &uri, &headers, body_slice);
2022
2023                            tracing::info!(
2024                                "[FIXTURE DEBUG] RequestFingerprint created: method='{}', path='{}', query='{}', body_hash={:?}",
2025                                fingerprint.method,
2026                                fingerprint.path,
2027                                fingerprint.query,
2028                                fingerprint.body_hash
2029                            );
2030
2031                            // Check what fixtures are available for this method
2032                            let available_fixtures = loader.has_fixture(&fingerprint);
2033                            tracing::info!(
2034                                "[FIXTURE DEBUG] Fixture check result: has_fixture={}",
2035                                available_fixtures
2036                            );
2037
2038                            if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
2039                                tracing::info!(
2040                                    "[FIXTURE DEBUG] ✅ FIXTURE MATCHED! Using custom fixture for {} {} (status: {}, path: '{}')",
2041                                    route.method,
2042                                    route.path,
2043                                    custom_fixture.status,
2044                                    custom_fixture.path
2045                                );
2046
2047                                // Apply delay if specified
2048                                if custom_fixture.delay_ms > 0 {
2049                                    tokio::time::sleep(tokio::time::Duration::from_millis(
2050                                        custom_fixture.delay_ms,
2051                                    ))
2052                                    .await;
2053                                }
2054
2055                                // Convert response to JSON string if needed
2056                                let response_body = if custom_fixture.response.is_string() {
2057                                    custom_fixture.response.as_str().unwrap().to_string()
2058                                } else {
2059                                    serde_json::to_string(&custom_fixture.response)
2060                                        .unwrap_or_else(|_| "{}".to_string())
2061                                };
2062
2063                                // Parse response body as JSON
2064                                let json_value: Value = serde_json::from_str(&response_body)
2065                                    .unwrap_or_else(|_| serde_json::json!({}));
2066
2067                                // Build response with status and JSON body
2068                                let status =
2069                                    axum::http::StatusCode::from_u16(custom_fixture.status)
2070                                        .unwrap_or(axum::http::StatusCode::OK);
2071
2072                                // Return as tuple (StatusCode, Json) to match handler signature
2073                                return (status, Json(json_value));
2074                            } else {
2075                                tracing::warn!(
2076                                    "[FIXTURE DEBUG] ❌ No fixture match found for {} {} (fingerprint.path='{}', normalized='{}')",
2077                                    route.method,
2078                                    route.path,
2079                                    fingerprint.path,
2080                                    normalized_request_path
2081                                );
2082                            }
2083                        } else {
2084                            tracing::warn!("[FIXTURE DEBUG] Failed to parse URI: '{}'", uri_str);
2085                        }
2086                    } else {
2087                        tracing::warn!(
2088                            "[FIXTURE DEBUG] Custom fixture loader not available for {} {}",
2089                            route.method,
2090                            route.path
2091                        );
2092                    }
2093
2094                    tracing::debug!(
2095                        "Handling MockAI request for route: {} {}",
2096                        route.method,
2097                        route.path
2098                    );
2099
2100                    // Query parameters are already parsed by Query extractor
2101                    let mockai_query = query.0;
2102
2103                    // If MockAI is enabled, use it to process the request
2104                    // CRITICAL FIX: Skip MockAI for GET, HEAD, and OPTIONS requests
2105                    // These are read-only operations and should use OpenAPI response generation
2106                    // MockAI's mutation analysis incorrectly treats GET requests as "Create" mutations
2107                    let method_upper = route.method.to_uppercase();
2108                    let should_use_mockai =
2109                        matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH" | "DELETE");
2110
2111                    if should_use_mockai {
2112                        if let Some(mockai_arc) = mockai {
2113                            let mockai_guard = mockai_arc.read().await;
2114
2115                            // Build MockAI request
2116                            let mut mockai_headers = HashMap::new();
2117                            for (k, v) in headers.iter() {
2118                                mockai_headers
2119                                    .insert(k.to_string(), v.to_str().unwrap_or("").to_string());
2120                            }
2121
2122                            let mockai_request = MockAIRequest {
2123                                method: route.method.clone(),
2124                                path: route.path.clone(),
2125                                body: body.as_ref().map(|Json(b)| b.clone()),
2126                                query_params: mockai_query,
2127                                headers: mockai_headers,
2128                            };
2129
2130                            // Process request through MockAI
2131                            match mockai_guard.process_request(&mockai_request).await {
2132                                Ok(mockai_response) => {
2133                                    // Check if MockAI returned an empty object (signals to use OpenAPI generation)
2134                                    let is_empty = mockai_response.body.is_object()
2135                                        && mockai_response
2136                                            .body
2137                                            .as_object()
2138                                            .map(|obj| obj.is_empty())
2139                                            .unwrap_or(false);
2140
2141                                    if is_empty {
2142                                        tracing::debug!(
2143                                            "MockAI returned empty object for {} {}, falling back to OpenAPI response generation",
2144                                            route.method,
2145                                            route.path
2146                                        );
2147                                        // Fall through to standard OpenAPI response generation
2148                                    } else {
2149                                        // Use the status code from the OpenAPI spec rather than
2150                                        // MockAI's hardcoded 200, so that e.g. POST returning 201
2151                                        // is honored correctly.
2152                                        let spec_status = route.find_first_available_status_code();
2153                                        tracing::debug!(
2154                                            "MockAI generated response for {} {}, using spec status: {} (MockAI suggested: {})",
2155                                            route.method,
2156                                            route.path,
2157                                            spec_status,
2158                                            mockai_response.status_code
2159                                        );
2160                                        return (
2161                                            axum::http::StatusCode::from_u16(spec_status)
2162                                                .unwrap_or(axum::http::StatusCode::OK),
2163                                            Json(mockai_response.body),
2164                                        );
2165                                    }
2166                                }
2167                                Err(e) => {
2168                                    tracing::warn!(
2169                                        "MockAI processing failed for {} {}: {}, falling back to standard response",
2170                                        route.method,
2171                                        route.path,
2172                                        e
2173                                    );
2174                                    // Fall through to standard response generation
2175                                }
2176                            }
2177                        }
2178                    } else {
2179                        tracing::debug!(
2180                            "Skipping MockAI for {} request {} - using OpenAPI response generation",
2181                            method_upper,
2182                            route.path
2183                        );
2184                    }
2185
2186                    // Check for status code override header
2187                    let status_override = headers
2188                        .get("X-Mockforge-Response-Status")
2189                        .and_then(|v| v.to_str().ok())
2190                        .and_then(|s| s.parse::<u16>().ok());
2191
2192                    // Check for scenario header
2193                    let scenario = headers
2194                        .get("X-Mockforge-Scenario")
2195                        .and_then(|v| v.to_str().ok())
2196                        .map(|s| s.to_string())
2197                        .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
2198
2199                    // Fallback to standard response generation
2200                    let (status, response) = route
2201                        .mock_response_with_status_and_scenario_and_override(
2202                            scenario.as_deref(),
2203                            status_override,
2204                        );
2205                    (
2206                        axum::http::StatusCode::from_u16(status)
2207                            .unwrap_or(axum::http::StatusCode::OK),
2208                        Json(response),
2209                    )
2210                }
2211            };
2212
2213            router = Self::route_for_method(router, axum_path, &route.method, handler);
2214        }
2215
2216        // Issue #79 — see `build_router_with_context`; same body-limit raise
2217        // for the MockAI router.
2218        router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
2219    }
2220}
2221
2222// Note: templating helpers are now in core::templating (shared across modules)
2223
2224/// Extract multipart form data from request body bytes
2225/// Returns (form_fields, file_paths) where file_paths maps field names to stored file paths
2226async fn extract_multipart_from_bytes(
2227    body: &axum::body::Bytes,
2228    headers: &HeaderMap,
2229) -> Result<(HashMap<String, Value>, HashMap<String, String>)> {
2230    // Get boundary from Content-Type header
2231    let boundary = headers
2232        .get(axum::http::header::CONTENT_TYPE)
2233        .and_then(|v| v.to_str().ok())
2234        .and_then(|ct| {
2235            ct.split(';').find_map(|part| {
2236                let part = part.trim();
2237                if part.starts_with("boundary=") {
2238                    Some(part.strip_prefix("boundary=").unwrap_or("").trim_matches('"'))
2239                } else {
2240                    None
2241                }
2242            })
2243        })
2244        .ok_or_else(|| Error::internal("Missing boundary in Content-Type header"))?;
2245
2246    let mut fields = HashMap::new();
2247    let mut files = HashMap::new();
2248
2249    // Parse multipart data using bytes directly (not string conversion)
2250    // Multipart format: --boundary\r\n...\r\n--boundary\r\n...\r\n--boundary--\r\n
2251    let boundary_prefix = format!("--{}", boundary).into_bytes();
2252    let boundary_line = format!("\r\n--{}\r\n", boundary).into_bytes();
2253    let end_boundary = format!("\r\n--{}--\r\n", boundary).into_bytes();
2254
2255    // Find all boundary positions
2256    let mut pos = 0;
2257    let mut parts = Vec::new();
2258
2259    // Skip initial boundary if present
2260    if body.starts_with(&boundary_prefix) {
2261        if let Some(first_crlf) = body.iter().position(|&b| b == b'\r') {
2262            pos = first_crlf + 2; // Skip --boundary\r\n
2263        }
2264    }
2265
2266    // Find all middle boundaries
2267    while let Some(boundary_pos) = body[pos..]
2268        .windows(boundary_line.len())
2269        .position(|window| window == boundary_line.as_slice())
2270    {
2271        let actual_pos = pos + boundary_pos;
2272        if actual_pos > pos {
2273            parts.push((pos, actual_pos));
2274        }
2275        pos = actual_pos + boundary_line.len();
2276    }
2277
2278    // Find final boundary
2279    if let Some(end_pos) = body[pos..]
2280        .windows(end_boundary.len())
2281        .position(|window| window == end_boundary.as_slice())
2282    {
2283        let actual_end = pos + end_pos;
2284        if actual_end > pos {
2285            parts.push((pos, actual_end));
2286        }
2287    } else if pos < body.len() {
2288        // No final boundary found, treat rest as last part
2289        parts.push((pos, body.len()));
2290    }
2291
2292    // Process each part
2293    for (start, end) in parts {
2294        let part_data = &body[start..end];
2295
2296        // Find header/body separator (CRLF CRLF)
2297        let separator = b"\r\n\r\n";
2298        if let Some(sep_pos) =
2299            part_data.windows(separator.len()).position(|window| window == separator)
2300        {
2301            let header_bytes = &part_data[..sep_pos];
2302            let body_start = sep_pos + separator.len();
2303            let body_data = &part_data[body_start..];
2304
2305            // Parse headers (assuming UTF-8)
2306            let header_str = String::from_utf8_lossy(header_bytes);
2307            let mut field_name = None;
2308            let mut filename = None;
2309
2310            for header_line in header_str.lines() {
2311                if header_line.starts_with("Content-Disposition:") {
2312                    // Extract field name
2313                    if let Some(name_start) = header_line.find("name=\"") {
2314                        let name_start = name_start + 6;
2315                        if let Some(name_end) = header_line[name_start..].find('"') {
2316                            field_name =
2317                                Some(header_line[name_start..name_start + name_end].to_string());
2318                        }
2319                    }
2320
2321                    // Extract filename if present
2322                    if let Some(file_start) = header_line.find("filename=\"") {
2323                        let file_start = file_start + 10;
2324                        if let Some(file_end) = header_line[file_start..].find('"') {
2325                            filename =
2326                                Some(header_line[file_start..file_start + file_end].to_string());
2327                        }
2328                    }
2329                }
2330            }
2331
2332            if let Some(name) = field_name {
2333                if let Some(file) = filename {
2334                    // This is a file upload - store to temp directory
2335                    let temp_dir = std::env::temp_dir().join("mockforge-uploads");
2336                    std::fs::create_dir_all(&temp_dir)
2337                        .map_err(|e| Error::io_with_context("temp directory", e.to_string()))?;
2338
2339                    let file_path = temp_dir.join(format!("{}_{}", uuid::Uuid::new_v4(), file));
2340                    std::fs::write(&file_path, body_data)
2341                        .map_err(|e| Error::io_with_context("file", e.to_string()))?;
2342
2343                    let file_path_str = file_path.to_string_lossy().to_string();
2344                    files.insert(name.clone(), file_path_str.clone());
2345                    fields.insert(name, Value::String(file_path_str));
2346                } else {
2347                    // This is a regular form field - try to parse as UTF-8 string
2348                    // Trim trailing CRLF
2349                    let body_str = body_data
2350                        .strip_suffix(b"\r\n")
2351                        .or_else(|| body_data.strip_suffix(b"\n"))
2352                        .unwrap_or(body_data);
2353
2354                    if let Ok(field_value) = String::from_utf8(body_str.to_vec()) {
2355                        fields.insert(name, Value::String(field_value.trim().to_string()));
2356                    } else {
2357                        // Non-UTF-8 field value - store as base64 encoded string
2358                        use base64::{engine::general_purpose, Engine as _};
2359                        fields.insert(
2360                            name,
2361                            Value::String(general_purpose::STANDARD.encode(body_str)),
2362                        );
2363                    }
2364                }
2365            }
2366        }
2367    }
2368
2369    Ok((fields, files))
2370}
2371
2372static LAST_ERRORS: Lazy<Mutex<VecDeque<Value>>> =
2373    Lazy::new(|| Mutex::new(VecDeque::with_capacity(20)));
2374
2375/// Classify a validation error reason string into one of the
2376/// `ConformanceFeature` categories used by the bench-side spec
2377/// validator. Best-effort string match — keeps the server-side
2378/// conformance ring buffer's `category` field meaningful for the TUI
2379/// without dragging in the entire spec analyser.
2380///
2381/// Issue #79 round 12.
2382pub fn classify_validation_reason(reason: &str) -> String {
2383    // Round 41 (#79) — Srikanth on 0.3.185: violations on GET requests
2384    // (which carry no body) AND query-only violations on POST requests
2385    // were both being categorised as "request-body" because the older
2386    // matcher checked `r.contains("schema")` before the per-location
2387    // checks. The validator's error payload embeds a structured
2388    // `"path":"<location>.<name>"` per violation; classify on the
2389    // FIRST such path so the category reflects the actual failure
2390    // location instead of the validator's prose.
2391    let r = reason.to_ascii_lowercase();
2392
2393    // Cheap structured pull from the validator's `"path":"<loc>.<name>"` fields.
2394    let path_starts_with = |prefix: &str| r.contains(&format!("\"path\":\"{}", prefix));
2395    if path_starts_with("query.") {
2396        return "query".into();
2397    }
2398    if path_starts_with("header.") {
2399        return "headers".into();
2400    }
2401    if path_starts_with("cookie.") {
2402        return "cookies".into();
2403    }
2404    if path_starts_with("path.") {
2405        return "parameters".into();
2406    }
2407    if path_starts_with("body") {
2408        return "request-body".into();
2409    }
2410
2411    // Content-type mismatches are surfaced separately, BEFORE the
2412    // schema validator runs.
2413    if r.contains("content-type") || r.contains("content type") {
2414        return "content-types".into();
2415    }
2416
2417    // Fallback: the older heuristics for callers that don't embed a
2418    // structured path field (e.g. malformed JSON, missing body
2419    // entirely, security-scheme mismatches).
2420    if r.contains("required")
2421        && (r.contains("param") || r.contains("query") || r.contains("header"))
2422    {
2423        return "parameters".into();
2424    }
2425    if r.contains("auth") || r.contains("security") {
2426        return "security".into();
2427    }
2428    if r.contains("method") {
2429        return "http-methods".into();
2430    }
2431    if r.contains("schema") || r.contains("body") || r.contains("json") {
2432        return "request-body".into();
2433    }
2434    if r.contains("enum") || r.contains("min") || r.contains("max") || r.contains("pattern") {
2435        return "constraints".into();
2436    }
2437    String::new()
2438}
2439
2440/// Record last validation error for Admin UI inspection
2441pub fn record_validation_error(v: &Value) {
2442    if let Ok(mut q) = LAST_ERRORS.lock() {
2443        if q.len() >= 20 {
2444            q.pop_front();
2445        }
2446        q.push_back(v.clone());
2447    }
2448    // If mutex is poisoned, we silently fail - validation errors are informational only
2449}
2450
2451/// Get most recent validation error
2452pub fn get_last_validation_error() -> Option<Value> {
2453    LAST_ERRORS.lock().ok()?.back().cloned()
2454}
2455
2456/// Get recent validation errors (most recent last)
2457pub fn get_validation_errors() -> Vec<Value> {
2458    LAST_ERRORS.lock().map(|q| q.iter().cloned().collect()).unwrap_or_default()
2459}
2460
2461/// Coerce a parameter `value` into the expected JSON type per `schema` where reasonable.
2462/// Applies only to param contexts (not request bodies). Conservative conversions:
2463/// - integer/number: parse from string; arrays: split comma-separated strings and coerce items
2464/// - boolean: parse true/false (case-insensitive) from string
2465fn coerce_value_for_schema(value: &Value, schema: &openapiv3::Schema) -> Value {
2466    // Basic coercion: try to parse strings as appropriate types
2467    match value {
2468        Value::String(s) => {
2469            // Check if schema expects an array and we have a comma-separated string
2470            if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2471                &schema.schema_kind
2472            {
2473                if s.contains(',') {
2474                    // Split comma-separated string into array
2475                    let parts: Vec<&str> = s.split(',').map(|s| s.trim()).collect();
2476                    let mut array_values = Vec::new();
2477
2478                    for part in parts {
2479                        // Coerce each part based on array item type
2480                        if let Some(items_schema) = &array_type.items {
2481                            if let Some(items_schema_obj) = items_schema.as_item() {
2482                                let part_value = Value::String(part.to_string());
2483                                let coerced_part =
2484                                    coerce_value_for_schema(&part_value, items_schema_obj);
2485                                array_values.push(coerced_part);
2486                            } else {
2487                                // If items schema is a reference or not available, keep as string
2488                                array_values.push(Value::String(part.to_string()));
2489                            }
2490                        } else {
2491                            // No items schema defined, keep as string
2492                            array_values.push(Value::String(part.to_string()));
2493                        }
2494                    }
2495                    return Value::Array(array_values);
2496                }
2497            }
2498
2499            // Only coerce if the schema expects a different type
2500            match &schema.schema_kind {
2501                openapiv3::SchemaKind::Type(openapiv3::Type::String(_)) => {
2502                    // Schema expects string, keep as string
2503                    value.clone()
2504                }
2505                openapiv3::SchemaKind::Type(openapiv3::Type::Number(_)) => {
2506                    // Schema expects number, try to parse
2507                    if let Ok(n) = s.parse::<f64>() {
2508                        if let Some(num) = serde_json::Number::from_f64(n) {
2509                            return Value::Number(num);
2510                        }
2511                    }
2512                    value.clone()
2513                }
2514                openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_)) => {
2515                    // Schema expects integer, try to parse
2516                    if let Ok(n) = s.parse::<i64>() {
2517                        if let Some(num) = serde_json::Number::from_f64(n as f64) {
2518                            return Value::Number(num);
2519                        }
2520                    }
2521                    value.clone()
2522                }
2523                openapiv3::SchemaKind::Type(openapiv3::Type::Boolean(_)) => {
2524                    // Schema expects boolean, try to parse
2525                    match s.to_lowercase().as_str() {
2526                        "true" | "1" | "yes" | "on" => Value::Bool(true),
2527                        "false" | "0" | "no" | "off" => Value::Bool(false),
2528                        _ => value.clone(),
2529                    }
2530                }
2531                _ => {
2532                    // Unknown schema type, keep as string
2533                    value.clone()
2534                }
2535            }
2536        }
2537        _ => value.clone(),
2538    }
2539}
2540
2541/// Apply style-aware coercion for query params
2542fn coerce_by_style(value: &Value, schema: &openapiv3::Schema, style: Option<&str>) -> Value {
2543    // Style-aware coercion for query parameters
2544    match value {
2545        Value::String(s) => {
2546            // Check if schema expects an array and we have a delimited string
2547            if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2548                &schema.schema_kind
2549            {
2550                let delimiter = match style {
2551                    Some("spaceDelimited") => " ",
2552                    Some("pipeDelimited") => "|",
2553                    Some("form") | None => ",", // Default to form style (comma-separated)
2554                    _ => ",",                   // Fallback to comma
2555                };
2556
2557                if s.contains(delimiter) {
2558                    // Split delimited string into array
2559                    let parts: Vec<&str> = s.split(delimiter).map(|s| s.trim()).collect();
2560                    let mut array_values = Vec::new();
2561
2562                    for part in parts {
2563                        // Coerce each part based on array item type
2564                        if let Some(items_schema) = &array_type.items {
2565                            if let Some(items_schema_obj) = items_schema.as_item() {
2566                                let part_value = Value::String(part.to_string());
2567                                let coerced_part =
2568                                    coerce_by_style(&part_value, items_schema_obj, style);
2569                                array_values.push(coerced_part);
2570                            } else {
2571                                // If items schema is a reference or not available, keep as string
2572                                array_values.push(Value::String(part.to_string()));
2573                            }
2574                        } else {
2575                            // No items schema defined, keep as string
2576                            array_values.push(Value::String(part.to_string()));
2577                        }
2578                    }
2579                    return Value::Array(array_values);
2580                }
2581            }
2582
2583            // Try to parse as number first
2584            if let Ok(n) = s.parse::<f64>() {
2585                if let Some(num) = serde_json::Number::from_f64(n) {
2586                    return Value::Number(num);
2587                }
2588            }
2589            // Try to parse as boolean
2590            match s.to_lowercase().as_str() {
2591                "true" | "1" | "yes" | "on" => return Value::Bool(true),
2592                "false" | "0" | "no" | "off" => return Value::Bool(false),
2593                _ => {}
2594            }
2595            // Keep as string
2596            value.clone()
2597        }
2598        _ => value.clone(),
2599    }
2600}
2601
2602/// Build a deepObject from query params like `name[prop]=val`
2603fn build_deep_object(name: &str, params: &Map<String, Value>) -> Option<Value> {
2604    let prefix = format!("{}[", name);
2605    let mut obj = Map::new();
2606    for (k, v) in params.iter() {
2607        if let Some(rest) = k.strip_prefix(&prefix) {
2608            if let Some(key) = rest.strip_suffix(']') {
2609                obj.insert(key.to_string(), v.clone());
2610            }
2611        }
2612    }
2613    if obj.is_empty() {
2614        None
2615    } else {
2616        Some(Value::Object(obj))
2617    }
2618}
2619
2620// Import the enhanced schema diff functionality
2621// use mockforge_foundation::schema_diff::{validation_diff, to_enhanced_422_json, ValidationError}; // Not currently used
2622
2623/// Generate an enhanced 422 response with detailed schema validation errors
2624/// This function provides comprehensive error information using the new schema diff utility
2625#[allow(clippy::too_many_arguments)]
2626fn generate_enhanced_422_response(
2627    validator: &OpenApiRouteRegistry,
2628    path_template: &str,
2629    method: &str,
2630    body: Option<&Value>,
2631    path_params: &Map<String, Value>,
2632    query_params: &Map<String, Value>,
2633    header_params: &Map<String, Value>,
2634    cookie_params: &Map<String, Value>,
2635) -> Value {
2636    let mut field_errors = Vec::new();
2637
2638    // Extract schema validation details if we have a route
2639    if let Some(route) = validator.get_route(path_template, method) {
2640        // Validate request body with detailed error collection
2641        if let Some(schema) = &route.operation.request_body {
2642            if let Some(value) = body {
2643                if let Some(content) =
2644                    schema.as_item().and_then(|rb| rb.content.get("application/json"))
2645                {
2646                    if let Some(_schema_ref) = &content.schema {
2647                        // Basic JSON validation - schema validation deferred
2648                        if serde_json::from_value::<Value>(value.clone()).is_err() {
2649                            field_errors.push(json!({
2650                                "path": "body",
2651                                "message": "invalid JSON"
2652                            }));
2653                        }
2654                    }
2655                }
2656            } else {
2657                field_errors.push(json!({
2658                    "path": "body",
2659                    "expected": "object",
2660                    "found": "missing",
2661                    "message": "Request body is required but not provided"
2662                }));
2663            }
2664        }
2665
2666        // Validate parameters with detailed error collection
2667        for param_ref in &route.operation.parameters {
2668            if let Some(param) = param_ref.as_item() {
2669                match param {
2670                    openapiv3::Parameter::Path { parameter_data, .. } => {
2671                        validate_parameter_detailed(
2672                            parameter_data,
2673                            path_params,
2674                            "path",
2675                            "path parameter",
2676                            &mut field_errors,
2677                        );
2678                    }
2679                    openapiv3::Parameter::Query { parameter_data, .. } => {
2680                        let deep_value = if Some("form") == Some("deepObject") {
2681                            build_deep_object(&parameter_data.name, query_params)
2682                        } else {
2683                            None
2684                        };
2685                        validate_parameter_detailed_with_deep(
2686                            parameter_data,
2687                            query_params,
2688                            "query",
2689                            "query parameter",
2690                            deep_value,
2691                            &mut field_errors,
2692                        );
2693                    }
2694                    openapiv3::Parameter::Header { parameter_data, .. } => {
2695                        validate_parameter_detailed(
2696                            parameter_data,
2697                            header_params,
2698                            "header",
2699                            "header parameter",
2700                            &mut field_errors,
2701                        );
2702                    }
2703                    openapiv3::Parameter::Cookie { parameter_data, .. } => {
2704                        validate_parameter_detailed(
2705                            parameter_data,
2706                            cookie_params,
2707                            "cookie",
2708                            "cookie parameter",
2709                            &mut field_errors,
2710                        );
2711                    }
2712                }
2713            }
2714        }
2715    }
2716
2717    // Return the detailed 422 error format
2718    json!({
2719        "error": "Schema validation failed",
2720        "details": field_errors,
2721        "method": method,
2722        "path": path_template,
2723        "timestamp": Utc::now().to_rfc3339(),
2724        "validation_type": "openapi_schema"
2725    })
2726}
2727
2728/// Helper function to validate a parameter
2729fn validate_parameter(
2730    parameter_data: &openapiv3::ParameterData,
2731    params_map: &Map<String, Value>,
2732    prefix: &str,
2733    aggregate: bool,
2734    errors: &mut Vec<String>,
2735    details: &mut Vec<Value>,
2736) {
2737    match params_map.get(&parameter_data.name) {
2738        Some(v) => {
2739            if let ParameterSchemaOrContent::Schema(s) = &parameter_data.format {
2740                if let Some(schema) = s.as_item() {
2741                    let coerced = coerce_value_for_schema(v, schema);
2742                    // Validate the coerced value against the schema
2743                    if let Err(validation_error) =
2744                        OpenApiSchema::new(schema.clone()).validate(&coerced)
2745                    {
2746                        let error_msg = validation_error.to_string();
2747                        errors.push(format!(
2748                            "{} parameter '{}' validation failed: {}",
2749                            prefix, parameter_data.name, error_msg
2750                        ));
2751                        if aggregate {
2752                            details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2753                        }
2754                    }
2755                }
2756            }
2757        }
2758        None => {
2759            if parameter_data.required {
2760                errors.push(format!(
2761                    "missing required {} parameter '{}'",
2762                    prefix, parameter_data.name
2763                ));
2764                details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2765            }
2766        }
2767    }
2768}
2769
2770/// Helper function to validate a parameter with deep object support
2771#[allow(clippy::too_many_arguments)]
2772fn validate_parameter_with_deep_object(
2773    parameter_data: &openapiv3::ParameterData,
2774    params_map: &Map<String, Value>,
2775    prefix: &str,
2776    deep_value: Option<Value>,
2777    style: Option<&str>,
2778    aggregate: bool,
2779    errors: &mut Vec<String>,
2780    details: &mut Vec<Value>,
2781) {
2782    match deep_value.as_ref().or_else(|| params_map.get(&parameter_data.name)) {
2783        Some(v) => {
2784            if let ParameterSchemaOrContent::Schema(s) = &parameter_data.format {
2785                if let Some(schema) = s.as_item() {
2786                    let coerced = coerce_by_style(v, schema, style); // Use the actual style
2787                                                                     // Validate the coerced value against the schema
2788                    if let Err(validation_error) =
2789                        OpenApiSchema::new(schema.clone()).validate(&coerced)
2790                    {
2791                        let error_msg = validation_error.to_string();
2792                        errors.push(format!(
2793                            "{} parameter '{}' validation failed: {}",
2794                            prefix, parameter_data.name, error_msg
2795                        ));
2796                        if aggregate {
2797                            details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2798                        }
2799                    }
2800                }
2801            }
2802        }
2803        None => {
2804            if parameter_data.required {
2805                errors.push(format!(
2806                    "missing required {} parameter '{}'",
2807                    prefix, parameter_data.name
2808                ));
2809                details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2810            }
2811        }
2812    }
2813}
2814
2815/// Helper function to validate a parameter with detailed error collection
2816fn validate_parameter_detailed(
2817    parameter_data: &openapiv3::ParameterData,
2818    params_map: &Map<String, Value>,
2819    location: &str,
2820    value_type: &str,
2821    field_errors: &mut Vec<Value>,
2822) {
2823    match params_map.get(&parameter_data.name) {
2824        Some(value) => {
2825            if let ParameterSchemaOrContent::Schema(schema) = &parameter_data.format {
2826                // Collect detailed validation errors for this parameter
2827                let details: Vec<Value> = Vec::new();
2828                let param_path = format!("{}.{}", location, parameter_data.name);
2829
2830                // Apply coercion before validation
2831                if let Some(schema_ref) = schema.as_item() {
2832                    let coerced_value = coerce_value_for_schema(value, schema_ref);
2833                    // Validate the coerced value against the schema
2834                    if let Err(validation_error) =
2835                        OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2836                    {
2837                        field_errors.push(json!({
2838                            "path": param_path,
2839                            "expected": "valid according to schema",
2840                            "found": coerced_value,
2841                            "message": validation_error.to_string()
2842                        }));
2843                    }
2844                }
2845
2846                for detail in details {
2847                    field_errors.push(json!({
2848                        "path": detail["path"],
2849                        "expected": detail["expected_type"],
2850                        "found": detail["value"],
2851                        "message": detail["message"]
2852                    }));
2853                }
2854            }
2855        }
2856        None => {
2857            if parameter_data.required {
2858                field_errors.push(json!({
2859                    "path": format!("{}.{}", location, parameter_data.name),
2860                    "expected": "value",
2861                    "found": "missing",
2862                    "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
2863                }));
2864            }
2865        }
2866    }
2867}
2868
2869/// Helper function to validate a parameter with deep object support and detailed errors
2870fn validate_parameter_detailed_with_deep(
2871    parameter_data: &openapiv3::ParameterData,
2872    params_map: &Map<String, Value>,
2873    location: &str,
2874    value_type: &str,
2875    deep_value: Option<Value>,
2876    field_errors: &mut Vec<Value>,
2877) {
2878    match deep_value.as_ref().or_else(|| params_map.get(&parameter_data.name)) {
2879        Some(value) => {
2880            if let ParameterSchemaOrContent::Schema(schema) = &parameter_data.format {
2881                // Collect detailed validation errors for this parameter
2882                let details: Vec<Value> = Vec::new();
2883                let param_path = format!("{}.{}", location, parameter_data.name);
2884
2885                // Apply coercion before validation
2886                if let Some(schema_ref) = schema.as_item() {
2887                    let coerced_value = coerce_by_style(value, schema_ref, Some("form")); // Default to form style for now
2888                                                                                          // Validate the coerced value against the schema
2889                    if let Err(validation_error) =
2890                        OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2891                    {
2892                        field_errors.push(json!({
2893                            "path": param_path,
2894                            "expected": "valid according to schema",
2895                            "found": coerced_value,
2896                            "message": validation_error.to_string()
2897                        }));
2898                    }
2899                }
2900
2901                for detail in details {
2902                    field_errors.push(json!({
2903                        "path": detail["path"],
2904                        "expected": detail["expected_type"],
2905                        "found": detail["value"],
2906                        "message": detail["message"]
2907                    }));
2908                }
2909            }
2910        }
2911        None => {
2912            if parameter_data.required {
2913                field_errors.push(json!({
2914                    "path": format!("{}.{}", location, parameter_data.name),
2915                    "expected": "value",
2916                    "found": "missing",
2917                    "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
2918                }));
2919            }
2920        }
2921    }
2922}
2923
2924/// Helper function to create an OpenAPI route registry from a file
2925pub async fn create_registry_from_file<P: AsRef<std::path::Path>>(
2926    path: P,
2927) -> Result<OpenApiRouteRegistry> {
2928    let spec = OpenApiSpec::from_file(path).await?;
2929    spec.validate()?;
2930    Ok(OpenApiRouteRegistry::new(spec))
2931}
2932
2933/// Helper function to create an OpenAPI route registry from JSON
2934pub fn create_registry_from_json(json: Value) -> Result<OpenApiRouteRegistry> {
2935    let spec = OpenApiSpec::from_json(json)?;
2936    spec.validate()?;
2937    Ok(OpenApiRouteRegistry::new(spec))
2938}
2939
2940#[cfg(test)]
2941mod tests {
2942    use super::*;
2943    use serde_json::json;
2944    use tempfile::TempDir;
2945
2946    /// Round 41 (#79) — Srikanth on 0.3.185: GET requests carry no
2947    /// body, so a query-only violation on GET should be categorised
2948    /// as `query`, not `request-body`. POST requests with both query
2949    /// AND body validators should also be classified by where the
2950    /// first detail's `"path":...` lives rather than the validator's
2951    /// generic "schema_validation" prose. The new classifier looks
2952    /// for `"path":"query.<name>"` / `"path":"header.<name>"` etc.
2953    /// first.
2954    #[test]
2955    fn classify_validation_reason_uses_structured_path_field_first() {
2956        // Real shape from the Google Apigee /v1/organizations report
2957        // — query-only enum/boolean violations.
2958        let query_only = r#"{"details":[{"code":"schema_validation","message":"Validation error","path":"query.$.xgafv"}]}"#;
2959        assert_eq!(classify_validation_reason(query_only), "query");
2960
2961        let header_only = r#"{"details":[{"code":"schema_validation","message":"missing required X-Trace","path":"header.X-Trace"}]}"#;
2962        assert_eq!(classify_validation_reason(header_only), "headers");
2963
2964        let cookie_only = r#"{"details":[{"code":"schema_validation","message":"missing session","path":"cookie.session"}]}"#;
2965        assert_eq!(classify_validation_reason(cookie_only), "cookies");
2966
2967        // Body-only violation stays `request-body`.
2968        let body_only = r#"{"details":[{"code":"schema_validation","message":"name required","path":"body.name"}]}"#;
2969        assert_eq!(classify_validation_reason(body_only), "request-body");
2970
2971        // Content-type mismatch keeps its own category.
2972        assert_eq!(
2973            classify_validation_reason("Content-Type application/xml not allowed"),
2974            "content-types"
2975        );
2976    }
2977
2978    #[tokio::test]
2979    async fn test_registry_creation() {
2980        let spec_json = json!({
2981            "openapi": "3.0.0",
2982            "info": {
2983                "title": "Test API",
2984                "version": "1.0.0"
2985            },
2986            "paths": {
2987                "/users": {
2988                    "get": {
2989                        "summary": "Get users",
2990                        "responses": {
2991                            "200": {
2992                                "description": "Success",
2993                                "content": {
2994                                    "application/json": {
2995                                        "schema": {
2996                                            "type": "array",
2997                                            "items": {
2998                                                "type": "object",
2999                                                "properties": {
3000                                                    "id": {"type": "integer"},
3001                                                    "name": {"type": "string"}
3002                                                }
3003                                            }
3004                                        }
3005                                    }
3006                                }
3007                            }
3008                        }
3009                    },
3010                    "post": {
3011                        "summary": "Create user",
3012                        "requestBody": {
3013                            "content": {
3014                                "application/json": {
3015                                    "schema": {
3016                                        "type": "object",
3017                                        "properties": {
3018                                            "name": {"type": "string"}
3019                                        },
3020                                        "required": ["name"]
3021                                    }
3022                                }
3023                            }
3024                        },
3025                        "responses": {
3026                            "201": {
3027                                "description": "Created",
3028                                "content": {
3029                                    "application/json": {
3030                                        "schema": {
3031                                            "type": "object",
3032                                            "properties": {
3033                                                "id": {"type": "integer"},
3034                                                "name": {"type": "string"}
3035                                            }
3036                                        }
3037                                    }
3038                                }
3039                            }
3040                        }
3041                    }
3042                },
3043                "/users/{id}": {
3044                    "get": {
3045                        "summary": "Get user by ID",
3046                        "parameters": [
3047                            {
3048                                "name": "id",
3049                                "in": "path",
3050                                "required": true,
3051                                "schema": {"type": "integer"}
3052                            }
3053                        ],
3054                        "responses": {
3055                            "200": {
3056                                "description": "Success",
3057                                "content": {
3058                                    "application/json": {
3059                                        "schema": {
3060                                            "type": "object",
3061                                            "properties": {
3062                                                "id": {"type": "integer"},
3063                                                "name": {"type": "string"}
3064                                            }
3065                                        }
3066                                    }
3067                                }
3068                            }
3069                        }
3070                    }
3071                }
3072            }
3073        });
3074
3075        let registry = create_registry_from_json(spec_json).unwrap();
3076
3077        // Test basic properties
3078        assert_eq!(registry.paths().len(), 2);
3079        assert!(registry.paths().contains(&"/users".to_string()));
3080        assert!(registry.paths().contains(&"/users/{id}".to_string()));
3081
3082        assert_eq!(registry.methods().len(), 2);
3083        assert!(registry.methods().contains(&"GET".to_string()));
3084        assert!(registry.methods().contains(&"POST".to_string()));
3085
3086        // Test route lookup
3087        let get_users_route = registry.get_route("/users", "GET").unwrap();
3088        assert_eq!(get_users_route.method, "GET");
3089        assert_eq!(get_users_route.path, "/users");
3090
3091        let post_users_route = registry.get_route("/users", "POST").unwrap();
3092        assert_eq!(post_users_route.method, "POST");
3093        assert!(post_users_route.operation.request_body.is_some());
3094
3095        // Test path parameter conversion
3096        let user_by_id_route = registry.get_route("/users/{id}", "GET").unwrap();
3097        assert_eq!(user_by_id_route.axum_path(), "/users/{id}");
3098    }
3099
3100    /// Round 28 — Srikanth's 0.3.171 trace showed mockforge silently
3101    /// accepting `Content-Type: application/xml` against a JSON-only
3102    /// endpoint. The new `check_request_content_type` should flag
3103    /// that as a mismatch; matching types and missing-Content-Type
3104    /// (let the body validator handle it) should still pass.
3105    #[tokio::test]
3106    async fn check_request_content_type_flags_mismatch() {
3107        let spec_json = json!({
3108            "openapi": "3.0.0",
3109            "info": { "title": "T", "version": "1" },
3110            "paths": {
3111                "/api/appliance/access/consolecli": {
3112                    "put": {
3113                        "requestBody": {
3114                            "required": true,
3115                            "content": {
3116                                "application/json": {
3117                                    "schema": {
3118                                        "type": "object",
3119                                        "required": ["enabled"],
3120                                        "properties": {"enabled": {"type": "boolean"}}
3121                                    }
3122                                }
3123                            }
3124                        },
3125                        "responses": { "204": { "description": "ok" } }
3126                    }
3127                }
3128            }
3129        });
3130        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3131        let registry = OpenApiRouteRegistry::new(spec);
3132
3133        // Mismatched Content-Type → flagged.
3134        let r = registry.check_request_content_type(
3135            "/api/appliance/access/consolecli",
3136            "PUT",
3137            Some("application/xml"),
3138        );
3139        assert!(r.is_err(), "should flag application/xml: {:?}", r);
3140        let msg = r.unwrap_err();
3141        assert!(msg.contains("application/xml"), "{msg}");
3142        assert!(msg.contains("application/json"), "{msg}");
3143
3144        // Matching Content-Type → pass.
3145        let r = registry.check_request_content_type(
3146            "/api/appliance/access/consolecli",
3147            "PUT",
3148            Some("application/json"),
3149        );
3150        assert!(r.is_ok(), "should accept application/json: {:?}", r);
3151
3152        // Charset suffix on the matching type → still pass.
3153        let r = registry.check_request_content_type(
3154            "/api/appliance/access/consolecli",
3155            "PUT",
3156            Some("application/json; charset=utf-8"),
3157        );
3158        assert!(r.is_ok(), "should strip charset: {:?}", r);
3159
3160        // No requestBody on this method → noop pass.
3161        let r = registry.check_request_content_type(
3162            "/api/appliance/access/consolecli",
3163            "GET",
3164            Some("application/xml"),
3165        );
3166        assert!(r.is_ok(), "GET has no requestBody on this op: {:?}", r);
3167
3168        // No Content-Type sent → noop pass (body validator's job).
3169        let r =
3170            registry.check_request_content_type("/api/appliance/access/consolecli", "PUT", None);
3171        assert!(r.is_ok(), "no Content-Type → don't double-report: {:?}", r);
3172    }
3173
3174    #[tokio::test]
3175    async fn test_validate_request_with_params_and_formats() {
3176        let spec_json = json!({
3177            "openapi": "3.0.0",
3178            "info": { "title": "Test API", "version": "1.0.0" },
3179            "paths": {
3180                "/users/{id}": {
3181                    "post": {
3182                        "parameters": [
3183                            { "name": "id", "in": "path", "required": true, "schema": {"type": "string"} },
3184                            { "name": "q",  "in": "query", "required": false, "schema": {"type": "integer"} }
3185                        ],
3186                        "requestBody": {
3187                            "content": {
3188                                "application/json": {
3189                                    "schema": {
3190                                        "type": "object",
3191                                        "required": ["email", "website"],
3192                                        "properties": {
3193                                            "email":   {"type": "string", "format": "email"},
3194                                            "website": {"type": "string", "format": "uri"}
3195                                        }
3196                                    }
3197                                }
3198                            }
3199                        },
3200                        "responses": {"200": {"description": "ok"}}
3201                    }
3202                }
3203            }
3204        });
3205
3206        let registry = create_registry_from_json(spec_json).unwrap();
3207        let mut path_params = Map::new();
3208        path_params.insert("id".to_string(), json!("abc"));
3209        let mut query_params = Map::new();
3210        query_params.insert("q".to_string(), json!(123));
3211
3212        // valid body
3213        let body = json!({"email":"a@b.co","website":"https://example.com"});
3214        assert!(registry
3215            .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3216            .is_ok());
3217
3218        // invalid email
3219        let bad_email = json!({"email":"not-an-email","website":"https://example.com"});
3220        assert!(registry
3221            .validate_request_with(
3222                "/users/{id}",
3223                "POST",
3224                &path_params,
3225                &query_params,
3226                Some(&bad_email)
3227            )
3228            .is_err());
3229
3230        // missing required path param
3231        let empty_path_params = Map::new();
3232        assert!(registry
3233            .validate_request_with(
3234                "/users/{id}",
3235                "POST",
3236                &empty_path_params,
3237                &query_params,
3238                Some(&body)
3239            )
3240            .is_err());
3241    }
3242
3243    #[tokio::test]
3244    async fn test_ref_resolution_for_params_and_body() {
3245        let spec_json = json!({
3246            "openapi": "3.0.0",
3247            "info": { "title": "Ref API", "version": "1.0.0" },
3248            "components": {
3249                "schemas": {
3250                    "EmailWebsite": {
3251                        "type": "object",
3252                        "required": ["email", "website"],
3253                        "properties": {
3254                            "email":   {"type": "string", "format": "email"},
3255                            "website": {"type": "string", "format": "uri"}
3256                        }
3257                    }
3258                },
3259                "parameters": {
3260                    "PathId": {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}},
3261                    "QueryQ": {"name": "q",  "in": "query", "required": false, "schema": {"type": "integer"}}
3262                },
3263                "requestBodies": {
3264                    "CreateUser": {
3265                        "content": {
3266                            "application/json": {
3267                                "schema": {"$ref": "#/components/schemas/EmailWebsite"}
3268                            }
3269                        }
3270                    }
3271                }
3272            },
3273            "paths": {
3274                "/users/{id}": {
3275                    "post": {
3276                        "parameters": [
3277                            {"$ref": "#/components/parameters/PathId"},
3278                            {"$ref": "#/components/parameters/QueryQ"}
3279                        ],
3280                        "requestBody": {"$ref": "#/components/requestBodies/CreateUser"},
3281                        "responses": {"200": {"description": "ok"}}
3282                    }
3283                }
3284            }
3285        });
3286
3287        let registry = create_registry_from_json(spec_json).unwrap();
3288        let mut path_params = Map::new();
3289        path_params.insert("id".to_string(), json!("abc"));
3290        let mut query_params = Map::new();
3291        query_params.insert("q".to_string(), json!(7));
3292
3293        let body = json!({"email":"user@example.com","website":"https://example.com"});
3294        assert!(registry
3295            .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3296            .is_ok());
3297
3298        let bad = json!({"email":"nope","website":"https://example.com"});
3299        assert!(registry
3300            .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&bad))
3301            .is_err());
3302    }
3303
3304    #[tokio::test]
3305    async fn test_header_cookie_and_query_coercion() {
3306        let spec_json = json!({
3307            "openapi": "3.0.0",
3308            "info": { "title": "Params API", "version": "1.0.0" },
3309            "paths": {
3310                "/items": {
3311                    "get": {
3312                        "parameters": [
3313                            {"name": "X-Flag", "in": "header", "required": true, "schema": {"type": "boolean"}},
3314                            {"name": "session", "in": "cookie", "required": true, "schema": {"type": "string"}},
3315                            {"name": "ids", "in": "query", "required": false, "schema": {"type": "array", "items": {"type": "integer"}}}
3316                        ],
3317                        "responses": {"200": {"description": "ok"}}
3318                    }
3319                }
3320            }
3321        });
3322
3323        let registry = create_registry_from_json(spec_json).unwrap();
3324
3325        let path_params = Map::new();
3326        let mut query_params = Map::new();
3327        // comma-separated string for array should coerce
3328        query_params.insert("ids".to_string(), json!("1,2,3"));
3329        let mut header_params = Map::new();
3330        header_params.insert("X-Flag".to_string(), json!("true"));
3331        let mut cookie_params = Map::new();
3332        cookie_params.insert("session".to_string(), json!("abc123"));
3333
3334        assert!(registry
3335            .validate_request_with_all(
3336                "/items",
3337                "GET",
3338                &path_params,
3339                &query_params,
3340                &header_params,
3341                &cookie_params,
3342                None
3343            )
3344            .is_ok());
3345
3346        // Missing required cookie
3347        let empty_cookie = Map::new();
3348        assert!(registry
3349            .validate_request_with_all(
3350                "/items",
3351                "GET",
3352                &path_params,
3353                &query_params,
3354                &header_params,
3355                &empty_cookie,
3356                None
3357            )
3358            .is_err());
3359
3360        // Bad boolean header value (cannot coerce)
3361        let mut bad_header = Map::new();
3362        bad_header.insert("X-Flag".to_string(), json!("notabool"));
3363        assert!(registry
3364            .validate_request_with_all(
3365                "/items",
3366                "GET",
3367                &path_params,
3368                &query_params,
3369                &bad_header,
3370                &cookie_params,
3371                None
3372            )
3373            .is_err());
3374    }
3375
3376    #[tokio::test]
3377    async fn test_query_styles_space_pipe_deepobject() {
3378        let spec_json = json!({
3379            "openapi": "3.0.0",
3380            "info": { "title": "Query Styles API", "version": "1.0.0" },
3381            "paths": {"/search": {"get": {
3382                "parameters": [
3383                    {"name":"tags","in":"query","style":"spaceDelimited","schema":{"type":"array","items":{"type":"string"}}},
3384                    {"name":"ids","in":"query","style":"pipeDelimited","schema":{"type":"array","items":{"type":"integer"}}},
3385                    {"name":"filter","in":"query","style":"deepObject","schema":{"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}}
3386                ],
3387                "responses": {"200": {"description":"ok"}}
3388            }} }
3389        });
3390
3391        let registry = create_registry_from_json(spec_json).unwrap();
3392
3393        let path_params = Map::new();
3394        let mut query = Map::new();
3395        query.insert("tags".into(), json!("alpha beta gamma"));
3396        query.insert("ids".into(), json!("1|2|3"));
3397        query.insert("filter[color]".into(), json!("red"));
3398
3399        assert!(registry
3400            .validate_request_with("/search", "GET", &path_params, &query, None)
3401            .is_ok());
3402    }
3403
3404    #[tokio::test]
3405    async fn test_oneof_anyof_allof_validation() {
3406        let spec_json = json!({
3407            "openapi": "3.0.0",
3408            "info": { "title": "Composite API", "version": "1.0.0" },
3409            "paths": {
3410                "/composite": {
3411                    "post": {
3412                        "requestBody": {
3413                            "content": {
3414                                "application/json": {
3415                                    "schema": {
3416                                        "allOf": [
3417                                            {"type": "object", "required": ["base"], "properties": {"base": {"type": "string"}}}
3418                                        ],
3419                                        "oneOf": [
3420                                            {"type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"], "not": {"required": ["b"]}},
3421                                            {"type": "object", "properties": {"b": {"type": "integer"}}, "required": ["b"], "not": {"required": ["a"]}}
3422                                        ],
3423                                        "anyOf": [
3424                                            {"type": "object", "properties": {"flag": {"type": "boolean"}}, "required": ["flag"]},
3425                                            {"type": "object", "properties": {"extra": {"type": "string"}}, "required": ["extra"]}
3426                                        ]
3427                                    }
3428                                }
3429                            }
3430                        },
3431                        "responses": {"200": {"description": "ok"}}
3432                    }
3433                }
3434            }
3435        });
3436
3437        let registry = create_registry_from_json(spec_json).unwrap();
3438        // valid: satisfies base via allOf, exactly one of a/b, and at least one of flag/extra
3439        let ok = json!({"base": "x", "a": 1, "flag": true});
3440        assert!(registry
3441            .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&ok))
3442            .is_ok());
3443
3444        // invalid oneOf: both a and b present
3445        let bad_oneof = json!({"base": "x", "a": 1, "b": 2, "flag": false});
3446        assert!(registry
3447            .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_oneof))
3448            .is_err());
3449
3450        // invalid anyOf: none of flag/extra present
3451        let bad_anyof = json!({"base": "x", "a": 1});
3452        assert!(registry
3453            .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_anyof))
3454            .is_err());
3455
3456        // invalid allOf: missing base
3457        let bad_allof = json!({"a": 1, "flag": true});
3458        assert!(registry
3459            .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_allof))
3460            .is_err());
3461    }
3462
3463    /// Round 19 — regression for Srikanth's vCenter spec which has
3464    /// component schemas with dotted names like
3465    /// `Esx.Settings.Inventory.EntitySpec`. The route-handler's body
3466    /// validator used to build a naked `jsonschema` validator with no
3467    /// `components` context, so nested `$ref` strings to
3468    /// `#/components/schemas/X` failed with "Pointer does not exist".
3469    /// Round 18.3 fixed the bench + `validate_request_body` paths;
3470    /// round 19 fixes this third path in `openapi_routes`.
3471    #[tokio::test]
3472    async fn dotted_schema_ref_resolves_in_route_validator() {
3473        let spec_json = json!({
3474            "openapi": "3.0.0",
3475            "info": { "title": "Dotted", "version": "1.0.0" },
3476            "paths": {
3477                "/x": {
3478                    "post": {
3479                        "requestBody": {
3480                            "required": true,
3481                            "content": {
3482                                "application/json": {
3483                                    "schema": {
3484                                        "$ref": "#/components/schemas/Esx.Settings.Inventory.EntitySpec"
3485                                    }
3486                                }
3487                            }
3488                        },
3489                        "responses": {"200": {"description": "ok"}}
3490                    }
3491                }
3492            },
3493            "components": {
3494                "schemas": {
3495                    "Esx.Settings.Inventory.EntitySpec": {
3496                        "type": "object",
3497                        "required": ["type"],
3498                        "properties": {"type": {"type": "string"}}
3499                    }
3500                }
3501            }
3502        });
3503        let registry = create_registry_from_json(spec_json).unwrap();
3504        // Pre-fix: this errored with `Pointer '/components/schemas/Esx.Settings.Inventory.EntitySpec' does not exist`.
3505        // Post-fix: the dotted ref resolves and the body validates.
3506        let good = json!({"type": "HOST"});
3507        let res =
3508            registry.validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&good));
3509        assert!(res.is_ok(), "valid body should pass; got {res:?}");
3510        // And a bad body should still error from inside the resolved schema, not from a build failure.
3511        let bad = json!({"unrelated": 1});
3512        let err = registry
3513            .validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&bad))
3514            .unwrap_err();
3515        let msg = format!("{err}");
3516        assert!(
3517            !msg.contains("Pointer") || !msg.contains("does not exist"),
3518            "should not be a pointer-resolution failure; got: {msg}"
3519        );
3520    }
3521
3522    #[tokio::test]
3523    async fn test_overrides_warn_mode_allows_invalid() {
3524        // Spec with a POST route expecting an integer query param
3525        let spec_json = json!({
3526            "openapi": "3.0.0",
3527            "info": { "title": "Overrides API", "version": "1.0.0" },
3528            "paths": {"/things": {"post": {
3529                "parameters": [{"name":"q","in":"query","required":true,"schema":{"type":"integer"}}],
3530                "responses": {"200": {"description":"ok"}}
3531            }}}
3532        });
3533
3534        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3535        let mut overrides = HashMap::new();
3536        overrides.insert("POST /things".to_string(), ValidationMode::Warn);
3537        let registry = OpenApiRouteRegistry::new_with_options(
3538            spec,
3539            ValidationOptions {
3540                request_mode: ValidationMode::Enforce,
3541                aggregate_errors: true,
3542                validate_responses: false,
3543                overrides,
3544                admin_skip_prefixes: vec![],
3545                response_template_expand: false,
3546                validation_status: None,
3547            },
3548        );
3549
3550        // Invalid q (missing) should warn, not error
3551        let ok = registry.validate_request_with("/things", "POST", &Map::new(), &Map::new(), None);
3552        assert!(ok.is_ok());
3553    }
3554
3555    #[tokio::test]
3556    async fn test_admin_skip_prefix_short_circuit() {
3557        let spec_json = json!({
3558            "openapi": "3.0.0",
3559            "info": { "title": "Skip API", "version": "1.0.0" },
3560            "paths": {}
3561        });
3562        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3563        let registry = OpenApiRouteRegistry::new_with_options(
3564            spec,
3565            ValidationOptions {
3566                request_mode: ValidationMode::Enforce,
3567                aggregate_errors: true,
3568                validate_responses: false,
3569                overrides: HashMap::new(),
3570                admin_skip_prefixes: vec!["/admin".into()],
3571                response_template_expand: false,
3572                validation_status: None,
3573            },
3574        );
3575
3576        // No route exists for this, but skip prefix means it is accepted
3577        let res = registry.validate_request_with_all(
3578            "/admin/__mockforge/health",
3579            "GET",
3580            &Map::new(),
3581            &Map::new(),
3582            &Map::new(),
3583            &Map::new(),
3584            None,
3585        );
3586        assert!(res.is_ok());
3587    }
3588
3589    #[test]
3590    fn test_path_conversion() {
3591        assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users"), "/users");
3592        assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users/{id}"), "/users/{id}");
3593        assert_eq!(
3594            OpenApiRouteRegistry::convert_path_to_axum("/users/{id}/posts/{postId}"),
3595            "/users/{id}/posts/{postId}"
3596        );
3597    }
3598
3599    #[test]
3600    fn test_validation_options_default() {
3601        let options = ValidationOptions::default();
3602        assert!(matches!(options.request_mode, ValidationMode::Enforce));
3603        assert!(options.aggregate_errors);
3604        assert!(!options.validate_responses);
3605        assert!(options.overrides.is_empty());
3606        assert!(options.admin_skip_prefixes.is_empty());
3607        assert!(!options.response_template_expand);
3608        assert!(options.validation_status.is_none());
3609    }
3610
3611    #[test]
3612    fn test_validation_mode_variants() {
3613        // Test that all variants can be created and compared
3614        let disabled = ValidationMode::Disabled;
3615        let warn = ValidationMode::Warn;
3616        let enforce = ValidationMode::Enforce;
3617        let default = ValidationMode::default();
3618
3619        // Test that default is Warn
3620        assert!(matches!(default, ValidationMode::Warn));
3621
3622        // Test that variants are distinct
3623        assert!(!matches!(disabled, ValidationMode::Warn));
3624        assert!(!matches!(warn, ValidationMode::Enforce));
3625        assert!(!matches!(enforce, ValidationMode::Disabled));
3626    }
3627
3628    #[test]
3629    fn test_registry_spec_accessor() {
3630        let spec_json = json!({
3631            "openapi": "3.0.0",
3632            "info": {
3633                "title": "Test API",
3634                "version": "1.0.0"
3635            },
3636            "paths": {}
3637        });
3638        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3639        let registry = OpenApiRouteRegistry::new(spec.clone());
3640
3641        // Test spec() accessor
3642        let accessed_spec = registry.spec();
3643        assert_eq!(accessed_spec.title(), "Test API");
3644    }
3645
3646    #[test]
3647    fn test_clone_for_validation() {
3648        let spec_json = json!({
3649            "openapi": "3.0.0",
3650            "info": {
3651                "title": "Test API",
3652                "version": "1.0.0"
3653            },
3654            "paths": {
3655                "/users": {
3656                    "get": {
3657                        "responses": {
3658                            "200": {
3659                                "description": "Success"
3660                            }
3661                        }
3662                    }
3663                }
3664            }
3665        });
3666        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3667        let registry = OpenApiRouteRegistry::new(spec);
3668
3669        // Test clone_for_validation
3670        let cloned = registry.clone_for_validation();
3671        assert_eq!(cloned.routes().len(), registry.routes().len());
3672        assert_eq!(cloned.spec().title(), registry.spec().title());
3673    }
3674
3675    #[test]
3676    fn test_with_custom_fixture_loader() {
3677        let temp_dir = TempDir::new().unwrap();
3678        let spec_json = json!({
3679            "openapi": "3.0.0",
3680            "info": {
3681                "title": "Test API",
3682                "version": "1.0.0"
3683            },
3684            "paths": {}
3685        });
3686        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3687        let registry = OpenApiRouteRegistry::new(spec);
3688        let original_routes_len = registry.routes().len();
3689
3690        // Test with_custom_fixture_loader
3691        let custom_loader = Arc::new(crate::custom_fixture::CustomFixtureLoader::new(
3692            temp_dir.path().to_path_buf(),
3693            true,
3694        ));
3695        let registry_with_loader = registry.with_custom_fixture_loader(custom_loader);
3696
3697        // Verify the loader was set (we can't directly access it, but we can test it doesn't panic)
3698        assert_eq!(registry_with_loader.routes().len(), original_routes_len);
3699    }
3700
3701    #[test]
3702    fn test_get_route() {
3703        let spec_json = json!({
3704            "openapi": "3.0.0",
3705            "info": {
3706                "title": "Test API",
3707                "version": "1.0.0"
3708            },
3709            "paths": {
3710                "/users": {
3711                    "get": {
3712                        "operationId": "getUsers",
3713                        "responses": {
3714                            "200": {
3715                                "description": "Success"
3716                            }
3717                        }
3718                    },
3719                    "post": {
3720                        "operationId": "createUser",
3721                        "responses": {
3722                            "201": {
3723                                "description": "Created"
3724                            }
3725                        }
3726                    }
3727                }
3728            }
3729        });
3730        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3731        let registry = OpenApiRouteRegistry::new(spec);
3732
3733        // Test get_route for existing route
3734        let route = registry.get_route("/users", "GET");
3735        assert!(route.is_some());
3736        assert_eq!(route.unwrap().method, "GET");
3737        assert_eq!(route.unwrap().path, "/users");
3738
3739        // Test get_route for non-existent route
3740        let route = registry.get_route("/nonexistent", "GET");
3741        assert!(route.is_none());
3742
3743        // Test get_route for different method
3744        let route = registry.get_route("/users", "POST");
3745        assert!(route.is_some());
3746        assert_eq!(route.unwrap().method, "POST");
3747    }
3748
3749    #[test]
3750    fn test_get_routes_for_path() {
3751        let spec_json = json!({
3752            "openapi": "3.0.0",
3753            "info": {
3754                "title": "Test API",
3755                "version": "1.0.0"
3756            },
3757            "paths": {
3758                "/users": {
3759                    "get": {
3760                        "responses": {
3761                            "200": {
3762                                "description": "Success"
3763                            }
3764                        }
3765                    },
3766                    "post": {
3767                        "responses": {
3768                            "201": {
3769                                "description": "Created"
3770                            }
3771                        }
3772                    },
3773                    "put": {
3774                        "responses": {
3775                            "200": {
3776                                "description": "Success"
3777                            }
3778                        }
3779                    }
3780                },
3781                "/posts": {
3782                    "get": {
3783                        "responses": {
3784                            "200": {
3785                                "description": "Success"
3786                            }
3787                        }
3788                    }
3789                }
3790            }
3791        });
3792        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3793        let registry = OpenApiRouteRegistry::new(spec);
3794
3795        // Test get_routes_for_path with multiple methods
3796        let routes = registry.get_routes_for_path("/users");
3797        assert_eq!(routes.len(), 3);
3798        let methods: Vec<&str> = routes.iter().map(|r| r.method.as_str()).collect();
3799        assert!(methods.contains(&"GET"));
3800        assert!(methods.contains(&"POST"));
3801        assert!(methods.contains(&"PUT"));
3802
3803        // Test get_routes_for_path with single method
3804        let routes = registry.get_routes_for_path("/posts");
3805        assert_eq!(routes.len(), 1);
3806        assert_eq!(routes[0].method, "GET");
3807
3808        // Test get_routes_for_path with non-existent path
3809        let routes = registry.get_routes_for_path("/nonexistent");
3810        assert!(routes.is_empty());
3811    }
3812
3813    #[test]
3814    fn test_new_vs_new_with_options() {
3815        let spec_json = json!({
3816            "openapi": "3.0.0",
3817            "info": {
3818                "title": "Test API",
3819                "version": "1.0.0"
3820            },
3821            "paths": {}
3822        });
3823        let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3824        let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3825
3826        // Test new() - uses environment-based options
3827        let registry1 = OpenApiRouteRegistry::new(spec1);
3828        assert_eq!(registry1.spec().title(), "Test API");
3829
3830        // Test new_with_options() - uses explicit options
3831        let options = ValidationOptions {
3832            request_mode: ValidationMode::Disabled,
3833            aggregate_errors: false,
3834            validate_responses: true,
3835            overrides: HashMap::new(),
3836            admin_skip_prefixes: vec!["/admin".to_string()],
3837            response_template_expand: true,
3838            validation_status: Some(422),
3839        };
3840        let registry2 = OpenApiRouteRegistry::new_with_options(spec2, options);
3841        assert_eq!(registry2.spec().title(), "Test API");
3842    }
3843
3844    #[test]
3845    fn test_new_with_env_vs_new() {
3846        let spec_json = json!({
3847            "openapi": "3.0.0",
3848            "info": {
3849                "title": "Test API",
3850                "version": "1.0.0"
3851            },
3852            "paths": {}
3853        });
3854        let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3855        let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3856
3857        // Test new() calls new_with_env()
3858        let registry1 = OpenApiRouteRegistry::new(spec1);
3859
3860        // Test new_with_env() directly
3861        let registry2 = OpenApiRouteRegistry::new_with_env(spec2);
3862
3863        // Both should create valid registries
3864        assert_eq!(registry1.spec().title(), "Test API");
3865        assert_eq!(registry2.spec().title(), "Test API");
3866    }
3867
3868    #[test]
3869    fn test_validation_options_custom() {
3870        let options = ValidationOptions {
3871            request_mode: ValidationMode::Warn,
3872            aggregate_errors: false,
3873            validate_responses: true,
3874            overrides: {
3875                let mut map = HashMap::new();
3876                map.insert("getUsers".to_string(), ValidationMode::Disabled);
3877                map
3878            },
3879            admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
3880            response_template_expand: true,
3881            validation_status: Some(422),
3882        };
3883
3884        assert!(matches!(options.request_mode, ValidationMode::Warn));
3885        assert!(!options.aggregate_errors);
3886        assert!(options.validate_responses);
3887        assert_eq!(options.overrides.len(), 1);
3888        assert_eq!(options.admin_skip_prefixes.len(), 2);
3889        assert!(options.response_template_expand);
3890        assert_eq!(options.validation_status, Some(422));
3891    }
3892
3893    #[test]
3894    fn test_validation_mode_default_standalone() {
3895        let mode = ValidationMode::default();
3896        assert!(matches!(mode, ValidationMode::Warn));
3897    }
3898
3899    #[test]
3900    fn test_validation_mode_clone() {
3901        let mode1 = ValidationMode::Enforce;
3902        let mode2 = mode1.clone();
3903        assert!(matches!(mode1, ValidationMode::Enforce));
3904        assert!(matches!(mode2, ValidationMode::Enforce));
3905    }
3906
3907    #[test]
3908    fn test_validation_mode_debug() {
3909        let mode = ValidationMode::Disabled;
3910        let debug_str = format!("{:?}", mode);
3911        assert!(debug_str.contains("Disabled") || debug_str.contains("ValidationMode"));
3912    }
3913
3914    #[test]
3915    fn test_validation_options_clone() {
3916        let options1 = ValidationOptions {
3917            request_mode: ValidationMode::Warn,
3918            aggregate_errors: true,
3919            validate_responses: false,
3920            overrides: HashMap::new(),
3921            admin_skip_prefixes: vec![],
3922            response_template_expand: false,
3923            validation_status: None,
3924        };
3925        let options2 = options1.clone();
3926        assert!(matches!(options2.request_mode, ValidationMode::Warn));
3927        assert_eq!(options1.aggregate_errors, options2.aggregate_errors);
3928    }
3929
3930    #[test]
3931    fn test_validation_options_debug() {
3932        let options = ValidationOptions::default();
3933        let debug_str = format!("{:?}", options);
3934        assert!(debug_str.contains("ValidationOptions"));
3935    }
3936
3937    #[test]
3938    fn test_validation_options_with_all_fields() {
3939        let mut overrides = HashMap::new();
3940        overrides.insert("op1".to_string(), ValidationMode::Disabled);
3941        overrides.insert("op2".to_string(), ValidationMode::Warn);
3942
3943        let options = ValidationOptions {
3944            request_mode: ValidationMode::Enforce,
3945            aggregate_errors: false,
3946            validate_responses: true,
3947            overrides: overrides.clone(),
3948            admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
3949            response_template_expand: true,
3950            validation_status: Some(422),
3951        };
3952
3953        assert!(matches!(options.request_mode, ValidationMode::Enforce));
3954        assert!(!options.aggregate_errors);
3955        assert!(options.validate_responses);
3956        assert_eq!(options.overrides.len(), 2);
3957        assert_eq!(options.admin_skip_prefixes.len(), 2);
3958        assert!(options.response_template_expand);
3959        assert_eq!(options.validation_status, Some(422));
3960    }
3961
3962    #[test]
3963    fn test_openapi_route_registry_clone() {
3964        let spec_json = json!({
3965            "openapi": "3.0.0",
3966            "info": { "title": "Test API", "version": "1.0.0" },
3967            "paths": {}
3968        });
3969        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3970        let registry1 = OpenApiRouteRegistry::new(spec);
3971        let registry2 = registry1.clone();
3972        assert_eq!(registry1.spec().title(), registry2.spec().title());
3973    }
3974
3975    #[test]
3976    fn test_validation_mode_serialization() {
3977        let mode = ValidationMode::Enforce;
3978        let json = serde_json::to_string(&mode).unwrap();
3979        assert!(json.contains("Enforce") || json.contains("enforce"));
3980    }
3981
3982    #[test]
3983    fn test_validation_mode_deserialization() {
3984        let json = r#""Disabled""#;
3985        let mode: ValidationMode = serde_json::from_str(json).unwrap();
3986        assert!(matches!(mode, ValidationMode::Disabled));
3987    }
3988
3989    #[test]
3990    fn test_validation_options_default_values() {
3991        let options = ValidationOptions::default();
3992        assert!(matches!(options.request_mode, ValidationMode::Enforce));
3993        assert!(options.aggregate_errors);
3994        assert!(!options.validate_responses);
3995        assert!(options.overrides.is_empty());
3996        assert!(options.admin_skip_prefixes.is_empty());
3997        assert!(!options.response_template_expand);
3998        assert_eq!(options.validation_status, None);
3999    }
4000
4001    #[test]
4002    fn test_validation_mode_all_variants() {
4003        let disabled = ValidationMode::Disabled;
4004        let warn = ValidationMode::Warn;
4005        let enforce = ValidationMode::Enforce;
4006
4007        assert!(matches!(disabled, ValidationMode::Disabled));
4008        assert!(matches!(warn, ValidationMode::Warn));
4009        assert!(matches!(enforce, ValidationMode::Enforce));
4010    }
4011
4012    #[test]
4013    fn test_validation_options_with_overrides() {
4014        let mut overrides = HashMap::new();
4015        overrides.insert("operation1".to_string(), ValidationMode::Disabled);
4016        overrides.insert("operation2".to_string(), ValidationMode::Warn);
4017
4018        let options = ValidationOptions {
4019            request_mode: ValidationMode::Enforce,
4020            aggregate_errors: true,
4021            validate_responses: false,
4022            overrides,
4023            admin_skip_prefixes: vec![],
4024            response_template_expand: false,
4025            validation_status: None,
4026        };
4027
4028        assert_eq!(options.overrides.len(), 2);
4029        assert!(matches!(options.overrides.get("operation1"), Some(ValidationMode::Disabled)));
4030        assert!(matches!(options.overrides.get("operation2"), Some(ValidationMode::Warn)));
4031    }
4032
4033    #[test]
4034    fn test_validation_options_with_admin_skip_prefixes() {
4035        let options = ValidationOptions {
4036            request_mode: ValidationMode::Enforce,
4037            aggregate_errors: true,
4038            validate_responses: false,
4039            overrides: HashMap::new(),
4040            admin_skip_prefixes: vec![
4041                "/admin".to_string(),
4042                "/internal".to_string(),
4043                "/debug".to_string(),
4044            ],
4045            response_template_expand: false,
4046            validation_status: None,
4047        };
4048
4049        assert_eq!(options.admin_skip_prefixes.len(), 3);
4050        assert!(options.admin_skip_prefixes.contains(&"/admin".to_string()));
4051        assert!(options.admin_skip_prefixes.contains(&"/internal".to_string()));
4052        assert!(options.admin_skip_prefixes.contains(&"/debug".to_string()));
4053    }
4054
4055    #[test]
4056    fn test_validation_options_with_validation_status() {
4057        let options1 = ValidationOptions {
4058            request_mode: ValidationMode::Enforce,
4059            aggregate_errors: true,
4060            validate_responses: false,
4061            overrides: HashMap::new(),
4062            admin_skip_prefixes: vec![],
4063            response_template_expand: false,
4064            validation_status: Some(400),
4065        };
4066
4067        let options2 = ValidationOptions {
4068            request_mode: ValidationMode::Enforce,
4069            aggregate_errors: true,
4070            validate_responses: false,
4071            overrides: HashMap::new(),
4072            admin_skip_prefixes: vec![],
4073            response_template_expand: false,
4074            validation_status: Some(422),
4075        };
4076
4077        assert_eq!(options1.validation_status, Some(400));
4078        assert_eq!(options2.validation_status, Some(422));
4079    }
4080
4081    #[test]
4082    fn test_validate_request_with_disabled_mode() {
4083        // Test validation with disabled mode (lines 1001-1007)
4084        let spec_json = json!({
4085            "openapi": "3.0.0",
4086            "info": {"title": "Test API", "version": "1.0.0"},
4087            "paths": {
4088                "/users": {
4089                    "get": {
4090                        "responses": {"200": {"description": "OK"}}
4091                    }
4092                }
4093            }
4094        });
4095        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4096        let options = ValidationOptions {
4097            request_mode: ValidationMode::Disabled,
4098            ..Default::default()
4099        };
4100        let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4101
4102        // Should pass validation when disabled (lines 1002-1003, 1005-1007)
4103        let result = registry.validate_request_with_all(
4104            "/users",
4105            "GET",
4106            &Map::new(),
4107            &Map::new(),
4108            &Map::new(),
4109            &Map::new(),
4110            None,
4111        );
4112        assert!(result.is_ok());
4113    }
4114
4115    #[test]
4116    fn test_validate_request_with_warn_mode() {
4117        // Test validation with warn mode (lines 1162-1166)
4118        let spec_json = json!({
4119            "openapi": "3.0.0",
4120            "info": {"title": "Test API", "version": "1.0.0"},
4121            "paths": {
4122                "/users": {
4123                    "post": {
4124                        "requestBody": {
4125                            "required": true,
4126                            "content": {
4127                                "application/json": {
4128                                    "schema": {
4129                                        "type": "object",
4130                                        "required": ["name"],
4131                                        "properties": {
4132                                            "name": {"type": "string"}
4133                                        }
4134                                    }
4135                                }
4136                            }
4137                        },
4138                        "responses": {"200": {"description": "OK"}}
4139                    }
4140                }
4141            }
4142        });
4143        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4144        let options = ValidationOptions {
4145            request_mode: ValidationMode::Warn,
4146            ..Default::default()
4147        };
4148        let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4149
4150        // Should pass with warnings when body is missing (lines 1162-1166)
4151        let result = registry.validate_request_with_all(
4152            "/users",
4153            "POST",
4154            &Map::new(),
4155            &Map::new(),
4156            &Map::new(),
4157            &Map::new(),
4158            None, // Missing required body
4159        );
4160        assert!(result.is_ok()); // Warn mode doesn't fail
4161    }
4162
4163    #[test]
4164    fn test_validate_request_body_validation_error() {
4165        // Test request body validation error path (lines 1072-1091)
4166        let spec_json = json!({
4167            "openapi": "3.0.0",
4168            "info": {"title": "Test API", "version": "1.0.0"},
4169            "paths": {
4170                "/users": {
4171                    "post": {
4172                        "requestBody": {
4173                            "required": true,
4174                            "content": {
4175                                "application/json": {
4176                                    "schema": {
4177                                        "type": "object",
4178                                        "required": ["name"],
4179                                        "properties": {
4180                                            "name": {"type": "string"}
4181                                        }
4182                                    }
4183                                }
4184                            }
4185                        },
4186                        "responses": {"200": {"description": "OK"}}
4187                    }
4188                }
4189            }
4190        });
4191        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4192        let registry = OpenApiRouteRegistry::new(spec);
4193
4194        // Should fail validation when body is missing (lines 1088-1091)
4195        let result = registry.validate_request_with_all(
4196            "/users",
4197            "POST",
4198            &Map::new(),
4199            &Map::new(),
4200            &Map::new(),
4201            &Map::new(),
4202            None, // Missing required body
4203        );
4204        assert!(result.is_err());
4205    }
4206
4207    #[test]
4208    fn test_validate_request_body_schema_validation_error() {
4209        // Test request body schema validation error (lines 1038-1049)
4210        let spec_json = json!({
4211            "openapi": "3.0.0",
4212            "info": {"title": "Test API", "version": "1.0.0"},
4213            "paths": {
4214                "/users": {
4215                    "post": {
4216                        "requestBody": {
4217                            "required": true,
4218                            "content": {
4219                                "application/json": {
4220                                    "schema": {
4221                                        "type": "object",
4222                                        "required": ["name"],
4223                                        "properties": {
4224                                            "name": {"type": "string"}
4225                                        }
4226                                    }
4227                                }
4228                            }
4229                        },
4230                        "responses": {"200": {"description": "OK"}}
4231                    }
4232                }
4233            }
4234        });
4235        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4236        let registry = OpenApiRouteRegistry::new(spec);
4237
4238        // Should fail validation when body doesn't match schema (lines 1038-1049)
4239        let invalid_body = json!({}); // Missing required "name" field
4240        let result = registry.validate_request_with_all(
4241            "/users",
4242            "POST",
4243            &Map::new(),
4244            &Map::new(),
4245            &Map::new(),
4246            &Map::new(),
4247            Some(&invalid_body),
4248        );
4249        assert!(result.is_err());
4250    }
4251
4252    #[test]
4253    fn test_validate_request_body_referenced_schema_error() {
4254        // Test request body with referenced schema that can't be resolved (lines 1070-1076)
4255        let spec_json = json!({
4256            "openapi": "3.0.0",
4257            "info": {"title": "Test API", "version": "1.0.0"},
4258            "paths": {
4259                "/users": {
4260                    "post": {
4261                        "requestBody": {
4262                            "required": true,
4263                            "content": {
4264                                "application/json": {
4265                                    "schema": {
4266                                        "$ref": "#/components/schemas/NonExistentSchema"
4267                                    }
4268                                }
4269                            }
4270                        },
4271                        "responses": {"200": {"description": "OK"}}
4272                    }
4273                }
4274            },
4275            "components": {}
4276        });
4277        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4278        let registry = OpenApiRouteRegistry::new(spec);
4279
4280        // Should fail validation when schema reference can't be resolved (lines 1070-1076)
4281        let body = json!({"name": "test"});
4282        let result = registry.validate_request_with_all(
4283            "/users",
4284            "POST",
4285            &Map::new(),
4286            &Map::new(),
4287            &Map::new(),
4288            &Map::new(),
4289            Some(&body),
4290        );
4291        assert!(result.is_err());
4292    }
4293
4294    #[test]
4295    fn test_validate_request_body_referenced_request_body_error() {
4296        // Test request body with referenced request body that can't be resolved (lines 1081-1087)
4297        let spec_json = json!({
4298            "openapi": "3.0.0",
4299            "info": {"title": "Test API", "version": "1.0.0"},
4300            "paths": {
4301                "/users": {
4302                    "post": {
4303                        "requestBody": {
4304                            "$ref": "#/components/requestBodies/NonExistentRequestBody"
4305                        },
4306                        "responses": {"200": {"description": "OK"}}
4307                    }
4308                }
4309            },
4310            "components": {}
4311        });
4312        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4313        let registry = OpenApiRouteRegistry::new(spec);
4314
4315        // Should fail validation when request body reference can't be resolved (lines 1081-1087)
4316        let body = json!({"name": "test"});
4317        let result = registry.validate_request_with_all(
4318            "/users",
4319            "POST",
4320            &Map::new(),
4321            &Map::new(),
4322            &Map::new(),
4323            &Map::new(),
4324            Some(&body),
4325        );
4326        assert!(result.is_err());
4327    }
4328
4329    #[test]
4330    fn test_validate_request_body_provided_when_not_expected() {
4331        // Test body provided when not expected (lines 1092-1094)
4332        let spec_json = json!({
4333            "openapi": "3.0.0",
4334            "info": {"title": "Test API", "version": "1.0.0"},
4335            "paths": {
4336                "/users": {
4337                    "get": {
4338                        "responses": {"200": {"description": "OK"}}
4339                    }
4340                }
4341            }
4342        });
4343        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4344        let registry = OpenApiRouteRegistry::new(spec);
4345
4346        // Should accept body even when not expected (lines 1092-1094)
4347        let body = json!({"extra": "data"});
4348        let result = registry.validate_request_with_all(
4349            "/users",
4350            "GET",
4351            &Map::new(),
4352            &Map::new(),
4353            &Map::new(),
4354            &Map::new(),
4355            Some(&body),
4356        );
4357        // Should not error - just logs debug message
4358        assert!(result.is_ok());
4359    }
4360
4361    #[test]
4362    fn test_get_operation() {
4363        // Test get_operation method (lines 1196-1205)
4364        let spec_json = json!({
4365            "openapi": "3.0.0",
4366            "info": {"title": "Test API", "version": "1.0.0"},
4367            "paths": {
4368                "/users": {
4369                    "get": {
4370                        "operationId": "getUsers",
4371                        "summary": "Get users",
4372                        "responses": {"200": {"description": "OK"}}
4373                    }
4374                }
4375            }
4376        });
4377        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4378        let registry = OpenApiRouteRegistry::new(spec);
4379
4380        // Should return operation details (lines 1196-1205)
4381        let operation = registry.get_operation("/users", "GET");
4382        assert!(operation.is_some());
4383        assert_eq!(operation.unwrap().method, "GET");
4384
4385        // Should return None for non-existent route
4386        assert!(registry.get_operation("/nonexistent", "GET").is_none());
4387    }
4388
4389    #[test]
4390    fn test_extract_path_parameters() {
4391        // Test extract_path_parameters method (lines 1208-1223)
4392        let spec_json = json!({
4393            "openapi": "3.0.0",
4394            "info": {"title": "Test API", "version": "1.0.0"},
4395            "paths": {
4396                "/users/{id}": {
4397                    "get": {
4398                        "parameters": [
4399                            {
4400                                "name": "id",
4401                                "in": "path",
4402                                "required": true,
4403                                "schema": {"type": "string"}
4404                            }
4405                        ],
4406                        "responses": {"200": {"description": "OK"}}
4407                    }
4408                }
4409            }
4410        });
4411        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4412        let registry = OpenApiRouteRegistry::new(spec);
4413
4414        // Should extract path parameters (lines 1208-1223)
4415        let params = registry.extract_path_parameters("/users/123", "GET");
4416        assert_eq!(params.get("id"), Some(&"123".to_string()));
4417
4418        // Should return empty map for non-matching path
4419        let empty_params = registry.extract_path_parameters("/users", "GET");
4420        assert!(empty_params.is_empty());
4421    }
4422
4423    #[test]
4424    fn extract_path_parameters_prefers_static_route_and_rejects_empty() {
4425        // #757: a literal route must win over a same-arity `{param}` route, and
4426        // a `{param}` must not capture an empty (trailing-slash) segment.
4427        let spec_json = json!({
4428            "openapi": "3.0.0",
4429            "info": {"title": "Test API", "version": "1.0.0"},
4430            "paths": {
4431                "/users/{id}": { "get": { "responses": {"200": {"description": "OK"}} } },
4432                "/users/me":   { "get": { "responses": {"200": {"description": "OK"}} } }
4433            }
4434        });
4435        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4436        let registry = OpenApiRouteRegistry::new(spec);
4437
4438        // Literal `/users/me` wins over `/users/{id}` → no `id` captured.
4439        let me = registry.extract_path_parameters("/users/me", "GET");
4440        assert!(!me.contains_key("id"), "literal route should win, got {me:?}");
4441
4442        // A real id still matches the parameter route.
4443        let by_id = registry.extract_path_parameters("/users/123", "GET");
4444        assert_eq!(by_id.get("id"), Some(&"123".to_string()));
4445
4446        // Trailing slash must NOT bind `{id}` to an empty value.
4447        let trailing = registry.extract_path_parameters("/users/", "GET");
4448        assert!(
4449            trailing.is_empty(),
4450            "empty trailing segment should not bind id, got {trailing:?}"
4451        );
4452    }
4453
4454    #[test]
4455    fn test_extract_path_parameters_multiple_params() {
4456        // Test extract_path_parameters with multiple path parameters
4457        let spec_json = json!({
4458            "openapi": "3.0.0",
4459            "info": {"title": "Test API", "version": "1.0.0"},
4460            "paths": {
4461                "/users/{userId}/posts/{postId}": {
4462                    "get": {
4463                        "parameters": [
4464                            {
4465                                "name": "userId",
4466                                "in": "path",
4467                                "required": true,
4468                                "schema": {"type": "string"}
4469                            },
4470                            {
4471                                "name": "postId",
4472                                "in": "path",
4473                                "required": true,
4474                                "schema": {"type": "string"}
4475                            }
4476                        ],
4477                        "responses": {"200": {"description": "OK"}}
4478                    }
4479                }
4480            }
4481        });
4482        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4483        let registry = OpenApiRouteRegistry::new(spec);
4484
4485        // Should extract multiple path parameters
4486        let params = registry.extract_path_parameters("/users/123/posts/456", "GET");
4487        assert_eq!(params.get("userId"), Some(&"123".to_string()));
4488        assert_eq!(params.get("postId"), Some(&"456".to_string()));
4489    }
4490
4491    #[test]
4492    fn test_validate_request_route_not_found() {
4493        // Test validation when route not found (lines 1171-1173)
4494        let spec_json = json!({
4495            "openapi": "3.0.0",
4496            "info": {"title": "Test API", "version": "1.0.0"},
4497            "paths": {
4498                "/users": {
4499                    "get": {
4500                        "responses": {"200": {"description": "OK"}}
4501                    }
4502                }
4503            }
4504        });
4505        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4506        let registry = OpenApiRouteRegistry::new(spec);
4507
4508        // Should return error when route not found (lines 1171-1173)
4509        let result = registry.validate_request_with_all(
4510            "/nonexistent",
4511            "GET",
4512            &Map::new(),
4513            &Map::new(),
4514            &Map::new(),
4515            &Map::new(),
4516            None,
4517        );
4518        assert!(result.is_err());
4519        assert!(result.unwrap_err().to_string().contains("not found"));
4520    }
4521
4522    #[test]
4523    fn test_validate_request_with_path_parameters() {
4524        // Test path parameter validation (lines 1101-1110)
4525        let spec_json = json!({
4526            "openapi": "3.0.0",
4527            "info": {"title": "Test API", "version": "1.0.0"},
4528            "paths": {
4529                "/users/{id}": {
4530                    "get": {
4531                        "parameters": [
4532                            {
4533                                "name": "id",
4534                                "in": "path",
4535                                "required": true,
4536                                "schema": {"type": "string", "minLength": 1}
4537                            }
4538                        ],
4539                        "responses": {"200": {"description": "OK"}}
4540                    }
4541                }
4542            }
4543        });
4544        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4545        let registry = OpenApiRouteRegistry::new(spec);
4546
4547        // Should pass validation with valid path parameter
4548        let mut path_params = Map::new();
4549        path_params.insert("id".to_string(), json!("123"));
4550        let result = registry.validate_request_with_all(
4551            "/users/{id}",
4552            "GET",
4553            &path_params,
4554            &Map::new(),
4555            &Map::new(),
4556            &Map::new(),
4557            None,
4558        );
4559        assert!(result.is_ok());
4560    }
4561
4562    #[test]
4563    fn test_validate_request_with_query_parameters() {
4564        // Test query parameter validation (lines 1111-1134)
4565        let spec_json = json!({
4566            "openapi": "3.0.0",
4567            "info": {"title": "Test API", "version": "1.0.0"},
4568            "paths": {
4569                "/users": {
4570                    "get": {
4571                        "parameters": [
4572                            {
4573                                "name": "page",
4574                                "in": "query",
4575                                "required": true,
4576                                "schema": {"type": "integer", "minimum": 1}
4577                            }
4578                        ],
4579                        "responses": {"200": {"description": "OK"}}
4580                    }
4581                }
4582            }
4583        });
4584        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4585        let registry = OpenApiRouteRegistry::new(spec);
4586
4587        // Should pass validation with valid query parameter
4588        let mut query_params = Map::new();
4589        query_params.insert("page".to_string(), json!(1));
4590        let result = registry.validate_request_with_all(
4591            "/users",
4592            "GET",
4593            &Map::new(),
4594            &query_params,
4595            &Map::new(),
4596            &Map::new(),
4597            None,
4598        );
4599        assert!(result.is_ok());
4600    }
4601
4602    #[test]
4603    fn test_validate_request_with_header_parameters() {
4604        // Test header parameter validation (lines 1135-1144)
4605        let spec_json = json!({
4606            "openapi": "3.0.0",
4607            "info": {"title": "Test API", "version": "1.0.0"},
4608            "paths": {
4609                "/users": {
4610                    "get": {
4611                        "parameters": [
4612                            {
4613                                "name": "X-API-Key",
4614                                "in": "header",
4615                                "required": true,
4616                                "schema": {"type": "string"}
4617                            }
4618                        ],
4619                        "responses": {"200": {"description": "OK"}}
4620                    }
4621                }
4622            }
4623        });
4624        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4625        let registry = OpenApiRouteRegistry::new(spec);
4626
4627        // Should pass validation with valid header parameter
4628        let mut header_params = Map::new();
4629        header_params.insert("X-API-Key".to_string(), json!("secret-key"));
4630        let result = registry.validate_request_with_all(
4631            "/users",
4632            "GET",
4633            &Map::new(),
4634            &Map::new(),
4635            &header_params,
4636            &Map::new(),
4637            None,
4638        );
4639        assert!(result.is_ok());
4640    }
4641
4642    #[test]
4643    fn test_validate_request_with_cookie_parameters() {
4644        // Test cookie parameter validation (lines 1145-1154)
4645        let spec_json = json!({
4646            "openapi": "3.0.0",
4647            "info": {"title": "Test API", "version": "1.0.0"},
4648            "paths": {
4649                "/users": {
4650                    "get": {
4651                        "parameters": [
4652                            {
4653                                "name": "sessionId",
4654                                "in": "cookie",
4655                                "required": true,
4656                                "schema": {"type": "string"}
4657                            }
4658                        ],
4659                        "responses": {"200": {"description": "OK"}}
4660                    }
4661                }
4662            }
4663        });
4664        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4665        let registry = OpenApiRouteRegistry::new(spec);
4666
4667        // Should pass validation with valid cookie parameter
4668        let mut cookie_params = Map::new();
4669        cookie_params.insert("sessionId".to_string(), json!("abc123"));
4670        let result = registry.validate_request_with_all(
4671            "/users",
4672            "GET",
4673            &Map::new(),
4674            &Map::new(),
4675            &Map::new(),
4676            &cookie_params,
4677            None,
4678        );
4679        assert!(result.is_ok());
4680    }
4681
4682    #[test]
4683    fn test_validate_request_no_errors_early_return() {
4684        // Test early return when no errors (lines 1158-1160)
4685        let spec_json = json!({
4686            "openapi": "3.0.0",
4687            "info": {"title": "Test API", "version": "1.0.0"},
4688            "paths": {
4689                "/users": {
4690                    "get": {
4691                        "responses": {"200": {"description": "OK"}}
4692                    }
4693                }
4694            }
4695        });
4696        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4697        let registry = OpenApiRouteRegistry::new(spec);
4698
4699        // Should return early when no errors (lines 1158-1160)
4700        let result = registry.validate_request_with_all(
4701            "/users",
4702            "GET",
4703            &Map::new(),
4704            &Map::new(),
4705            &Map::new(),
4706            &Map::new(),
4707            None,
4708        );
4709        assert!(result.is_ok());
4710    }
4711
4712    #[test]
4713    fn test_validate_request_query_parameter_different_styles() {
4714        // Test query parameter validation with different styles (lines 1118-1123)
4715        let spec_json = json!({
4716            "openapi": "3.0.0",
4717            "info": {"title": "Test API", "version": "1.0.0"},
4718            "paths": {
4719                "/users": {
4720                    "get": {
4721                        "parameters": [
4722                            {
4723                                "name": "tags",
4724                                "in": "query",
4725                                "style": "pipeDelimited",
4726                                "schema": {
4727                                    "type": "array",
4728                                    "items": {"type": "string"}
4729                                }
4730                            }
4731                        ],
4732                        "responses": {"200": {"description": "OK"}}
4733                    }
4734                }
4735            }
4736        });
4737        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4738        let registry = OpenApiRouteRegistry::new(spec);
4739
4740        // Should handle pipeDelimited style (lines 1118-1123)
4741        let mut query_params = Map::new();
4742        query_params.insert("tags".to_string(), json!(["tag1", "tag2"]));
4743        let result = registry.validate_request_with_all(
4744            "/users",
4745            "GET",
4746            &Map::new(),
4747            &query_params,
4748            &Map::new(),
4749            &Map::new(),
4750            None,
4751        );
4752        // Should not error on style handling
4753        assert!(result.is_ok() || result.is_err()); // Either is fine, just testing the path
4754    }
4755}