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                    inject_spec_response_headers(&mut response, &route_clone, selected_status);
1003                    return response;
1004                }
1005
1006                // (j) Return response (non-full-validation path)
1007                let mut response = Json(final_response).into_response();
1008                *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
1009                    .unwrap_or(axum::http::StatusCode::OK);
1010                inject_spec_response_headers(&mut response, &route_clone, selected_status);
1011                response
1012            };
1013
1014            router = Self::route_for_method(router, axum_path, &route.method, handler);
1015        }
1016
1017        // Add OpenAPI documentation endpoint if configured
1018        if ctx.add_spec_endpoint {
1019            let spec_json = serde_json::to_value(&self.spec.spec).unwrap_or(Value::Null);
1020            router = router.route("/openapi.json", get(move || async move { Json(spec_json) }));
1021        }
1022
1023        // Issue #79 — raise the body-size cap above axum's 2 MiB default so
1024        // large chunked uploads don't get truncated mid-extraction. See
1025        // `openapi_body_limit_bytes` for the rationale.
1026        router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
1027    }
1028
1029    /// Build an Axum router from the OpenAPI spec with latency injection support
1030    pub fn build_router_with_latency(self, latency_injector: LatencyInjector) -> Router {
1031        self.build_router_with_injectors(latency_injector, None)
1032    }
1033
1034    /// Build an Axum router from the OpenAPI spec with both latency and failure injection support
1035    pub fn build_router_with_injectors(
1036        self,
1037        latency_injector: LatencyInjector,
1038        failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1039    ) -> Router {
1040        self.build_router_with_injectors_and_overrides(
1041            latency_injector,
1042            failure_injector,
1043            None,
1044            false,
1045        )
1046    }
1047
1048    /// Build an Axum router from the OpenAPI spec with latency, failure
1049    /// injection, and a response-rewriter hook (typically wrapping
1050    /// core's `Overrides` + `templating::expand_tokens`).
1051    pub fn build_router_with_injectors_and_overrides(
1052        self,
1053        latency_injector: LatencyInjector,
1054        failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1055        response_rewriter: Option<Arc<dyn ResponseRewriter>>,
1056        overrides_enabled: bool,
1057    ) -> Router {
1058        let ctx = RouterContext {
1059            custom_fixture_loader: self.custom_fixture_loader.clone(),
1060            latency_injector: Some(latency_injector),
1061            failure_injector,
1062            response_rewriter,
1063            overrides_enabled,
1064            enable_full_validation: true,
1065            enable_template_expand: true,
1066            add_spec_endpoint: true,
1067            ..Default::default()
1068        };
1069        self.build_router_with_context(ctx)
1070    }
1071
1072    /// Get route by path and method
1073    pub fn get_route(&self, path: &str, method: &str) -> Option<&OpenApiRoute> {
1074        self.routes.iter().find(|route| route.path == path && route.method == method)
1075    }
1076
1077    /// Get all routes for a specific path
1078    pub fn get_routes_for_path(&self, path: &str) -> Vec<&OpenApiRoute> {
1079        self.routes.iter().filter(|route| route.path == path).collect()
1080    }
1081
1082    /// Validate request against OpenAPI spec (legacy body-only)
1083    pub fn validate_request(&self, path: &str, method: &str, body: Option<&Value>) -> Result<()> {
1084        self.validate_request_with(path, method, &Map::new(), &Map::new(), body)
1085    }
1086
1087    /// Round 28 — Srikanth's content-type-mismatch finding on 0.3.171:
1088    /// the bench-side probe was correctly sending `Content-Type:
1089    /// application/xml` against a JSON-only endpoint, but mockforge's
1090    /// server-side conformance validator was accepting it because the
1091    /// existing path checked the BODY against the JSON schema directly
1092    /// (ignoring the actual Content-Type header). This method gives
1093    /// the route handler a way to flag Content-Type mismatches before
1094    /// the body validation runs.
1095    ///
1096    /// Returns `Err(message)` when the operation's request body
1097    /// declares one or more `content` keys AND the actual
1098    /// Content-Type doesn't match any of them; `Ok(())` otherwise
1099    /// (no requestBody declared, no Content-Type sent, or a match
1100    /// found). The comparison is type/subtype only (parameters like
1101    /// `; charset=...` and `; boundary=...` are stripped).
1102    pub fn check_request_content_type(
1103        &self,
1104        path: &str,
1105        method: &str,
1106        actual_content_type: Option<&str>,
1107    ) -> std::result::Result<(), String> {
1108        let Some(route) = self.get_route(path, method) else {
1109            return Ok(());
1110        };
1111        let Some(rb_ref) = &route.operation.request_body else {
1112            return Ok(());
1113        };
1114        let request_body = match rb_ref {
1115            openapiv3::ReferenceOr::Item(rb) => rb,
1116            openapiv3::ReferenceOr::Reference { reference } => {
1117                let resolved = self
1118                    .spec
1119                    .spec
1120                    .components
1121                    .as_ref()
1122                    .and_then(|components| {
1123                        components
1124                            .request_bodies
1125                            .get(reference.trim_start_matches("#/components/requestBodies/"))
1126                    })
1127                    .and_then(|rb_ref| rb_ref.as_item());
1128                let Some(rb) = resolved else { return Ok(()) };
1129                rb
1130            }
1131        };
1132        if request_body.content.is_empty() {
1133            return Ok(());
1134        }
1135        let actual = actual_content_type
1136            .and_then(|s| s.split(';').next())
1137            .map(|s| s.trim().to_ascii_lowercase());
1138        let Some(actual) = actual else {
1139            // No Content-Type sent at all. If a body is required and
1140            // content is declared, the body validator catches it via
1141            // a different path; we don't flag here so that
1142            // bodyless-but-required cases don't double-report.
1143            return Ok(());
1144        };
1145        let allowed: Vec<String> = request_body
1146            .content
1147            .keys()
1148            .map(|k| k.split(';').next().unwrap_or(k).trim().to_ascii_lowercase())
1149            .collect();
1150        if allowed.iter().any(|a| a == &actual) {
1151            return Ok(());
1152        }
1153        Err(format!(
1154            "Content-Type '{actual}' not allowed; spec declares: [{}]",
1155            allowed.join(", ")
1156        ))
1157    }
1158
1159    /// Validate request against OpenAPI spec with path/query params
1160    pub fn validate_request_with(
1161        &self,
1162        path: &str,
1163        method: &str,
1164        path_params: &Map<String, Value>,
1165        query_params: &Map<String, Value>,
1166        body: Option<&Value>,
1167    ) -> Result<()> {
1168        self.validate_request_with_all(
1169            path,
1170            method,
1171            path_params,
1172            query_params,
1173            &Map::new(),
1174            &Map::new(),
1175            body,
1176        )
1177    }
1178
1179    /// Issue #79 round 13 — run the standard request-validation bookend
1180    /// (validate → build error payload → record to the conformance ring
1181    /// buffer) and return `Ok(())` if validation passed, or
1182    /// `Err((status_code, payload))` if it failed. Centralises the logic
1183    /// that previously lived inline at `build_router_with_context`
1184    /// (line ~686) so the MockAI and AI handlers can share it instead
1185    /// of silently bypassing validation.
1186    ///
1187    /// Callers should short-circuit with the returned status + payload
1188    /// on `Err`; the violation has already been recorded to
1189    /// `mockforge_foundation::conformance_violations` by the time this
1190    /// function returns.
1191    #[allow(clippy::too_many_arguments)]
1192    pub fn run_validation_with_recording(
1193        &self,
1194        path_template: &str,
1195        method: &str,
1196        path_params: &Map<String, Value>,
1197        query_params: &Map<String, Value>,
1198        header_map: &Map<String, Value>,
1199        cookie_map: &Map<String, Value>,
1200        body: Option<&Value>,
1201    ) -> std::result::Result<(), (u16, Value)> {
1202        let e = match self.validate_request_with_all(
1203            path_template,
1204            method,
1205            path_params,
1206            query_params,
1207            header_map,
1208            cookie_map,
1209            body,
1210        ) {
1211            Ok(()) => {
1212                // Round 17.1 — track conformant requests alongside
1213                // violations so the TUI can show the real pass/fail
1214                // ratio (Srikanth's (f) follow-up).
1215                mockforge_foundation::conformance_violations::record_ok();
1216                return Ok(());
1217            }
1218            Err(e) => e,
1219        };
1220
1221        let status_code = self.options.validation_status.unwrap_or_else(|| {
1222            std::env::var("MOCKFORGE_VALIDATION_STATUS")
1223                .ok()
1224                .and_then(|s| s.parse::<u16>().ok())
1225                .unwrap_or(400)
1226        });
1227
1228        let payload = if status_code == 422 {
1229            generate_enhanced_422_response(
1230                self,
1231                path_template,
1232                method,
1233                body,
1234                path_params,
1235                query_params,
1236                header_map,
1237                cookie_map,
1238            )
1239        } else {
1240            let msg = format!("{}", e);
1241            let detail_val = serde_json::from_str::<Value>(&msg).unwrap_or(serde_json::json!(msg));
1242            json!({
1243                "error": "request validation failed",
1244                "detail": detail_val,
1245                "method": method,
1246                "path": path_template,
1247                "timestamp": Utc::now().to_rfc3339(),
1248            })
1249        };
1250
1251        record_validation_error(&payload);
1252
1253        let reason = payload
1254            .get("detail")
1255            .and_then(|d| {
1256                if d.is_string() {
1257                    d.as_str().map(|s| s.to_string())
1258                } else {
1259                    serde_json::to_string(d).ok()
1260                }
1261            })
1262            .unwrap_or_else(|| {
1263                payload
1264                    .get("error")
1265                    .and_then(|v| v.as_str())
1266                    .unwrap_or("request validation failed")
1267                    .to_string()
1268            });
1269        let category = classify_validation_reason(&reason);
1270        // Issue #79 round 15 — Srikanth asked for server-side logs of
1271        // *why* a request was a violation. Emit one line per violation
1272        // under a dedicated target so it can be enabled precisely
1273        // (`RUST_LOG=mockforge::conformance=debug`) without turning on
1274        // firehose debug logging. DEBUG (not WARN) so it doesn't spam
1275        // the default log under load — the aggregate is in the
1276        // Conformance tab / API; this is the opt-in detail channel.
1277        tracing::debug!(
1278            target: "mockforge::conformance",
1279            method = %method,
1280            path = %path_template,
1281            status = status_code,
1282            category = %category,
1283            reason = %reason,
1284            "request conformance violation"
1285        );
1286        let (client_mockforge_version, client_sent_at) =
1287            mockforge_foundation::conformance_violations::read_client_stamps(|name| {
1288                header_map
1289                    .iter()
1290                    .find(|(k, _)| k.eq_ignore_ascii_case(name))
1291                    .and_then(|(_, v)| v.as_str().map(|s| s.to_string()))
1292            });
1293        mockforge_foundation::conformance_violations::record(
1294            mockforge_foundation::conformance_violations::ServerConformanceViolation {
1295                timestamp: Utc::now(),
1296                method: method.to_string(),
1297                path: path_template.to_string(),
1298                client_ip: "unknown".to_string(),
1299                status: status_code,
1300                reason,
1301                category,
1302                occurrences: 1,
1303                client_mockforge_version,
1304                client_sent_at,
1305            },
1306        );
1307
1308        // Issue #79 round 14 — shadow mode: the violation is recorded
1309        // above (so the Conformance tab still shows it, with its real
1310        // 400/422 classification), but we return Ok so the handler
1311        // proceeds to synthesise a normal 2xx response. Lets a proxy
1312        // replay run flow through non-blocking while capturing every
1313        // violation.
1314        if mockforge_foundation::unknown_paths::shadow_mode_enabled() {
1315            return Ok(());
1316        }
1317
1318        Err((status_code, payload))
1319    }
1320
1321    /// Validate request against OpenAPI spec with path/query/header/cookie params
1322    #[allow(clippy::too_many_arguments)]
1323    pub fn validate_request_with_all(
1324        &self,
1325        path: &str,
1326        method: &str,
1327        path_params: &Map<String, Value>,
1328        query_params: &Map<String, Value>,
1329        header_params: &Map<String, Value>,
1330        cookie_params: &Map<String, Value>,
1331        body: Option<&Value>,
1332    ) -> Result<()> {
1333        // Skip validation for any configured admin prefixes
1334        for pref in &self.options.admin_skip_prefixes {
1335            if !pref.is_empty() && path.starts_with(pref) {
1336                return Ok(());
1337            }
1338        }
1339        // Runtime env overrides
1340        let env_mode = std::env::var("MOCKFORGE_REQUEST_VALIDATION").ok().map(|v| {
1341            match v.to_ascii_lowercase().as_str() {
1342                "off" | "disable" | "disabled" => ValidationMode::Disabled,
1343                "warn" | "warning" => ValidationMode::Warn,
1344                _ => ValidationMode::Enforce,
1345            }
1346        });
1347        let aggregate = std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
1348            .ok()
1349            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1350            .unwrap_or(self.options.aggregate_errors);
1351        // Per-route runtime overrides via JSON env var
1352        let env_overrides: Option<Map<String, Value>> =
1353            std::env::var("MOCKFORGE_VALIDATION_OVERRIDES_JSON")
1354                .ok()
1355                .and_then(|s| serde_json::from_str::<Value>(&s).ok())
1356                .and_then(|v| v.as_object().cloned());
1357        // Response validation is handled in HTTP layer now
1358        let mut effective_mode = env_mode.unwrap_or(self.options.request_mode.clone());
1359        // Apply runtime overrides first if present
1360        if let Some(map) = &env_overrides {
1361            if let Some(v) = map.get(&format!("{} {}", method, path)) {
1362                if let Some(m) = v.as_str() {
1363                    effective_mode = match m {
1364                        "off" => ValidationMode::Disabled,
1365                        "warn" => ValidationMode::Warn,
1366                        _ => ValidationMode::Enforce,
1367                    };
1368                }
1369            }
1370        }
1371        // Then static options overrides
1372        if let Some(override_mode) = self.options.overrides.get(&format!("{} {}", method, path)) {
1373            effective_mode = override_mode.clone();
1374        }
1375        if matches!(effective_mode, ValidationMode::Disabled) {
1376            return Ok(());
1377        }
1378        if let Some(route) = self.get_route(path, method) {
1379            if matches!(effective_mode, ValidationMode::Disabled) {
1380                return Ok(());
1381            }
1382            let mut errors: Vec<String> = Vec::new();
1383            let mut details: Vec<Value> = Vec::new();
1384            // Validate request body if required
1385            if let Some(schema) = &route.operation.request_body {
1386                if let Some(value) = body {
1387                    // First resolve the request body reference if it's a reference
1388                    let request_body = match schema {
1389                        openapiv3::ReferenceOr::Item(rb) => Some(rb),
1390                        openapiv3::ReferenceOr::Reference { reference } => {
1391                            // Try to resolve request body reference through spec
1392                            self.spec
1393                                .spec
1394                                .components
1395                                .as_ref()
1396                                .and_then(|components| {
1397                                    components.request_bodies.get(
1398                                        reference.trim_start_matches("#/components/requestBodies/"),
1399                                    )
1400                                })
1401                                .and_then(|rb_ref| rb_ref.as_item())
1402                        }
1403                    };
1404
1405                    if let Some(rb) = request_body {
1406                        if let Some(content) = rb.content.get("application/json") {
1407                            if let Some(schema_ref) = &content.schema {
1408                                // Issue #79 round 19 — every body validator on
1409                                // this path used `OpenApiSchema::new(...).validate()`
1410                                // which builds a naked jsonschema validator with
1411                                // no `components` context. Nested `$ref` strings
1412                                // to `#/components/schemas/X` (especially
1413                                // dotted vCenter names) then fail with
1414                                // "Pointer does not exist". Round 18.3 fixed
1415                                // the bench-side + the `validate_request_body`
1416                                // sibling; this is the live-server route
1417                                // handler, the third path. Switch to
1418                                // `schema_ref_resolver::build_validator` which
1419                                // inlines the spec's components.
1420                                let root_schema = match schema_ref {
1421                                    openapiv3::ReferenceOr::Item(s) => Some((*s).clone()),
1422                                    openapiv3::ReferenceOr::Reference { reference } => {
1423                                        self.spec.get_schema(reference).map(|s| s.schema.clone())
1424                                    }
1425                                };
1426                                if let Some(root_schema) = root_schema {
1427                                    let result = crate::schema_ref_resolver::build_validator(
1428                                        &root_schema,
1429                                        &self.spec.spec,
1430                                    )
1431                                    .and_then(|validator| {
1432                                        let errs: Vec<String> = validator
1433                                            .iter_errors(value)
1434                                            .map(|e| e.to_string())
1435                                            .collect();
1436                                        if errs.is_empty() {
1437                                            Ok(())
1438                                        } else {
1439                                            Err(errs.join("; "))
1440                                        }
1441                                    });
1442                                    if let Err(error_msg) = result {
1443                                        errors
1444                                            .push(format!("body validation failed: {}", error_msg));
1445                                        if aggregate {
1446                                            details.push(serde_json::json!({"path":"body","code":"schema_validation","message":error_msg}));
1447                                        }
1448                                    }
1449                                } else if let openapiv3::ReferenceOr::Reference { reference } =
1450                                    schema_ref
1451                                {
1452                                    // Schema reference couldn't be resolved
1453                                    errors.push(format!("body validation failed: could not resolve schema reference {}", reference));
1454                                    if aggregate {
1455                                        details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve schema reference"}));
1456                                    }
1457                                }
1458                            }
1459                        }
1460                    } else {
1461                        // Request body reference couldn't be resolved or no application/json content
1462                        errors.push("body validation failed: could not resolve request body or no application/json content".to_string());
1463                        if aggregate {
1464                            details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve request body reference"}));
1465                        }
1466                    }
1467                } else {
1468                    errors.push("body: Request body is required but not provided".to_string());
1469                    details.push(serde_json::json!({"path":"body","code":"required","message":"Request body is required"}));
1470                }
1471            } else if body.is_some() {
1472                // No body expected but provided — not an error by default, but log it
1473                tracing::debug!("Body provided for operation without requestBody; accepting");
1474            }
1475
1476            // Validate path/query parameters
1477            for p_ref in &route.operation.parameters {
1478                if let Some(p) = p_ref.as_item() {
1479                    match p {
1480                        openapiv3::Parameter::Path { parameter_data, .. } => {
1481                            validate_parameter(
1482                                parameter_data,
1483                                path_params,
1484                                "path",
1485                                aggregate,
1486                                &mut errors,
1487                                &mut details,
1488                            );
1489                        }
1490                        openapiv3::Parameter::Query {
1491                            parameter_data,
1492                            style,
1493                            ..
1494                        } => {
1495                            // For deepObject style, reconstruct nested value from keys like name[prop]
1496                            // e.g., filter[name]=John&filter[age]=30 -> {"name":"John","age":"30"}
1497                            let deep_value = if matches!(style, openapiv3::QueryStyle::DeepObject) {
1498                                let prefix_bracket = format!("{}[", parameter_data.name);
1499                                let mut obj = Map::new();
1500                                for (key, val) in query_params.iter() {
1501                                    if let Some(rest) = key.strip_prefix(&prefix_bracket) {
1502                                        if let Some(prop) = rest.strip_suffix(']') {
1503                                            obj.insert(prop.to_string(), val.clone());
1504                                        }
1505                                    }
1506                                }
1507                                if obj.is_empty() {
1508                                    None
1509                                } else {
1510                                    Some(Value::Object(obj))
1511                                }
1512                            } else {
1513                                None
1514                            };
1515                            let style_str = match style {
1516                                openapiv3::QueryStyle::Form => Some("form"),
1517                                openapiv3::QueryStyle::SpaceDelimited => Some("spaceDelimited"),
1518                                openapiv3::QueryStyle::PipeDelimited => Some("pipeDelimited"),
1519                                openapiv3::QueryStyle::DeepObject => Some("deepObject"),
1520                            };
1521                            validate_parameter_with_deep_object(
1522                                parameter_data,
1523                                query_params,
1524                                "query",
1525                                deep_value,
1526                                style_str,
1527                                aggregate,
1528                                &mut errors,
1529                                &mut details,
1530                            );
1531                        }
1532                        openapiv3::Parameter::Header { parameter_data, .. } => {
1533                            validate_parameter(
1534                                parameter_data,
1535                                header_params,
1536                                "header",
1537                                aggregate,
1538                                &mut errors,
1539                                &mut details,
1540                            );
1541                        }
1542                        openapiv3::Parameter::Cookie { parameter_data, .. } => {
1543                            validate_parameter(
1544                                parameter_data,
1545                                cookie_params,
1546                                "cookie",
1547                                aggregate,
1548                                &mut errors,
1549                                &mut details,
1550                            );
1551                        }
1552                    }
1553                }
1554            }
1555            if errors.is_empty() {
1556                return Ok(());
1557            }
1558            match effective_mode {
1559                ValidationMode::Disabled => Ok(()),
1560                ValidationMode::Warn => {
1561                    tracing::warn!("Request validation warnings: {:?}", errors);
1562                    Ok(())
1563                }
1564                ValidationMode::Enforce => Err(Error::validation(
1565                    serde_json::json!({"errors": errors, "details": details}).to_string(),
1566                )),
1567            }
1568        } else {
1569            Err(Error::internal(format!("Route {} {} not found in OpenAPI spec", method, path)))
1570        }
1571    }
1572
1573    // Legacy helper removed (mock + status selection happens in handler via route.mock_response_with_status)
1574
1575    /// Get all paths defined in the spec
1576    pub fn paths(&self) -> Vec<String> {
1577        let mut paths: Vec<String> = self.routes.iter().map(|route| route.path.clone()).collect();
1578        paths.sort();
1579        paths.dedup();
1580        paths
1581    }
1582
1583    /// Get all HTTP methods supported
1584    pub fn methods(&self) -> Vec<String> {
1585        let mut methods: Vec<String> =
1586            self.routes.iter().map(|route| route.method.clone()).collect();
1587        methods.sort();
1588        methods.dedup();
1589        methods
1590    }
1591
1592    /// Get operation details for a route
1593    pub fn get_operation(&self, path: &str, method: &str) -> Option<OpenApiOperation> {
1594        self.get_route(path, method).map(|route| {
1595            OpenApiOperation::from_operation(
1596                &route.method,
1597                route.path.clone(),
1598                &route.operation,
1599                &self.spec,
1600            )
1601        })
1602    }
1603
1604    /// Extract path parameters from a request path by matching against known routes
1605    pub fn extract_path_parameters(&self, path: &str, method: &str) -> HashMap<String, String> {
1606        // Among all routes that match, prefer the most *specific* — the one with
1607        // the most static (non-parameter) segments — so an exact literal route
1608        // wins over a same-arity `{param}` route regardless of declaration order
1609        // (e.g. `/users/me` matches `/users/me`, not `/users/{id}`). Returning
1610        // the first match meant spec order silently decided this (#757).
1611        let mut best: Option<(usize, HashMap<String, String>)> = None;
1612        for route in &self.routes {
1613            if route.method != method {
1614                continue;
1615            }
1616
1617            if let Some(params) = self.match_path_to_route(path, &route.path) {
1618                let static_segments = route
1619                    .path
1620                    .trim_start_matches('/')
1621                    .split('/')
1622                    .filter(|s| !(s.starts_with('{') && s.ends_with('}')))
1623                    .count();
1624                let is_more_specific = match &best {
1625                    None => true,
1626                    Some((score, _)) => static_segments > *score,
1627                };
1628                if is_more_specific {
1629                    best = Some((static_segments, params));
1630                }
1631            }
1632        }
1633        best.map(|(_, params)| params).unwrap_or_default()
1634    }
1635
1636    /// Match a request path against a route pattern and extract parameters
1637    fn match_path_to_route(
1638        &self,
1639        request_path: &str,
1640        route_pattern: &str,
1641    ) -> Option<HashMap<String, String>> {
1642        let mut params = HashMap::new();
1643
1644        // Split both paths into segments
1645        let request_segments: Vec<&str> = request_path.trim_start_matches('/').split('/').collect();
1646        let pattern_segments: Vec<&str> =
1647            route_pattern.trim_start_matches('/').split('/').collect();
1648
1649        if request_segments.len() != pattern_segments.len() {
1650            return None;
1651        }
1652
1653        for (req_seg, pat_seg) in request_segments.iter().zip(pattern_segments.iter()) {
1654            if pat_seg.starts_with('{') && pat_seg.ends_with('}') {
1655                // This is a parameter. Reject an *empty* captured segment (e.g.
1656                // a trailing slash: `/users/` would otherwise match
1657                // `/users/{id}` with id=""), which downstream treats as a real
1658                // value (#757).
1659                if req_seg.is_empty() {
1660                    return None;
1661                }
1662                let param_name = &pat_seg[1..pat_seg.len() - 1];
1663                params.insert(param_name.to_string(), req_seg.to_string());
1664            } else if req_seg != pat_seg {
1665                // Static segment doesn't match
1666                return None;
1667            }
1668        }
1669
1670        Some(params)
1671    }
1672
1673    /// Convert OpenAPI path to Axum-compatible path
1674    /// This is a utility function for converting path parameters from {param} to :param format
1675    pub fn convert_path_to_axum(openapi_path: &str) -> String {
1676        // Axum v0.7+ uses {param} format, same as OpenAPI
1677        openapi_path.to_string()
1678    }
1679
1680    /// Build router with AI generator support
1681    pub fn build_router_with_ai(
1682        &self,
1683        ai_generator: Option<Arc<dyn AiGenerator + Send + Sync>>,
1684    ) -> Router {
1685        let mut router = Router::new();
1686        let deduped = self.deduplicated_routes();
1687        tracing::debug!("Building router with AI support from {} routes", self.routes.len());
1688
1689        // Issue #79 round 14 hotfix — one shared validator (Arc) instead
1690        // of a per-route deep clone. See `build_router_with_context` for
1691        // the O(N²)/OOM rationale.
1692        let validator = Arc::new(self.clone_for_validation());
1693        for (axum_path, route) in &deduped {
1694            tracing::debug!("Adding AI-enabled route: {} {}", route.method, route.path);
1695
1696            let route_clone = (*route).clone();
1697            let ai_generator_clone = ai_generator.clone();
1698            // Issue #79 round 13 — same validation bypass as
1699            // `build_router_with_mockai`. Run validation before AI
1700            // response generation; the validator is shared via Arc.
1701            let validator_clone = validator.clone();
1702
1703            // Create async handler that extracts request data and builds context
1704            let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
1705                                axum::extract::Query(query_params): axum::extract::Query<
1706                HashMap<String, String>,
1707            >,
1708                                headers: HeaderMap,
1709                                body: Option<Json<Value>>| {
1710                let route = route_clone.clone();
1711                let ai_generator = ai_generator_clone.clone();
1712                let validator = validator_clone.clone();
1713
1714                async move {
1715                    // (a-pre) Run request validation against the spec
1716                    // before AI response synthesis. On failure this
1717                    // also records to the foundation conformance ring
1718                    // buffer surfaced by the TUI Conformance tab.
1719                    let mut path_map = Map::new();
1720                    for (k, v) in &path_params {
1721                        path_map.insert(k.clone(), Value::String(v.clone()));
1722                    }
1723                    let mut query_map = Map::new();
1724                    for (k, v) in &query_params {
1725                        query_map.insert(k.clone(), Value::String(v.clone()));
1726                    }
1727                    let mut header_map = Map::new();
1728                    for (k, v) in headers.iter() {
1729                        if let Ok(s) = v.to_str() {
1730                            header_map.insert(k.to_string(), Value::String(s.to_string()));
1731                        }
1732                    }
1733                    let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
1734                    if let Err((status_code, payload)) = validator.run_validation_with_recording(
1735                        &route.path,
1736                        &route.method,
1737                        &path_map,
1738                        &query_map,
1739                        &header_map,
1740                        &Map::new(),
1741                        body_val,
1742                    ) {
1743                        let status = axum::http::StatusCode::from_u16(status_code)
1744                            .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
1745                        return (status, Json(payload));
1746                    }
1747
1748                    tracing::debug!(
1749                        "Handling AI request for route: {} {}",
1750                        route.method,
1751                        route.path
1752                    );
1753
1754                    // Build request context
1755                    let mut context = RequestContext::new(route.method.clone(), route.path.clone());
1756
1757                    // Extract headers
1758                    context.headers = headers
1759                        .iter()
1760                        .map(|(k, v)| {
1761                            (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string()))
1762                        })
1763                        .collect();
1764
1765                    // Extract body if present
1766                    context.body = body.map(|Json(b)| b);
1767
1768                    // Generate AI response if AI generator is available and route has AI config
1769                    let (status, response) = if let (Some(generator), Some(_ai_config)) =
1770                        (ai_generator, &route.ai_config)
1771                    {
1772                        route
1773                            .mock_response_with_status_async(&context, Some(generator.as_ref()))
1774                            .await
1775                    } else {
1776                        // No AI support, use static response
1777                        route.mock_response_with_status()
1778                    };
1779
1780                    (
1781                        axum::http::StatusCode::from_u16(status)
1782                            .unwrap_or(axum::http::StatusCode::OK),
1783                        Json(response),
1784                    )
1785                }
1786            };
1787
1788            router = Self::route_for_method(router, axum_path, &route.method, handler);
1789        }
1790
1791        // Issue #79 — same body-limit raise as `build_router_with_context`;
1792        // the AI handler also uses `Option<Json<Value>>` so axum's 2 MiB
1793        // default truncates large bodies and the handler responds early.
1794        router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
1795    }
1796
1797    /// Build router with MockAI (Behavioral Mock Intelligence) support
1798    ///
1799    /// This method integrates MockAI for intelligent, context-aware response generation,
1800    /// mutation detection, validation error generation, and pagination intelligence.
1801    ///
1802    /// # Arguments
1803    /// * `mockai` - Optional MockAI instance for intelligent behavior
1804    ///
1805    /// # Returns
1806    /// Axum router with MockAI-powered response generation
1807    pub fn build_router_with_mockai(
1808        &self,
1809        mockai: Option<
1810            Arc<
1811                tokio::sync::RwLock<
1812                    dyn mockforge_foundation::intelligent_behavior::MockAiBehavior + Send + Sync,
1813                >,
1814            >,
1815        >,
1816    ) -> Router {
1817        use mockforge_foundation::intelligent_behavior::Request as MockAIRequest;
1818
1819        let mut router = Router::new();
1820        let deduped = self.deduplicated_routes();
1821        tracing::debug!("Building router with MockAI support from {} routes", self.routes.len());
1822
1823        let custom_loader = self.custom_fixture_loader.clone();
1824        // Issue #79 round 14 hotfix — one shared validator (Arc) instead
1825        // of a per-route deep clone. See `build_router_with_context` for
1826        // the O(N²)/OOM rationale.
1827        let validator = Arc::new(self.clone_for_validation());
1828        for (axum_path, route) in &deduped {
1829            tracing::debug!("Adding MockAI-enabled route: {} {}", route.method, route.path);
1830
1831            let route_clone = (*route).clone();
1832            let mockai_clone = mockai.clone();
1833            let custom_loader_clone = custom_loader.clone();
1834            // Issue #79 round 13 — the MockAI handler was bypassing
1835            // request validation entirely, so spec violations never
1836            // populated the conformance ring buffer. Run
1837            // `run_validation_with_recording` before fixture/MockAI/
1838            // mock-response synthesis; the validator is shared via Arc.
1839            let validator_clone = validator.clone();
1840
1841            // Create async handler that processes requests through MockAI
1842            // Query params are extracted via Query extractor with HashMap
1843            // Round 32 — Srikanth on 0.3.176: bench's content-type-swap
1844            // probes return 415 client-side but the server-side
1845            // conformance buffer only ever shows 400s. Root cause: this
1846            // handler used to take `body: Option<Json<Value>>`, and
1847            // axum's `Json` extractor 415s a request whose
1848            // `Content-Type` isn't `application/json` BEFORE the
1849            // handler runs — so the buffer never had a chance to
1850            // record the violation, and client/server logs got out of
1851            // sync on the same URI. Extract raw bytes instead; we now
1852            // do the content-type check ourselves (recording to the
1853            // buffer with category `content-types` and the configured
1854            // validation status, default 415) and parse the body as
1855            // JSON manually for the validator + MockAI paths below.
1856            let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
1857                                query: axum::extract::Query<HashMap<String, String>>,
1858                                headers: HeaderMap,
1859                                body_bytes: axum::body::Bytes| {
1860                let route = route_clone.clone();
1861                let mockai = mockai_clone.clone();
1862                let validator = validator_clone.clone();
1863
1864                async move {
1865                    let mut path_map = Map::new();
1866                    for (k, v) in &path_params {
1867                        path_map.insert(k.clone(), Value::String(v.clone()));
1868                    }
1869                    let mut query_map = Map::new();
1870                    for (k, v) in &query.0 {
1871                        query_map.insert(k.clone(), Value::String(v.clone()));
1872                    }
1873                    let mut header_map = Map::new();
1874                    for (k, v) in headers.iter() {
1875                        if let Ok(s) = v.to_str() {
1876                            header_map.insert(k.to_string(), Value::String(s.to_string()));
1877                        }
1878                    }
1879
1880                    // (a-pre1) Round 32 — content-type mismatch check
1881                    // BEFORE body parsing. Mirrors the existing check
1882                    // in `build_router_with_context`; both must record
1883                    // because at any given moment exactly one of the
1884                    // two router builders is wired up.
1885                    if !body_bytes.is_empty() {
1886                        let actual_ct = headers
1887                            .get(axum::http::header::CONTENT_TYPE)
1888                            .and_then(|v| v.to_str().ok());
1889                        if let Err(ct_err) = validator.check_request_content_type(
1890                            &route.path,
1891                            &route.method,
1892                            actual_ct,
1893                        ) {
1894                            let status_code =
1895                                validator.options.validation_status.unwrap_or_else(|| {
1896                                    std::env::var("MOCKFORGE_VALIDATION_STATUS")
1897                                        .ok()
1898                                        .and_then(|s| s.parse::<u16>().ok())
1899                                        .unwrap_or(415)
1900                                });
1901                            let (client_mockforge_version, client_sent_at) =
1902                                mockforge_foundation::conformance_violations::read_client_stamps(
1903                                    |name| {
1904                                        headers
1905                                            .get(name)
1906                                            .and_then(|v| v.to_str().ok())
1907                                            .map(|s| s.to_string())
1908                                    },
1909                                );
1910                            mockforge_foundation::conformance_violations::record(
1911                                mockforge_foundation::conformance_violations::ServerConformanceViolation {
1912                                    timestamp: Utc::now(),
1913                                    method: route.method.clone(),
1914                                    path: route.path.clone(),
1915                                    client_ip: "unknown".to_string(),
1916                                    status: status_code,
1917                                    reason: ct_err.clone(),
1918                                    category: "content-types".to_string(),
1919                                    occurrences: 1,
1920                                    client_mockforge_version,
1921                                    client_sent_at,
1922                                },
1923                            );
1924                            let status = axum::http::StatusCode::from_u16(status_code)
1925                                .unwrap_or(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
1926                            return (
1927                                status,
1928                                Json(serde_json::json!({
1929                                    "error": "content_type_not_allowed",
1930                                    "message": ct_err,
1931                                })),
1932                            )
1933                                .into_response();
1934                        }
1935                    }
1936
1937                    // (a-pre2) Parse body as JSON when present so the
1938                    // validator, fingerprint, and MockAI paths can all
1939                    // see it. We deliberately don't fail on JSON parse
1940                    // errors here — the body validator below produces
1941                    // the right shape of error message in that case.
1942                    //
1943                    // Round 42 (#79) — Srikanth on 0.3.186: a multipart
1944                    // upload (Content-Type: `multipart/form-data; ...`)
1945                    // returns `400 body: Request body is required but
1946                    // not provided` because the JSON parse silently
1947                    // returns None and the validator treats that as
1948                    // "no body present". For multipart requests, parse
1949                    // the form parts into a synthetic JSON object (one
1950                    // entry per field name, file parts contribute their
1951                    // tmpfile path) so the validator sees the body
1952                    // exists. Schema validation then runs against that
1953                    // synthetic object the same way it would for a
1954                    // regular `application/json` body.
1955                    let is_multipart_req = headers
1956                        .get(axum::http::header::CONTENT_TYPE)
1957                        .and_then(|v| v.to_str().ok())
1958                        .map(|ct| ct.starts_with("multipart/form-data"))
1959                        .unwrap_or(false);
1960                    let body: Option<Json<Value>> = if body_bytes.is_empty() {
1961                        None
1962                    } else if is_multipart_req {
1963                        match extract_multipart_from_bytes(&body_bytes, &headers).await {
1964                            Ok((fields, _files)) => {
1965                                let mut obj = Map::new();
1966                                for (k, v) in fields {
1967                                    obj.insert(k, v);
1968                                }
1969                                if obj.is_empty() {
1970                                    // Non-empty multipart body that yielded
1971                                    // zero fields (malformed envelope or
1972                                    // files only with no Content-Disposition
1973                                    // name); still signal "body present" so
1974                                    // the validator doesn't 400 with "body
1975                                    // required". Schema mismatches downstream
1976                                    // surface as their own validation errors.
1977                                    Some(Json(Value::Object(Map::new())))
1978                                } else {
1979                                    Some(Json(Value::Object(obj)))
1980                                }
1981                            }
1982                            Err(e) => {
1983                                tracing::warn!(
1984                                    "multipart parse failed for {} {}: {}",
1985                                    route.method,
1986                                    route.path,
1987                                    e
1988                                );
1989                                Some(Json(Value::Object(Map::new())))
1990                            }
1991                        }
1992                    } else {
1993                        serde_json::from_slice::<Value>(&body_bytes).ok().map(Json)
1994                    };
1995
1996                    // (a-pre3) Run request validation against the spec
1997                    // before any response synthesis. On failure this
1998                    // also records to the foundation conformance ring
1999                    // buffer surfaced by the TUI Conformance tab.
2000                    let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
2001                    if let Err((status_code, payload)) = validator.run_validation_with_recording(
2002                        &route.path,
2003                        &route.method,
2004                        &path_map,
2005                        &query_map,
2006                        &header_map,
2007                        &Map::new(),
2008                        body_val,
2009                    ) {
2010                        let status = axum::http::StatusCode::from_u16(status_code)
2011                            .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
2012                        return (status, Json(payload)).into_response();
2013                    }
2014
2015                    tracing::info!(
2016                        "[FIXTURE DEBUG] Starting fixture check for {} {} (custom_loader available: {})",
2017                        route.method,
2018                        route.path,
2019                        custom_loader_clone.is_some()
2020                    );
2021
2022                    // Check for custom fixture first (highest priority, before MockAI)
2023                    if let Some(ref loader) = custom_loader_clone {
2024                        use crate::request_fingerprint::RequestFingerprint;
2025                        use axum::http::{Method, Uri};
2026
2027                        // Build query string from parsed query params
2028                        let query_string = if query.0.is_empty() {
2029                            String::new()
2030                        } else {
2031                            query
2032                                .0
2033                                .iter()
2034                                .map(|(k, v)| format!("{}={}", k, v))
2035                                .collect::<Vec<_>>()
2036                                .join("&")
2037                        };
2038
2039                        // Normalize the path to match fixture normalization
2040                        let normalized_request_path =
2041                            crate::custom_fixture::CustomFixtureLoader::normalize_path(&route.path);
2042
2043                        tracing::info!(
2044                            "[FIXTURE DEBUG] Path normalization: original='{}', normalized='{}'",
2045                            route.path,
2046                            normalized_request_path
2047                        );
2048
2049                        // Create URI for fingerprint
2050                        let uri_str = if query_string.is_empty() {
2051                            normalized_request_path.clone()
2052                        } else {
2053                            format!("{}?{}", normalized_request_path, query_string)
2054                        };
2055
2056                        tracing::info!(
2057                            "[FIXTURE DEBUG] URI construction: uri_str='{}', query_string='{}'",
2058                            uri_str,
2059                            query_string
2060                        );
2061
2062                        if let Ok(uri) = uri_str.parse::<Uri>() {
2063                            let http_method =
2064                                Method::from_bytes(route.method.as_bytes()).unwrap_or(Method::GET);
2065
2066                            // Convert body to bytes for fingerprint
2067                            let body_bytes =
2068                                body.as_ref().and_then(|Json(b)| serde_json::to_vec(b).ok());
2069                            let body_slice = body_bytes.as_deref();
2070
2071                            let fingerprint =
2072                                RequestFingerprint::new(http_method, &uri, &headers, body_slice);
2073
2074                            tracing::info!(
2075                                "[FIXTURE DEBUG] RequestFingerprint created: method='{}', path='{}', query='{}', body_hash={:?}",
2076                                fingerprint.method,
2077                                fingerprint.path,
2078                                fingerprint.query,
2079                                fingerprint.body_hash
2080                            );
2081
2082                            // Check what fixtures are available for this method
2083                            let available_fixtures = loader.has_fixture(&fingerprint);
2084                            tracing::info!(
2085                                "[FIXTURE DEBUG] Fixture check result: has_fixture={}",
2086                                available_fixtures
2087                            );
2088
2089                            if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
2090                                tracing::info!(
2091                                    "[FIXTURE DEBUG] ✅ FIXTURE MATCHED! Using custom fixture for {} {} (status: {}, path: '{}')",
2092                                    route.method,
2093                                    route.path,
2094                                    custom_fixture.status,
2095                                    custom_fixture.path
2096                                );
2097
2098                                // Apply delay if specified
2099                                if custom_fixture.delay_ms > 0 {
2100                                    tokio::time::sleep(tokio::time::Duration::from_millis(
2101                                        custom_fixture.delay_ms,
2102                                    ))
2103                                    .await;
2104                                }
2105
2106                                // Convert response to JSON string if needed
2107                                let response_body = if custom_fixture.response.is_string() {
2108                                    custom_fixture.response.as_str().unwrap().to_string()
2109                                } else {
2110                                    serde_json::to_string(&custom_fixture.response)
2111                                        .unwrap_or_else(|_| "{}".to_string())
2112                                };
2113
2114                                // Parse response body as JSON
2115                                let json_value: Value = serde_json::from_str(&response_body)
2116                                    .unwrap_or_else(|_| serde_json::json!({}));
2117
2118                                // Build response with status and JSON body
2119                                let status =
2120                                    axum::http::StatusCode::from_u16(custom_fixture.status)
2121                                        .unwrap_or(axum::http::StatusCode::OK);
2122
2123                                // Return as tuple (StatusCode, Json) to match handler signature
2124                                return (status, Json(json_value)).into_response();
2125                            } else {
2126                                tracing::warn!(
2127                                    "[FIXTURE DEBUG] ❌ No fixture match found for {} {} (fingerprint.path='{}', normalized='{}')",
2128                                    route.method,
2129                                    route.path,
2130                                    fingerprint.path,
2131                                    normalized_request_path
2132                                );
2133                            }
2134                        } else {
2135                            tracing::warn!("[FIXTURE DEBUG] Failed to parse URI: '{}'", uri_str);
2136                        }
2137                    } else {
2138                        tracing::warn!(
2139                            "[FIXTURE DEBUG] Custom fixture loader not available for {} {}",
2140                            route.method,
2141                            route.path
2142                        );
2143                    }
2144
2145                    tracing::debug!(
2146                        "Handling MockAI request for route: {} {}",
2147                        route.method,
2148                        route.path
2149                    );
2150
2151                    // Query parameters are already parsed by Query extractor
2152                    let mockai_query = query.0;
2153
2154                    // If MockAI is enabled, use it to process the request
2155                    // CRITICAL FIX: Skip MockAI for GET, HEAD, and OPTIONS requests
2156                    // These are read-only operations and should use OpenAPI response generation
2157                    // MockAI's mutation analysis incorrectly treats GET requests as "Create" mutations
2158                    let method_upper = route.method.to_uppercase();
2159                    let should_use_mockai =
2160                        matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH" | "DELETE");
2161
2162                    if should_use_mockai {
2163                        if let Some(mockai_arc) = mockai {
2164                            let mockai_guard = mockai_arc.read().await;
2165
2166                            // Build MockAI request
2167                            let mut mockai_headers = HashMap::new();
2168                            for (k, v) in headers.iter() {
2169                                mockai_headers
2170                                    .insert(k.to_string(), v.to_str().unwrap_or("").to_string());
2171                            }
2172
2173                            let mockai_request = MockAIRequest {
2174                                method: route.method.clone(),
2175                                path: route.path.clone(),
2176                                body: body.as_ref().map(|Json(b)| b.clone()),
2177                                query_params: mockai_query,
2178                                headers: mockai_headers,
2179                            };
2180
2181                            // Process request through MockAI
2182                            match mockai_guard.process_request(&mockai_request).await {
2183                                Ok(mockai_response) => {
2184                                    // Check if MockAI returned an empty object (signals to use OpenAPI generation)
2185                                    let is_empty = mockai_response.body.is_object()
2186                                        && mockai_response
2187                                            .body
2188                                            .as_object()
2189                                            .map(|obj| obj.is_empty())
2190                                            .unwrap_or(false);
2191
2192                                    if is_empty {
2193                                        tracing::debug!(
2194                                            "MockAI returned empty object for {} {}, falling back to OpenAPI response generation",
2195                                            route.method,
2196                                            route.path
2197                                        );
2198                                        // Fall through to standard OpenAPI response generation
2199                                    } else {
2200                                        // Use the status code from the OpenAPI spec rather than
2201                                        // MockAI's hardcoded 200, so that e.g. POST returning 201
2202                                        // is honored correctly.
2203                                        let spec_status = route.find_first_available_status_code();
2204                                        tracing::debug!(
2205                                            "MockAI generated response for {} {}, using spec status: {} (MockAI suggested: {})",
2206                                            route.method,
2207                                            route.path,
2208                                            spec_status,
2209                                            mockai_response.status_code
2210                                        );
2211                                        let status = axum::http::StatusCode::from_u16(spec_status)
2212                                            .unwrap_or(axum::http::StatusCode::OK);
2213                                        let mut resp =
2214                                            (status, Json(mockai_response.body)).into_response();
2215                                        inject_spec_response_headers(
2216                                            &mut resp,
2217                                            &route,
2218                                            spec_status,
2219                                        );
2220                                        return resp;
2221                                    }
2222                                }
2223                                Err(e) => {
2224                                    tracing::warn!(
2225                                        "MockAI processing failed for {} {}: {}, falling back to standard response",
2226                                        route.method,
2227                                        route.path,
2228                                        e
2229                                    );
2230                                    // Fall through to standard response generation
2231                                }
2232                            }
2233                        }
2234                    } else {
2235                        tracing::debug!(
2236                            "Skipping MockAI for {} request {} - using OpenAPI response generation",
2237                            method_upper,
2238                            route.path
2239                        );
2240                    }
2241
2242                    // Check for status code override header
2243                    let status_override = headers
2244                        .get("X-Mockforge-Response-Status")
2245                        .and_then(|v| v.to_str().ok())
2246                        .and_then(|s| s.parse::<u16>().ok());
2247
2248                    // Check for scenario header
2249                    let scenario = headers
2250                        .get("X-Mockforge-Scenario")
2251                        .and_then(|v| v.to_str().ok())
2252                        .map(|s| s.to_string())
2253                        .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
2254
2255                    // Fallback to standard response generation
2256                    let (status, response) = route
2257                        .mock_response_with_status_and_scenario_and_override(
2258                            scenario.as_deref(),
2259                            status_override,
2260                        );
2261                    let status_code = axum::http::StatusCode::from_u16(status)
2262                        .unwrap_or(axum::http::StatusCode::OK);
2263                    let mut resp = (status_code, Json(response)).into_response();
2264                    inject_spec_response_headers(&mut resp, &route, status);
2265                    resp
2266                }
2267            };
2268
2269            router = Self::route_for_method(router, axum_path, &route.method, handler);
2270        }
2271
2272        // Issue #79 — see `build_router_with_context`; same body-limit raise
2273        // for the MockAI router.
2274        router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
2275    }
2276}
2277
2278/// Inject response headers declared in `responses.<code>.headers` from
2279/// the spec into an axum response, after the body and status have been
2280/// set. No-op when the route's operation has no headers for that status.
2281///
2282/// Round 43 (#79) — the validator already knew about declared response
2283/// headers (`validation::validate_response_headers`) but the synthesiser
2284/// never emitted them, so `mockforge serve` returned 200 with no
2285/// `Set-Cookie` even when the spec promised one. Existing headers stay
2286/// (so axum's content-type / vary / rate-limit headers aren't clobbered)
2287/// — we only insert if absent. Invalid HeaderName / HeaderValue bytes
2288/// are silently skipped so a typo in the spec doesn't take the response
2289/// down.
2290fn inject_spec_response_headers(
2291    response: &mut axum::response::Response,
2292    route: &crate::route::OpenApiRoute,
2293    status_code: u16,
2294) {
2295    let synthesized = route.mock_response_headers_for_status(status_code);
2296    if synthesized.is_empty() {
2297        return;
2298    }
2299    let response_headers = response.headers_mut();
2300    for (name, value) in synthesized {
2301        let Ok(header_name) = axum::http::HeaderName::from_bytes(name.as_bytes()) else {
2302            continue;
2303        };
2304        if response_headers.contains_key(&header_name) {
2305            continue;
2306        }
2307        let Ok(header_value) = axum::http::HeaderValue::from_str(&value) else {
2308            continue;
2309        };
2310        response_headers.insert(header_name, header_value);
2311    }
2312}
2313
2314// Note: templating helpers are now in core::templating (shared across modules)
2315
2316/// Extract multipart form data from request body bytes
2317/// Returns (form_fields, file_paths) where file_paths maps field names to stored file paths
2318async fn extract_multipart_from_bytes(
2319    body: &axum::body::Bytes,
2320    headers: &HeaderMap,
2321) -> Result<(HashMap<String, Value>, HashMap<String, String>)> {
2322    // Get boundary from Content-Type header
2323    let boundary = headers
2324        .get(axum::http::header::CONTENT_TYPE)
2325        .and_then(|v| v.to_str().ok())
2326        .and_then(|ct| {
2327            ct.split(';').find_map(|part| {
2328                let part = part.trim();
2329                if part.starts_with("boundary=") {
2330                    Some(part.strip_prefix("boundary=").unwrap_or("").trim_matches('"'))
2331                } else {
2332                    None
2333                }
2334            })
2335        })
2336        .ok_or_else(|| Error::internal("Missing boundary in Content-Type header"))?;
2337
2338    let mut fields = HashMap::new();
2339    let mut files = HashMap::new();
2340
2341    // Parse multipart data using bytes directly (not string conversion)
2342    // Multipart format: --boundary\r\n...\r\n--boundary\r\n...\r\n--boundary--\r\n
2343    let boundary_prefix = format!("--{}", boundary).into_bytes();
2344    let boundary_line = format!("\r\n--{}\r\n", boundary).into_bytes();
2345    let end_boundary = format!("\r\n--{}--\r\n", boundary).into_bytes();
2346
2347    // Find all boundary positions
2348    let mut pos = 0;
2349    let mut parts = Vec::new();
2350
2351    // Skip initial boundary if present
2352    if body.starts_with(&boundary_prefix) {
2353        if let Some(first_crlf) = body.iter().position(|&b| b == b'\r') {
2354            pos = first_crlf + 2; // Skip --boundary\r\n
2355        }
2356    }
2357
2358    // Find all middle boundaries
2359    while let Some(boundary_pos) = body[pos..]
2360        .windows(boundary_line.len())
2361        .position(|window| window == boundary_line.as_slice())
2362    {
2363        let actual_pos = pos + boundary_pos;
2364        if actual_pos > pos {
2365            parts.push((pos, actual_pos));
2366        }
2367        pos = actual_pos + boundary_line.len();
2368    }
2369
2370    // Find final boundary
2371    if let Some(end_pos) = body[pos..]
2372        .windows(end_boundary.len())
2373        .position(|window| window == end_boundary.as_slice())
2374    {
2375        let actual_end = pos + end_pos;
2376        if actual_end > pos {
2377            parts.push((pos, actual_end));
2378        }
2379    } else if pos < body.len() {
2380        // No final boundary found, treat rest as last part
2381        parts.push((pos, body.len()));
2382    }
2383
2384    // Process each part
2385    for (start, end) in parts {
2386        let part_data = &body[start..end];
2387
2388        // Find header/body separator (CRLF CRLF)
2389        let separator = b"\r\n\r\n";
2390        if let Some(sep_pos) =
2391            part_data.windows(separator.len()).position(|window| window == separator)
2392        {
2393            let header_bytes = &part_data[..sep_pos];
2394            let body_start = sep_pos + separator.len();
2395            let body_data = &part_data[body_start..];
2396
2397            // Parse headers (assuming UTF-8)
2398            let header_str = String::from_utf8_lossy(header_bytes);
2399            let mut field_name = None;
2400            let mut filename = None;
2401
2402            for header_line in header_str.lines() {
2403                if header_line.starts_with("Content-Disposition:") {
2404                    // Extract field name
2405                    if let Some(name_start) = header_line.find("name=\"") {
2406                        let name_start = name_start + 6;
2407                        if let Some(name_end) = header_line[name_start..].find('"') {
2408                            field_name =
2409                                Some(header_line[name_start..name_start + name_end].to_string());
2410                        }
2411                    }
2412
2413                    // Extract filename if present
2414                    if let Some(file_start) = header_line.find("filename=\"") {
2415                        let file_start = file_start + 10;
2416                        if let Some(file_end) = header_line[file_start..].find('"') {
2417                            filename =
2418                                Some(header_line[file_start..file_start + file_end].to_string());
2419                        }
2420                    }
2421                }
2422            }
2423
2424            if let Some(name) = field_name {
2425                if let Some(file) = filename {
2426                    // This is a file upload - store to temp directory
2427                    let temp_dir = std::env::temp_dir().join("mockforge-uploads");
2428                    std::fs::create_dir_all(&temp_dir)
2429                        .map_err(|e| Error::io_with_context("temp directory", e.to_string()))?;
2430
2431                    let file_path = temp_dir.join(format!("{}_{}", uuid::Uuid::new_v4(), file));
2432                    std::fs::write(&file_path, body_data)
2433                        .map_err(|e| Error::io_with_context("file", e.to_string()))?;
2434
2435                    let file_path_str = file_path.to_string_lossy().to_string();
2436                    files.insert(name.clone(), file_path_str.clone());
2437                    fields.insert(name, Value::String(file_path_str));
2438                } else {
2439                    // This is a regular form field - try to parse as UTF-8 string
2440                    // Trim trailing CRLF
2441                    let body_str = body_data
2442                        .strip_suffix(b"\r\n")
2443                        .or_else(|| body_data.strip_suffix(b"\n"))
2444                        .unwrap_or(body_data);
2445
2446                    if let Ok(field_value) = String::from_utf8(body_str.to_vec()) {
2447                        fields.insert(name, Value::String(field_value.trim().to_string()));
2448                    } else {
2449                        // Non-UTF-8 field value - store as base64 encoded string
2450                        use base64::{engine::general_purpose, Engine as _};
2451                        fields.insert(
2452                            name,
2453                            Value::String(general_purpose::STANDARD.encode(body_str)),
2454                        );
2455                    }
2456                }
2457            }
2458        }
2459    }
2460
2461    Ok((fields, files))
2462}
2463
2464static LAST_ERRORS: Lazy<Mutex<VecDeque<Value>>> =
2465    Lazy::new(|| Mutex::new(VecDeque::with_capacity(20)));
2466
2467/// Classify a validation error reason string into one of the
2468/// `ConformanceFeature` categories used by the bench-side spec
2469/// validator. Best-effort string match — keeps the server-side
2470/// conformance ring buffer's `category` field meaningful for the TUI
2471/// without dragging in the entire spec analyser.
2472///
2473/// Issue #79 round 12.
2474pub fn classify_validation_reason(reason: &str) -> String {
2475    // Round 41 (#79) — Srikanth on 0.3.185: violations on GET requests
2476    // (which carry no body) AND query-only violations on POST requests
2477    // were both being categorised as "request-body" because the older
2478    // matcher checked `r.contains("schema")` before the per-location
2479    // checks. The validator's error payload embeds a structured
2480    // `"path":"<location>.<name>"` per violation; classify on the
2481    // FIRST such path so the category reflects the actual failure
2482    // location instead of the validator's prose.
2483    let r = reason.to_ascii_lowercase();
2484
2485    // Cheap structured pull from the validator's `"path":"<loc>.<name>"` fields.
2486    let path_starts_with = |prefix: &str| r.contains(&format!("\"path\":\"{}", prefix));
2487    if path_starts_with("query.") {
2488        return "query".into();
2489    }
2490    if path_starts_with("header.") {
2491        return "headers".into();
2492    }
2493    if path_starts_with("cookie.") {
2494        return "cookies".into();
2495    }
2496    if path_starts_with("path.") {
2497        return "parameters".into();
2498    }
2499    if path_starts_with("body") {
2500        return "request-body".into();
2501    }
2502
2503    // Content-type mismatches are surfaced separately, BEFORE the
2504    // schema validator runs.
2505    if r.contains("content-type") || r.contains("content type") {
2506        return "content-types".into();
2507    }
2508
2509    // Fallback: the older heuristics for callers that don't embed a
2510    // structured path field (e.g. malformed JSON, missing body
2511    // entirely, security-scheme mismatches).
2512    if r.contains("required")
2513        && (r.contains("param") || r.contains("query") || r.contains("header"))
2514    {
2515        return "parameters".into();
2516    }
2517    if r.contains("auth") || r.contains("security") {
2518        return "security".into();
2519    }
2520    if r.contains("method") {
2521        return "http-methods".into();
2522    }
2523    if r.contains("schema") || r.contains("body") || r.contains("json") {
2524        return "request-body".into();
2525    }
2526    if r.contains("enum") || r.contains("min") || r.contains("max") || r.contains("pattern") {
2527        return "constraints".into();
2528    }
2529    String::new()
2530}
2531
2532/// Record last validation error for Admin UI inspection
2533pub fn record_validation_error(v: &Value) {
2534    if let Ok(mut q) = LAST_ERRORS.lock() {
2535        if q.len() >= 20 {
2536            q.pop_front();
2537        }
2538        q.push_back(v.clone());
2539    }
2540    // If mutex is poisoned, we silently fail - validation errors are informational only
2541}
2542
2543/// Get most recent validation error
2544pub fn get_last_validation_error() -> Option<Value> {
2545    LAST_ERRORS.lock().ok()?.back().cloned()
2546}
2547
2548/// Get recent validation errors (most recent last)
2549pub fn get_validation_errors() -> Vec<Value> {
2550    LAST_ERRORS.lock().map(|q| q.iter().cloned().collect()).unwrap_or_default()
2551}
2552
2553/// Coerce a parameter `value` into the expected JSON type per `schema` where reasonable.
2554/// Applies only to param contexts (not request bodies). Conservative conversions:
2555/// - integer/number: parse from string; arrays: split comma-separated strings and coerce items
2556/// - boolean: parse true/false (case-insensitive) from string
2557fn coerce_value_for_schema(value: &Value, schema: &openapiv3::Schema) -> Value {
2558    // Basic coercion: try to parse strings as appropriate types
2559    match value {
2560        Value::String(s) => {
2561            // Check if schema expects an array and we have a comma-separated string
2562            if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2563                &schema.schema_kind
2564            {
2565                if s.contains(',') {
2566                    // Split comma-separated string into array
2567                    let parts: Vec<&str> = s.split(',').map(|s| s.trim()).collect();
2568                    let mut array_values = Vec::new();
2569
2570                    for part in parts {
2571                        // Coerce each part based on array item type
2572                        if let Some(items_schema) = &array_type.items {
2573                            if let Some(items_schema_obj) = items_schema.as_item() {
2574                                let part_value = Value::String(part.to_string());
2575                                let coerced_part =
2576                                    coerce_value_for_schema(&part_value, items_schema_obj);
2577                                array_values.push(coerced_part);
2578                            } else {
2579                                // If items schema is a reference or not available, keep as string
2580                                array_values.push(Value::String(part.to_string()));
2581                            }
2582                        } else {
2583                            // No items schema defined, keep as string
2584                            array_values.push(Value::String(part.to_string()));
2585                        }
2586                    }
2587                    return Value::Array(array_values);
2588                }
2589            }
2590
2591            // Only coerce if the schema expects a different type
2592            match &schema.schema_kind {
2593                openapiv3::SchemaKind::Type(openapiv3::Type::String(_)) => {
2594                    // Schema expects string, keep as string
2595                    value.clone()
2596                }
2597                openapiv3::SchemaKind::Type(openapiv3::Type::Number(_)) => {
2598                    // Schema expects number, try to parse
2599                    if let Ok(n) = s.parse::<f64>() {
2600                        if let Some(num) = serde_json::Number::from_f64(n) {
2601                            return Value::Number(num);
2602                        }
2603                    }
2604                    value.clone()
2605                }
2606                openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_)) => {
2607                    // Schema expects integer, try to parse
2608                    if let Ok(n) = s.parse::<i64>() {
2609                        if let Some(num) = serde_json::Number::from_f64(n as f64) {
2610                            return Value::Number(num);
2611                        }
2612                    }
2613                    value.clone()
2614                }
2615                openapiv3::SchemaKind::Type(openapiv3::Type::Boolean(_)) => {
2616                    // Schema expects boolean, try to parse
2617                    match s.to_lowercase().as_str() {
2618                        "true" | "1" | "yes" | "on" => Value::Bool(true),
2619                        "false" | "0" | "no" | "off" => Value::Bool(false),
2620                        _ => value.clone(),
2621                    }
2622                }
2623                _ => {
2624                    // Unknown schema type, keep as string
2625                    value.clone()
2626                }
2627            }
2628        }
2629        _ => value.clone(),
2630    }
2631}
2632
2633/// Apply style-aware coercion for query params
2634fn coerce_by_style(value: &Value, schema: &openapiv3::Schema, style: Option<&str>) -> Value {
2635    // Style-aware coercion for query parameters
2636    match value {
2637        Value::String(s) => {
2638            // Check if schema expects an array and we have a delimited string
2639            if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2640                &schema.schema_kind
2641            {
2642                let delimiter = match style {
2643                    Some("spaceDelimited") => " ",
2644                    Some("pipeDelimited") => "|",
2645                    Some("form") | None => ",", // Default to form style (comma-separated)
2646                    _ => ",",                   // Fallback to comma
2647                };
2648
2649                if s.contains(delimiter) {
2650                    // Split delimited string into array
2651                    let parts: Vec<&str> = s.split(delimiter).map(|s| s.trim()).collect();
2652                    let mut array_values = Vec::new();
2653
2654                    for part in parts {
2655                        // Coerce each part based on array item type
2656                        if let Some(items_schema) = &array_type.items {
2657                            if let Some(items_schema_obj) = items_schema.as_item() {
2658                                let part_value = Value::String(part.to_string());
2659                                let coerced_part =
2660                                    coerce_by_style(&part_value, items_schema_obj, style);
2661                                array_values.push(coerced_part);
2662                            } else {
2663                                // If items schema is a reference or not available, keep as string
2664                                array_values.push(Value::String(part.to_string()));
2665                            }
2666                        } else {
2667                            // No items schema defined, keep as string
2668                            array_values.push(Value::String(part.to_string()));
2669                        }
2670                    }
2671                    return Value::Array(array_values);
2672                }
2673            }
2674
2675            // Try to parse as number first
2676            if let Ok(n) = s.parse::<f64>() {
2677                if let Some(num) = serde_json::Number::from_f64(n) {
2678                    return Value::Number(num);
2679                }
2680            }
2681            // Try to parse as boolean
2682            match s.to_lowercase().as_str() {
2683                "true" | "1" | "yes" | "on" => return Value::Bool(true),
2684                "false" | "0" | "no" | "off" => return Value::Bool(false),
2685                _ => {}
2686            }
2687            // Keep as string
2688            value.clone()
2689        }
2690        _ => value.clone(),
2691    }
2692}
2693
2694/// Build a deepObject from query params like `name[prop]=val`
2695fn build_deep_object(name: &str, params: &Map<String, Value>) -> Option<Value> {
2696    let prefix = format!("{}[", name);
2697    let mut obj = Map::new();
2698    for (k, v) in params.iter() {
2699        if let Some(rest) = k.strip_prefix(&prefix) {
2700            if let Some(key) = rest.strip_suffix(']') {
2701                obj.insert(key.to_string(), v.clone());
2702            }
2703        }
2704    }
2705    if obj.is_empty() {
2706        None
2707    } else {
2708        Some(Value::Object(obj))
2709    }
2710}
2711
2712// Import the enhanced schema diff functionality
2713// use mockforge_foundation::schema_diff::{validation_diff, to_enhanced_422_json, ValidationError}; // Not currently used
2714
2715/// Generate an enhanced 422 response with detailed schema validation errors
2716/// This function provides comprehensive error information using the new schema diff utility
2717#[allow(clippy::too_many_arguments)]
2718fn generate_enhanced_422_response(
2719    validator: &OpenApiRouteRegistry,
2720    path_template: &str,
2721    method: &str,
2722    body: Option<&Value>,
2723    path_params: &Map<String, Value>,
2724    query_params: &Map<String, Value>,
2725    header_params: &Map<String, Value>,
2726    cookie_params: &Map<String, Value>,
2727) -> Value {
2728    let mut field_errors = Vec::new();
2729
2730    // Extract schema validation details if we have a route
2731    if let Some(route) = validator.get_route(path_template, method) {
2732        // Validate request body with detailed error collection
2733        if let Some(schema) = &route.operation.request_body {
2734            if let Some(value) = body {
2735                if let Some(content) =
2736                    schema.as_item().and_then(|rb| rb.content.get("application/json"))
2737                {
2738                    if let Some(_schema_ref) = &content.schema {
2739                        // Basic JSON validation - schema validation deferred
2740                        if serde_json::from_value::<Value>(value.clone()).is_err() {
2741                            field_errors.push(json!({
2742                                "path": "body",
2743                                "message": "invalid JSON"
2744                            }));
2745                        }
2746                    }
2747                }
2748            } else {
2749                field_errors.push(json!({
2750                    "path": "body",
2751                    "expected": "object",
2752                    "found": "missing",
2753                    "message": "Request body is required but not provided"
2754                }));
2755            }
2756        }
2757
2758        // Validate parameters with detailed error collection
2759        for param_ref in &route.operation.parameters {
2760            if let Some(param) = param_ref.as_item() {
2761                match param {
2762                    openapiv3::Parameter::Path { parameter_data, .. } => {
2763                        validate_parameter_detailed(
2764                            parameter_data,
2765                            path_params,
2766                            "path",
2767                            "path parameter",
2768                            &mut field_errors,
2769                        );
2770                    }
2771                    openapiv3::Parameter::Query { parameter_data, .. } => {
2772                        let deep_value = if Some("form") == Some("deepObject") {
2773                            build_deep_object(&parameter_data.name, query_params)
2774                        } else {
2775                            None
2776                        };
2777                        validate_parameter_detailed_with_deep(
2778                            parameter_data,
2779                            query_params,
2780                            "query",
2781                            "query parameter",
2782                            deep_value,
2783                            &mut field_errors,
2784                        );
2785                    }
2786                    openapiv3::Parameter::Header { parameter_data, .. } => {
2787                        validate_parameter_detailed(
2788                            parameter_data,
2789                            header_params,
2790                            "header",
2791                            "header parameter",
2792                            &mut field_errors,
2793                        );
2794                    }
2795                    openapiv3::Parameter::Cookie { parameter_data, .. } => {
2796                        validate_parameter_detailed(
2797                            parameter_data,
2798                            cookie_params,
2799                            "cookie",
2800                            "cookie parameter",
2801                            &mut field_errors,
2802                        );
2803                    }
2804                }
2805            }
2806        }
2807    }
2808
2809    // Return the detailed 422 error format
2810    json!({
2811        "error": "Schema validation failed",
2812        "details": field_errors,
2813        "method": method,
2814        "path": path_template,
2815        "timestamp": Utc::now().to_rfc3339(),
2816        "validation_type": "openapi_schema"
2817    })
2818}
2819
2820/// Helper function to validate a parameter
2821fn validate_parameter(
2822    parameter_data: &openapiv3::ParameterData,
2823    params_map: &Map<String, Value>,
2824    prefix: &str,
2825    aggregate: bool,
2826    errors: &mut Vec<String>,
2827    details: &mut Vec<Value>,
2828) {
2829    match params_map.get(&parameter_data.name) {
2830        Some(v) => {
2831            if let ParameterSchemaOrContent::Schema(s) = &parameter_data.format {
2832                if let Some(schema) = s.as_item() {
2833                    let coerced = coerce_value_for_schema(v, schema);
2834                    // Validate the coerced value against the schema
2835                    if let Err(validation_error) =
2836                        OpenApiSchema::new(schema.clone()).validate(&coerced)
2837                    {
2838                        let error_msg = validation_error.to_string();
2839                        errors.push(format!(
2840                            "{} parameter '{}' validation failed: {}",
2841                            prefix, parameter_data.name, error_msg
2842                        ));
2843                        if aggregate {
2844                            details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2845                        }
2846                    }
2847                }
2848            }
2849        }
2850        None => {
2851            if parameter_data.required {
2852                errors.push(format!(
2853                    "missing required {} parameter '{}'",
2854                    prefix, parameter_data.name
2855                ));
2856                details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2857            }
2858        }
2859    }
2860}
2861
2862/// Helper function to validate a parameter with deep object support
2863#[allow(clippy::too_many_arguments)]
2864fn validate_parameter_with_deep_object(
2865    parameter_data: &openapiv3::ParameterData,
2866    params_map: &Map<String, Value>,
2867    prefix: &str,
2868    deep_value: Option<Value>,
2869    style: Option<&str>,
2870    aggregate: bool,
2871    errors: &mut Vec<String>,
2872    details: &mut Vec<Value>,
2873) {
2874    match deep_value.as_ref().or_else(|| params_map.get(&parameter_data.name)) {
2875        Some(v) => {
2876            if let ParameterSchemaOrContent::Schema(s) = &parameter_data.format {
2877                if let Some(schema) = s.as_item() {
2878                    let coerced = coerce_by_style(v, schema, style); // Use the actual style
2879                                                                     // Validate the coerced value against the schema
2880                    if let Err(validation_error) =
2881                        OpenApiSchema::new(schema.clone()).validate(&coerced)
2882                    {
2883                        let error_msg = validation_error.to_string();
2884                        errors.push(format!(
2885                            "{} parameter '{}' validation failed: {}",
2886                            prefix, parameter_data.name, error_msg
2887                        ));
2888                        if aggregate {
2889                            details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2890                        }
2891                    }
2892                }
2893            }
2894        }
2895        None => {
2896            if parameter_data.required {
2897                errors.push(format!(
2898                    "missing required {} parameter '{}'",
2899                    prefix, parameter_data.name
2900                ));
2901                details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2902            }
2903        }
2904    }
2905}
2906
2907/// Helper function to validate a parameter with detailed error collection
2908fn validate_parameter_detailed(
2909    parameter_data: &openapiv3::ParameterData,
2910    params_map: &Map<String, Value>,
2911    location: &str,
2912    value_type: &str,
2913    field_errors: &mut Vec<Value>,
2914) {
2915    match params_map.get(&parameter_data.name) {
2916        Some(value) => {
2917            if let ParameterSchemaOrContent::Schema(schema) = &parameter_data.format {
2918                // Collect detailed validation errors for this parameter
2919                let details: Vec<Value> = Vec::new();
2920                let param_path = format!("{}.{}", location, parameter_data.name);
2921
2922                // Apply coercion before validation
2923                if let Some(schema_ref) = schema.as_item() {
2924                    let coerced_value = coerce_value_for_schema(value, schema_ref);
2925                    // Validate the coerced value against the schema
2926                    if let Err(validation_error) =
2927                        OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2928                    {
2929                        field_errors.push(json!({
2930                            "path": param_path,
2931                            "expected": "valid according to schema",
2932                            "found": coerced_value,
2933                            "message": validation_error.to_string()
2934                        }));
2935                    }
2936                }
2937
2938                for detail in details {
2939                    field_errors.push(json!({
2940                        "path": detail["path"],
2941                        "expected": detail["expected_type"],
2942                        "found": detail["value"],
2943                        "message": detail["message"]
2944                    }));
2945                }
2946            }
2947        }
2948        None => {
2949            if parameter_data.required {
2950                field_errors.push(json!({
2951                    "path": format!("{}.{}", location, parameter_data.name),
2952                    "expected": "value",
2953                    "found": "missing",
2954                    "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
2955                }));
2956            }
2957        }
2958    }
2959}
2960
2961/// Helper function to validate a parameter with deep object support and detailed errors
2962fn validate_parameter_detailed_with_deep(
2963    parameter_data: &openapiv3::ParameterData,
2964    params_map: &Map<String, Value>,
2965    location: &str,
2966    value_type: &str,
2967    deep_value: Option<Value>,
2968    field_errors: &mut Vec<Value>,
2969) {
2970    match deep_value.as_ref().or_else(|| params_map.get(&parameter_data.name)) {
2971        Some(value) => {
2972            if let ParameterSchemaOrContent::Schema(schema) = &parameter_data.format {
2973                // Collect detailed validation errors for this parameter
2974                let details: Vec<Value> = Vec::new();
2975                let param_path = format!("{}.{}", location, parameter_data.name);
2976
2977                // Apply coercion before validation
2978                if let Some(schema_ref) = schema.as_item() {
2979                    let coerced_value = coerce_by_style(value, schema_ref, Some("form")); // Default to form style for now
2980                                                                                          // Validate the coerced value against the schema
2981                    if let Err(validation_error) =
2982                        OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2983                    {
2984                        field_errors.push(json!({
2985                            "path": param_path,
2986                            "expected": "valid according to schema",
2987                            "found": coerced_value,
2988                            "message": validation_error.to_string()
2989                        }));
2990                    }
2991                }
2992
2993                for detail in details {
2994                    field_errors.push(json!({
2995                        "path": detail["path"],
2996                        "expected": detail["expected_type"],
2997                        "found": detail["value"],
2998                        "message": detail["message"]
2999                    }));
3000                }
3001            }
3002        }
3003        None => {
3004            if parameter_data.required {
3005                field_errors.push(json!({
3006                    "path": format!("{}.{}", location, parameter_data.name),
3007                    "expected": "value",
3008                    "found": "missing",
3009                    "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
3010                }));
3011            }
3012        }
3013    }
3014}
3015
3016/// Helper function to create an OpenAPI route registry from a file
3017pub async fn create_registry_from_file<P: AsRef<std::path::Path>>(
3018    path: P,
3019) -> Result<OpenApiRouteRegistry> {
3020    let spec = OpenApiSpec::from_file(path).await?;
3021    spec.validate()?;
3022    Ok(OpenApiRouteRegistry::new(spec))
3023}
3024
3025/// Helper function to create an OpenAPI route registry from JSON
3026pub fn create_registry_from_json(json: Value) -> Result<OpenApiRouteRegistry> {
3027    let spec = OpenApiSpec::from_json(json)?;
3028    spec.validate()?;
3029    Ok(OpenApiRouteRegistry::new(spec))
3030}
3031
3032#[cfg(test)]
3033mod tests {
3034    use super::*;
3035    use serde_json::json;
3036    use tempfile::TempDir;
3037
3038    /// Round 41 (#79) — Srikanth on 0.3.185: GET requests carry no
3039    /// body, so a query-only violation on GET should be categorised
3040    /// as `query`, not `request-body`. POST requests with both query
3041    /// AND body validators should also be classified by where the
3042    /// first detail's `"path":...` lives rather than the validator's
3043    /// generic "schema_validation" prose. The new classifier looks
3044    /// for `"path":"query.<name>"` / `"path":"header.<name>"` etc.
3045    /// first.
3046    #[test]
3047    fn classify_validation_reason_uses_structured_path_field_first() {
3048        // Real shape from the Google Apigee /v1/organizations report
3049        // — query-only enum/boolean violations.
3050        let query_only = r#"{"details":[{"code":"schema_validation","message":"Validation error","path":"query.$.xgafv"}]}"#;
3051        assert_eq!(classify_validation_reason(query_only), "query");
3052
3053        let header_only = r#"{"details":[{"code":"schema_validation","message":"missing required X-Trace","path":"header.X-Trace"}]}"#;
3054        assert_eq!(classify_validation_reason(header_only), "headers");
3055
3056        let cookie_only = r#"{"details":[{"code":"schema_validation","message":"missing session","path":"cookie.session"}]}"#;
3057        assert_eq!(classify_validation_reason(cookie_only), "cookies");
3058
3059        // Body-only violation stays `request-body`.
3060        let body_only = r#"{"details":[{"code":"schema_validation","message":"name required","path":"body.name"}]}"#;
3061        assert_eq!(classify_validation_reason(body_only), "request-body");
3062
3063        // Content-type mismatch keeps its own category.
3064        assert_eq!(
3065            classify_validation_reason("Content-Type application/xml not allowed"),
3066            "content-types"
3067        );
3068    }
3069
3070    #[tokio::test]
3071    async fn test_registry_creation() {
3072        let spec_json = json!({
3073            "openapi": "3.0.0",
3074            "info": {
3075                "title": "Test API",
3076                "version": "1.0.0"
3077            },
3078            "paths": {
3079                "/users": {
3080                    "get": {
3081                        "summary": "Get users",
3082                        "responses": {
3083                            "200": {
3084                                "description": "Success",
3085                                "content": {
3086                                    "application/json": {
3087                                        "schema": {
3088                                            "type": "array",
3089                                            "items": {
3090                                                "type": "object",
3091                                                "properties": {
3092                                                    "id": {"type": "integer"},
3093                                                    "name": {"type": "string"}
3094                                                }
3095                                            }
3096                                        }
3097                                    }
3098                                }
3099                            }
3100                        }
3101                    },
3102                    "post": {
3103                        "summary": "Create user",
3104                        "requestBody": {
3105                            "content": {
3106                                "application/json": {
3107                                    "schema": {
3108                                        "type": "object",
3109                                        "properties": {
3110                                            "name": {"type": "string"}
3111                                        },
3112                                        "required": ["name"]
3113                                    }
3114                                }
3115                            }
3116                        },
3117                        "responses": {
3118                            "201": {
3119                                "description": "Created",
3120                                "content": {
3121                                    "application/json": {
3122                                        "schema": {
3123                                            "type": "object",
3124                                            "properties": {
3125                                                "id": {"type": "integer"},
3126                                                "name": {"type": "string"}
3127                                            }
3128                                        }
3129                                    }
3130                                }
3131                            }
3132                        }
3133                    }
3134                },
3135                "/users/{id}": {
3136                    "get": {
3137                        "summary": "Get user by ID",
3138                        "parameters": [
3139                            {
3140                                "name": "id",
3141                                "in": "path",
3142                                "required": true,
3143                                "schema": {"type": "integer"}
3144                            }
3145                        ],
3146                        "responses": {
3147                            "200": {
3148                                "description": "Success",
3149                                "content": {
3150                                    "application/json": {
3151                                        "schema": {
3152                                            "type": "object",
3153                                            "properties": {
3154                                                "id": {"type": "integer"},
3155                                                "name": {"type": "string"}
3156                                            }
3157                                        }
3158                                    }
3159                                }
3160                            }
3161                        }
3162                    }
3163                }
3164            }
3165        });
3166
3167        let registry = create_registry_from_json(spec_json).unwrap();
3168
3169        // Test basic properties
3170        assert_eq!(registry.paths().len(), 2);
3171        assert!(registry.paths().contains(&"/users".to_string()));
3172        assert!(registry.paths().contains(&"/users/{id}".to_string()));
3173
3174        assert_eq!(registry.methods().len(), 2);
3175        assert!(registry.methods().contains(&"GET".to_string()));
3176        assert!(registry.methods().contains(&"POST".to_string()));
3177
3178        // Test route lookup
3179        let get_users_route = registry.get_route("/users", "GET").unwrap();
3180        assert_eq!(get_users_route.method, "GET");
3181        assert_eq!(get_users_route.path, "/users");
3182
3183        let post_users_route = registry.get_route("/users", "POST").unwrap();
3184        assert_eq!(post_users_route.method, "POST");
3185        assert!(post_users_route.operation.request_body.is_some());
3186
3187        // Test path parameter conversion
3188        let user_by_id_route = registry.get_route("/users/{id}", "GET").unwrap();
3189        assert_eq!(user_by_id_route.axum_path(), "/users/{id}");
3190    }
3191
3192    /// Round 28 — Srikanth's 0.3.171 trace showed mockforge silently
3193    /// accepting `Content-Type: application/xml` against a JSON-only
3194    /// endpoint. The new `check_request_content_type` should flag
3195    /// that as a mismatch; matching types and missing-Content-Type
3196    /// (let the body validator handle it) should still pass.
3197    #[tokio::test]
3198    async fn check_request_content_type_flags_mismatch() {
3199        let spec_json = json!({
3200            "openapi": "3.0.0",
3201            "info": { "title": "T", "version": "1" },
3202            "paths": {
3203                "/api/appliance/access/consolecli": {
3204                    "put": {
3205                        "requestBody": {
3206                            "required": true,
3207                            "content": {
3208                                "application/json": {
3209                                    "schema": {
3210                                        "type": "object",
3211                                        "required": ["enabled"],
3212                                        "properties": {"enabled": {"type": "boolean"}}
3213                                    }
3214                                }
3215                            }
3216                        },
3217                        "responses": { "204": { "description": "ok" } }
3218                    }
3219                }
3220            }
3221        });
3222        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3223        let registry = OpenApiRouteRegistry::new(spec);
3224
3225        // Mismatched Content-Type → flagged.
3226        let r = registry.check_request_content_type(
3227            "/api/appliance/access/consolecli",
3228            "PUT",
3229            Some("application/xml"),
3230        );
3231        assert!(r.is_err(), "should flag application/xml: {:?}", r);
3232        let msg = r.unwrap_err();
3233        assert!(msg.contains("application/xml"), "{msg}");
3234        assert!(msg.contains("application/json"), "{msg}");
3235
3236        // Matching Content-Type → pass.
3237        let r = registry.check_request_content_type(
3238            "/api/appliance/access/consolecli",
3239            "PUT",
3240            Some("application/json"),
3241        );
3242        assert!(r.is_ok(), "should accept application/json: {:?}", r);
3243
3244        // Charset suffix on the matching type → still pass.
3245        let r = registry.check_request_content_type(
3246            "/api/appliance/access/consolecli",
3247            "PUT",
3248            Some("application/json; charset=utf-8"),
3249        );
3250        assert!(r.is_ok(), "should strip charset: {:?}", r);
3251
3252        // No requestBody on this method → noop pass.
3253        let r = registry.check_request_content_type(
3254            "/api/appliance/access/consolecli",
3255            "GET",
3256            Some("application/xml"),
3257        );
3258        assert!(r.is_ok(), "GET has no requestBody on this op: {:?}", r);
3259
3260        // No Content-Type sent → noop pass (body validator's job).
3261        let r =
3262            registry.check_request_content_type("/api/appliance/access/consolecli", "PUT", None);
3263        assert!(r.is_ok(), "no Content-Type → don't double-report: {:?}", r);
3264    }
3265
3266    #[tokio::test]
3267    async fn test_validate_request_with_params_and_formats() {
3268        let spec_json = json!({
3269            "openapi": "3.0.0",
3270            "info": { "title": "Test API", "version": "1.0.0" },
3271            "paths": {
3272                "/users/{id}": {
3273                    "post": {
3274                        "parameters": [
3275                            { "name": "id", "in": "path", "required": true, "schema": {"type": "string"} },
3276                            { "name": "q",  "in": "query", "required": false, "schema": {"type": "integer"} }
3277                        ],
3278                        "requestBody": {
3279                            "content": {
3280                                "application/json": {
3281                                    "schema": {
3282                                        "type": "object",
3283                                        "required": ["email", "website"],
3284                                        "properties": {
3285                                            "email":   {"type": "string", "format": "email"},
3286                                            "website": {"type": "string", "format": "uri"}
3287                                        }
3288                                    }
3289                                }
3290                            }
3291                        },
3292                        "responses": {"200": {"description": "ok"}}
3293                    }
3294                }
3295            }
3296        });
3297
3298        let registry = create_registry_from_json(spec_json).unwrap();
3299        let mut path_params = Map::new();
3300        path_params.insert("id".to_string(), json!("abc"));
3301        let mut query_params = Map::new();
3302        query_params.insert("q".to_string(), json!(123));
3303
3304        // valid body
3305        let body = json!({"email":"a@b.co","website":"https://example.com"});
3306        assert!(registry
3307            .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3308            .is_ok());
3309
3310        // invalid email
3311        let bad_email = json!({"email":"not-an-email","website":"https://example.com"});
3312        assert!(registry
3313            .validate_request_with(
3314                "/users/{id}",
3315                "POST",
3316                &path_params,
3317                &query_params,
3318                Some(&bad_email)
3319            )
3320            .is_err());
3321
3322        // missing required path param
3323        let empty_path_params = Map::new();
3324        assert!(registry
3325            .validate_request_with(
3326                "/users/{id}",
3327                "POST",
3328                &empty_path_params,
3329                &query_params,
3330                Some(&body)
3331            )
3332            .is_err());
3333    }
3334
3335    #[tokio::test]
3336    async fn test_ref_resolution_for_params_and_body() {
3337        let spec_json = json!({
3338            "openapi": "3.0.0",
3339            "info": { "title": "Ref API", "version": "1.0.0" },
3340            "components": {
3341                "schemas": {
3342                    "EmailWebsite": {
3343                        "type": "object",
3344                        "required": ["email", "website"],
3345                        "properties": {
3346                            "email":   {"type": "string", "format": "email"},
3347                            "website": {"type": "string", "format": "uri"}
3348                        }
3349                    }
3350                },
3351                "parameters": {
3352                    "PathId": {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}},
3353                    "QueryQ": {"name": "q",  "in": "query", "required": false, "schema": {"type": "integer"}}
3354                },
3355                "requestBodies": {
3356                    "CreateUser": {
3357                        "content": {
3358                            "application/json": {
3359                                "schema": {"$ref": "#/components/schemas/EmailWebsite"}
3360                            }
3361                        }
3362                    }
3363                }
3364            },
3365            "paths": {
3366                "/users/{id}": {
3367                    "post": {
3368                        "parameters": [
3369                            {"$ref": "#/components/parameters/PathId"},
3370                            {"$ref": "#/components/parameters/QueryQ"}
3371                        ],
3372                        "requestBody": {"$ref": "#/components/requestBodies/CreateUser"},
3373                        "responses": {"200": {"description": "ok"}}
3374                    }
3375                }
3376            }
3377        });
3378
3379        let registry = create_registry_from_json(spec_json).unwrap();
3380        let mut path_params = Map::new();
3381        path_params.insert("id".to_string(), json!("abc"));
3382        let mut query_params = Map::new();
3383        query_params.insert("q".to_string(), json!(7));
3384
3385        let body = json!({"email":"user@example.com","website":"https://example.com"});
3386        assert!(registry
3387            .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3388            .is_ok());
3389
3390        let bad = json!({"email":"nope","website":"https://example.com"});
3391        assert!(registry
3392            .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&bad))
3393            .is_err());
3394    }
3395
3396    #[tokio::test]
3397    async fn test_header_cookie_and_query_coercion() {
3398        let spec_json = json!({
3399            "openapi": "3.0.0",
3400            "info": { "title": "Params API", "version": "1.0.0" },
3401            "paths": {
3402                "/items": {
3403                    "get": {
3404                        "parameters": [
3405                            {"name": "X-Flag", "in": "header", "required": true, "schema": {"type": "boolean"}},
3406                            {"name": "session", "in": "cookie", "required": true, "schema": {"type": "string"}},
3407                            {"name": "ids", "in": "query", "required": false, "schema": {"type": "array", "items": {"type": "integer"}}}
3408                        ],
3409                        "responses": {"200": {"description": "ok"}}
3410                    }
3411                }
3412            }
3413        });
3414
3415        let registry = create_registry_from_json(spec_json).unwrap();
3416
3417        let path_params = Map::new();
3418        let mut query_params = Map::new();
3419        // comma-separated string for array should coerce
3420        query_params.insert("ids".to_string(), json!("1,2,3"));
3421        let mut header_params = Map::new();
3422        header_params.insert("X-Flag".to_string(), json!("true"));
3423        let mut cookie_params = Map::new();
3424        cookie_params.insert("session".to_string(), json!("abc123"));
3425
3426        assert!(registry
3427            .validate_request_with_all(
3428                "/items",
3429                "GET",
3430                &path_params,
3431                &query_params,
3432                &header_params,
3433                &cookie_params,
3434                None
3435            )
3436            .is_ok());
3437
3438        // Missing required cookie
3439        let empty_cookie = Map::new();
3440        assert!(registry
3441            .validate_request_with_all(
3442                "/items",
3443                "GET",
3444                &path_params,
3445                &query_params,
3446                &header_params,
3447                &empty_cookie,
3448                None
3449            )
3450            .is_err());
3451
3452        // Bad boolean header value (cannot coerce)
3453        let mut bad_header = Map::new();
3454        bad_header.insert("X-Flag".to_string(), json!("notabool"));
3455        assert!(registry
3456            .validate_request_with_all(
3457                "/items",
3458                "GET",
3459                &path_params,
3460                &query_params,
3461                &bad_header,
3462                &cookie_params,
3463                None
3464            )
3465            .is_err());
3466    }
3467
3468    #[tokio::test]
3469    async fn test_query_styles_space_pipe_deepobject() {
3470        let spec_json = json!({
3471            "openapi": "3.0.0",
3472            "info": { "title": "Query Styles API", "version": "1.0.0" },
3473            "paths": {"/search": {"get": {
3474                "parameters": [
3475                    {"name":"tags","in":"query","style":"spaceDelimited","schema":{"type":"array","items":{"type":"string"}}},
3476                    {"name":"ids","in":"query","style":"pipeDelimited","schema":{"type":"array","items":{"type":"integer"}}},
3477                    {"name":"filter","in":"query","style":"deepObject","schema":{"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}}
3478                ],
3479                "responses": {"200": {"description":"ok"}}
3480            }} }
3481        });
3482
3483        let registry = create_registry_from_json(spec_json).unwrap();
3484
3485        let path_params = Map::new();
3486        let mut query = Map::new();
3487        query.insert("tags".into(), json!("alpha beta gamma"));
3488        query.insert("ids".into(), json!("1|2|3"));
3489        query.insert("filter[color]".into(), json!("red"));
3490
3491        assert!(registry
3492            .validate_request_with("/search", "GET", &path_params, &query, None)
3493            .is_ok());
3494    }
3495
3496    #[tokio::test]
3497    async fn test_oneof_anyof_allof_validation() {
3498        let spec_json = json!({
3499            "openapi": "3.0.0",
3500            "info": { "title": "Composite API", "version": "1.0.0" },
3501            "paths": {
3502                "/composite": {
3503                    "post": {
3504                        "requestBody": {
3505                            "content": {
3506                                "application/json": {
3507                                    "schema": {
3508                                        "allOf": [
3509                                            {"type": "object", "required": ["base"], "properties": {"base": {"type": "string"}}}
3510                                        ],
3511                                        "oneOf": [
3512                                            {"type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"], "not": {"required": ["b"]}},
3513                                            {"type": "object", "properties": {"b": {"type": "integer"}}, "required": ["b"], "not": {"required": ["a"]}}
3514                                        ],
3515                                        "anyOf": [
3516                                            {"type": "object", "properties": {"flag": {"type": "boolean"}}, "required": ["flag"]},
3517                                            {"type": "object", "properties": {"extra": {"type": "string"}}, "required": ["extra"]}
3518                                        ]
3519                                    }
3520                                }
3521                            }
3522                        },
3523                        "responses": {"200": {"description": "ok"}}
3524                    }
3525                }
3526            }
3527        });
3528
3529        let registry = create_registry_from_json(spec_json).unwrap();
3530        // valid: satisfies base via allOf, exactly one of a/b, and at least one of flag/extra
3531        let ok = json!({"base": "x", "a": 1, "flag": true});
3532        assert!(registry
3533            .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&ok))
3534            .is_ok());
3535
3536        // invalid oneOf: both a and b present
3537        let bad_oneof = json!({"base": "x", "a": 1, "b": 2, "flag": false});
3538        assert!(registry
3539            .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_oneof))
3540            .is_err());
3541
3542        // invalid anyOf: none of flag/extra present
3543        let bad_anyof = json!({"base": "x", "a": 1});
3544        assert!(registry
3545            .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_anyof))
3546            .is_err());
3547
3548        // invalid allOf: missing base
3549        let bad_allof = json!({"a": 1, "flag": true});
3550        assert!(registry
3551            .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_allof))
3552            .is_err());
3553    }
3554
3555    /// Round 19 — regression for Srikanth's vCenter spec which has
3556    /// component schemas with dotted names like
3557    /// `Esx.Settings.Inventory.EntitySpec`. The route-handler's body
3558    /// validator used to build a naked `jsonschema` validator with no
3559    /// `components` context, so nested `$ref` strings to
3560    /// `#/components/schemas/X` failed with "Pointer does not exist".
3561    /// Round 18.3 fixed the bench + `validate_request_body` paths;
3562    /// round 19 fixes this third path in `openapi_routes`.
3563    #[tokio::test]
3564    async fn dotted_schema_ref_resolves_in_route_validator() {
3565        let spec_json = json!({
3566            "openapi": "3.0.0",
3567            "info": { "title": "Dotted", "version": "1.0.0" },
3568            "paths": {
3569                "/x": {
3570                    "post": {
3571                        "requestBody": {
3572                            "required": true,
3573                            "content": {
3574                                "application/json": {
3575                                    "schema": {
3576                                        "$ref": "#/components/schemas/Esx.Settings.Inventory.EntitySpec"
3577                                    }
3578                                }
3579                            }
3580                        },
3581                        "responses": {"200": {"description": "ok"}}
3582                    }
3583                }
3584            },
3585            "components": {
3586                "schemas": {
3587                    "Esx.Settings.Inventory.EntitySpec": {
3588                        "type": "object",
3589                        "required": ["type"],
3590                        "properties": {"type": {"type": "string"}}
3591                    }
3592                }
3593            }
3594        });
3595        let registry = create_registry_from_json(spec_json).unwrap();
3596        // Pre-fix: this errored with `Pointer '/components/schemas/Esx.Settings.Inventory.EntitySpec' does not exist`.
3597        // Post-fix: the dotted ref resolves and the body validates.
3598        let good = json!({"type": "HOST"});
3599        let res =
3600            registry.validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&good));
3601        assert!(res.is_ok(), "valid body should pass; got {res:?}");
3602        // And a bad body should still error from inside the resolved schema, not from a build failure.
3603        let bad = json!({"unrelated": 1});
3604        let err = registry
3605            .validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&bad))
3606            .unwrap_err();
3607        let msg = format!("{err}");
3608        assert!(
3609            !msg.contains("Pointer") || !msg.contains("does not exist"),
3610            "should not be a pointer-resolution failure; got: {msg}"
3611        );
3612    }
3613
3614    #[tokio::test]
3615    async fn test_overrides_warn_mode_allows_invalid() {
3616        // Spec with a POST route expecting an integer query param
3617        let spec_json = json!({
3618            "openapi": "3.0.0",
3619            "info": { "title": "Overrides API", "version": "1.0.0" },
3620            "paths": {"/things": {"post": {
3621                "parameters": [{"name":"q","in":"query","required":true,"schema":{"type":"integer"}}],
3622                "responses": {"200": {"description":"ok"}}
3623            }}}
3624        });
3625
3626        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3627        let mut overrides = HashMap::new();
3628        overrides.insert("POST /things".to_string(), ValidationMode::Warn);
3629        let registry = OpenApiRouteRegistry::new_with_options(
3630            spec,
3631            ValidationOptions {
3632                request_mode: ValidationMode::Enforce,
3633                aggregate_errors: true,
3634                validate_responses: false,
3635                overrides,
3636                admin_skip_prefixes: vec![],
3637                response_template_expand: false,
3638                validation_status: None,
3639            },
3640        );
3641
3642        // Invalid q (missing) should warn, not error
3643        let ok = registry.validate_request_with("/things", "POST", &Map::new(), &Map::new(), None);
3644        assert!(ok.is_ok());
3645    }
3646
3647    #[tokio::test]
3648    async fn test_admin_skip_prefix_short_circuit() {
3649        let spec_json = json!({
3650            "openapi": "3.0.0",
3651            "info": { "title": "Skip API", "version": "1.0.0" },
3652            "paths": {}
3653        });
3654        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3655        let registry = OpenApiRouteRegistry::new_with_options(
3656            spec,
3657            ValidationOptions {
3658                request_mode: ValidationMode::Enforce,
3659                aggregate_errors: true,
3660                validate_responses: false,
3661                overrides: HashMap::new(),
3662                admin_skip_prefixes: vec!["/admin".into()],
3663                response_template_expand: false,
3664                validation_status: None,
3665            },
3666        );
3667
3668        // No route exists for this, but skip prefix means it is accepted
3669        let res = registry.validate_request_with_all(
3670            "/admin/__mockforge/health",
3671            "GET",
3672            &Map::new(),
3673            &Map::new(),
3674            &Map::new(),
3675            &Map::new(),
3676            None,
3677        );
3678        assert!(res.is_ok());
3679    }
3680
3681    #[test]
3682    fn test_path_conversion() {
3683        assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users"), "/users");
3684        assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users/{id}"), "/users/{id}");
3685        assert_eq!(
3686            OpenApiRouteRegistry::convert_path_to_axum("/users/{id}/posts/{postId}"),
3687            "/users/{id}/posts/{postId}"
3688        );
3689    }
3690
3691    #[test]
3692    fn test_validation_options_default() {
3693        let options = ValidationOptions::default();
3694        assert!(matches!(options.request_mode, ValidationMode::Enforce));
3695        assert!(options.aggregate_errors);
3696        assert!(!options.validate_responses);
3697        assert!(options.overrides.is_empty());
3698        assert!(options.admin_skip_prefixes.is_empty());
3699        assert!(!options.response_template_expand);
3700        assert!(options.validation_status.is_none());
3701    }
3702
3703    #[test]
3704    fn test_validation_mode_variants() {
3705        // Test that all variants can be created and compared
3706        let disabled = ValidationMode::Disabled;
3707        let warn = ValidationMode::Warn;
3708        let enforce = ValidationMode::Enforce;
3709        let default = ValidationMode::default();
3710
3711        // Test that default is Warn
3712        assert!(matches!(default, ValidationMode::Warn));
3713
3714        // Test that variants are distinct
3715        assert!(!matches!(disabled, ValidationMode::Warn));
3716        assert!(!matches!(warn, ValidationMode::Enforce));
3717        assert!(!matches!(enforce, ValidationMode::Disabled));
3718    }
3719
3720    #[test]
3721    fn test_registry_spec_accessor() {
3722        let spec_json = json!({
3723            "openapi": "3.0.0",
3724            "info": {
3725                "title": "Test API",
3726                "version": "1.0.0"
3727            },
3728            "paths": {}
3729        });
3730        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3731        let registry = OpenApiRouteRegistry::new(spec.clone());
3732
3733        // Test spec() accessor
3734        let accessed_spec = registry.spec();
3735        assert_eq!(accessed_spec.title(), "Test API");
3736    }
3737
3738    #[test]
3739    fn test_clone_for_validation() {
3740        let spec_json = json!({
3741            "openapi": "3.0.0",
3742            "info": {
3743                "title": "Test API",
3744                "version": "1.0.0"
3745            },
3746            "paths": {
3747                "/users": {
3748                    "get": {
3749                        "responses": {
3750                            "200": {
3751                                "description": "Success"
3752                            }
3753                        }
3754                    }
3755                }
3756            }
3757        });
3758        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3759        let registry = OpenApiRouteRegistry::new(spec);
3760
3761        // Test clone_for_validation
3762        let cloned = registry.clone_for_validation();
3763        assert_eq!(cloned.routes().len(), registry.routes().len());
3764        assert_eq!(cloned.spec().title(), registry.spec().title());
3765    }
3766
3767    #[test]
3768    fn test_with_custom_fixture_loader() {
3769        let temp_dir = TempDir::new().unwrap();
3770        let spec_json = json!({
3771            "openapi": "3.0.0",
3772            "info": {
3773                "title": "Test API",
3774                "version": "1.0.0"
3775            },
3776            "paths": {}
3777        });
3778        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3779        let registry = OpenApiRouteRegistry::new(spec);
3780        let original_routes_len = registry.routes().len();
3781
3782        // Test with_custom_fixture_loader
3783        let custom_loader = Arc::new(crate::custom_fixture::CustomFixtureLoader::new(
3784            temp_dir.path().to_path_buf(),
3785            true,
3786        ));
3787        let registry_with_loader = registry.with_custom_fixture_loader(custom_loader);
3788
3789        // Verify the loader was set (we can't directly access it, but we can test it doesn't panic)
3790        assert_eq!(registry_with_loader.routes().len(), original_routes_len);
3791    }
3792
3793    #[test]
3794    fn test_get_route() {
3795        let spec_json = json!({
3796            "openapi": "3.0.0",
3797            "info": {
3798                "title": "Test API",
3799                "version": "1.0.0"
3800            },
3801            "paths": {
3802                "/users": {
3803                    "get": {
3804                        "operationId": "getUsers",
3805                        "responses": {
3806                            "200": {
3807                                "description": "Success"
3808                            }
3809                        }
3810                    },
3811                    "post": {
3812                        "operationId": "createUser",
3813                        "responses": {
3814                            "201": {
3815                                "description": "Created"
3816                            }
3817                        }
3818                    }
3819                }
3820            }
3821        });
3822        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3823        let registry = OpenApiRouteRegistry::new(spec);
3824
3825        // Test get_route for existing route
3826        let route = registry.get_route("/users", "GET");
3827        assert!(route.is_some());
3828        assert_eq!(route.unwrap().method, "GET");
3829        assert_eq!(route.unwrap().path, "/users");
3830
3831        // Test get_route for non-existent route
3832        let route = registry.get_route("/nonexistent", "GET");
3833        assert!(route.is_none());
3834
3835        // Test get_route for different method
3836        let route = registry.get_route("/users", "POST");
3837        assert!(route.is_some());
3838        assert_eq!(route.unwrap().method, "POST");
3839    }
3840
3841    #[test]
3842    fn test_get_routes_for_path() {
3843        let spec_json = json!({
3844            "openapi": "3.0.0",
3845            "info": {
3846                "title": "Test API",
3847                "version": "1.0.0"
3848            },
3849            "paths": {
3850                "/users": {
3851                    "get": {
3852                        "responses": {
3853                            "200": {
3854                                "description": "Success"
3855                            }
3856                        }
3857                    },
3858                    "post": {
3859                        "responses": {
3860                            "201": {
3861                                "description": "Created"
3862                            }
3863                        }
3864                    },
3865                    "put": {
3866                        "responses": {
3867                            "200": {
3868                                "description": "Success"
3869                            }
3870                        }
3871                    }
3872                },
3873                "/posts": {
3874                    "get": {
3875                        "responses": {
3876                            "200": {
3877                                "description": "Success"
3878                            }
3879                        }
3880                    }
3881                }
3882            }
3883        });
3884        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3885        let registry = OpenApiRouteRegistry::new(spec);
3886
3887        // Test get_routes_for_path with multiple methods
3888        let routes = registry.get_routes_for_path("/users");
3889        assert_eq!(routes.len(), 3);
3890        let methods: Vec<&str> = routes.iter().map(|r| r.method.as_str()).collect();
3891        assert!(methods.contains(&"GET"));
3892        assert!(methods.contains(&"POST"));
3893        assert!(methods.contains(&"PUT"));
3894
3895        // Test get_routes_for_path with single method
3896        let routes = registry.get_routes_for_path("/posts");
3897        assert_eq!(routes.len(), 1);
3898        assert_eq!(routes[0].method, "GET");
3899
3900        // Test get_routes_for_path with non-existent path
3901        let routes = registry.get_routes_for_path("/nonexistent");
3902        assert!(routes.is_empty());
3903    }
3904
3905    #[test]
3906    fn test_new_vs_new_with_options() {
3907        let spec_json = json!({
3908            "openapi": "3.0.0",
3909            "info": {
3910                "title": "Test API",
3911                "version": "1.0.0"
3912            },
3913            "paths": {}
3914        });
3915        let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3916        let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3917
3918        // Test new() - uses environment-based options
3919        let registry1 = OpenApiRouteRegistry::new(spec1);
3920        assert_eq!(registry1.spec().title(), "Test API");
3921
3922        // Test new_with_options() - uses explicit options
3923        let options = ValidationOptions {
3924            request_mode: ValidationMode::Disabled,
3925            aggregate_errors: false,
3926            validate_responses: true,
3927            overrides: HashMap::new(),
3928            admin_skip_prefixes: vec!["/admin".to_string()],
3929            response_template_expand: true,
3930            validation_status: Some(422),
3931        };
3932        let registry2 = OpenApiRouteRegistry::new_with_options(spec2, options);
3933        assert_eq!(registry2.spec().title(), "Test API");
3934    }
3935
3936    #[test]
3937    fn test_new_with_env_vs_new() {
3938        let spec_json = json!({
3939            "openapi": "3.0.0",
3940            "info": {
3941                "title": "Test API",
3942                "version": "1.0.0"
3943            },
3944            "paths": {}
3945        });
3946        let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3947        let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3948
3949        // Test new() calls new_with_env()
3950        let registry1 = OpenApiRouteRegistry::new(spec1);
3951
3952        // Test new_with_env() directly
3953        let registry2 = OpenApiRouteRegistry::new_with_env(spec2);
3954
3955        // Both should create valid registries
3956        assert_eq!(registry1.spec().title(), "Test API");
3957        assert_eq!(registry2.spec().title(), "Test API");
3958    }
3959
3960    #[test]
3961    fn test_validation_options_custom() {
3962        let options = ValidationOptions {
3963            request_mode: ValidationMode::Warn,
3964            aggregate_errors: false,
3965            validate_responses: true,
3966            overrides: {
3967                let mut map = HashMap::new();
3968                map.insert("getUsers".to_string(), ValidationMode::Disabled);
3969                map
3970            },
3971            admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
3972            response_template_expand: true,
3973            validation_status: Some(422),
3974        };
3975
3976        assert!(matches!(options.request_mode, ValidationMode::Warn));
3977        assert!(!options.aggregate_errors);
3978        assert!(options.validate_responses);
3979        assert_eq!(options.overrides.len(), 1);
3980        assert_eq!(options.admin_skip_prefixes.len(), 2);
3981        assert!(options.response_template_expand);
3982        assert_eq!(options.validation_status, Some(422));
3983    }
3984
3985    #[test]
3986    fn test_validation_mode_default_standalone() {
3987        let mode = ValidationMode::default();
3988        assert!(matches!(mode, ValidationMode::Warn));
3989    }
3990
3991    #[test]
3992    fn test_validation_mode_clone() {
3993        let mode1 = ValidationMode::Enforce;
3994        let mode2 = mode1.clone();
3995        assert!(matches!(mode1, ValidationMode::Enforce));
3996        assert!(matches!(mode2, ValidationMode::Enforce));
3997    }
3998
3999    #[test]
4000    fn test_validation_mode_debug() {
4001        let mode = ValidationMode::Disabled;
4002        let debug_str = format!("{:?}", mode);
4003        assert!(debug_str.contains("Disabled") || debug_str.contains("ValidationMode"));
4004    }
4005
4006    #[test]
4007    fn test_validation_options_clone() {
4008        let options1 = ValidationOptions {
4009            request_mode: ValidationMode::Warn,
4010            aggregate_errors: true,
4011            validate_responses: false,
4012            overrides: HashMap::new(),
4013            admin_skip_prefixes: vec![],
4014            response_template_expand: false,
4015            validation_status: None,
4016        };
4017        let options2 = options1.clone();
4018        assert!(matches!(options2.request_mode, ValidationMode::Warn));
4019        assert_eq!(options1.aggregate_errors, options2.aggregate_errors);
4020    }
4021
4022    #[test]
4023    fn test_validation_options_debug() {
4024        let options = ValidationOptions::default();
4025        let debug_str = format!("{:?}", options);
4026        assert!(debug_str.contains("ValidationOptions"));
4027    }
4028
4029    #[test]
4030    fn test_validation_options_with_all_fields() {
4031        let mut overrides = HashMap::new();
4032        overrides.insert("op1".to_string(), ValidationMode::Disabled);
4033        overrides.insert("op2".to_string(), ValidationMode::Warn);
4034
4035        let options = ValidationOptions {
4036            request_mode: ValidationMode::Enforce,
4037            aggregate_errors: false,
4038            validate_responses: true,
4039            overrides: overrides.clone(),
4040            admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
4041            response_template_expand: true,
4042            validation_status: Some(422),
4043        };
4044
4045        assert!(matches!(options.request_mode, ValidationMode::Enforce));
4046        assert!(!options.aggregate_errors);
4047        assert!(options.validate_responses);
4048        assert_eq!(options.overrides.len(), 2);
4049        assert_eq!(options.admin_skip_prefixes.len(), 2);
4050        assert!(options.response_template_expand);
4051        assert_eq!(options.validation_status, Some(422));
4052    }
4053
4054    #[test]
4055    fn test_openapi_route_registry_clone() {
4056        let spec_json = json!({
4057            "openapi": "3.0.0",
4058            "info": { "title": "Test API", "version": "1.0.0" },
4059            "paths": {}
4060        });
4061        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4062        let registry1 = OpenApiRouteRegistry::new(spec);
4063        let registry2 = registry1.clone();
4064        assert_eq!(registry1.spec().title(), registry2.spec().title());
4065    }
4066
4067    #[test]
4068    fn test_validation_mode_serialization() {
4069        let mode = ValidationMode::Enforce;
4070        let json = serde_json::to_string(&mode).unwrap();
4071        assert!(json.contains("Enforce") || json.contains("enforce"));
4072    }
4073
4074    #[test]
4075    fn test_validation_mode_deserialization() {
4076        let json = r#""Disabled""#;
4077        let mode: ValidationMode = serde_json::from_str(json).unwrap();
4078        assert!(matches!(mode, ValidationMode::Disabled));
4079    }
4080
4081    #[test]
4082    fn test_validation_options_default_values() {
4083        let options = ValidationOptions::default();
4084        assert!(matches!(options.request_mode, ValidationMode::Enforce));
4085        assert!(options.aggregate_errors);
4086        assert!(!options.validate_responses);
4087        assert!(options.overrides.is_empty());
4088        assert!(options.admin_skip_prefixes.is_empty());
4089        assert!(!options.response_template_expand);
4090        assert_eq!(options.validation_status, None);
4091    }
4092
4093    #[test]
4094    fn test_validation_mode_all_variants() {
4095        let disabled = ValidationMode::Disabled;
4096        let warn = ValidationMode::Warn;
4097        let enforce = ValidationMode::Enforce;
4098
4099        assert!(matches!(disabled, ValidationMode::Disabled));
4100        assert!(matches!(warn, ValidationMode::Warn));
4101        assert!(matches!(enforce, ValidationMode::Enforce));
4102    }
4103
4104    #[test]
4105    fn test_validation_options_with_overrides() {
4106        let mut overrides = HashMap::new();
4107        overrides.insert("operation1".to_string(), ValidationMode::Disabled);
4108        overrides.insert("operation2".to_string(), ValidationMode::Warn);
4109
4110        let options = ValidationOptions {
4111            request_mode: ValidationMode::Enforce,
4112            aggregate_errors: true,
4113            validate_responses: false,
4114            overrides,
4115            admin_skip_prefixes: vec![],
4116            response_template_expand: false,
4117            validation_status: None,
4118        };
4119
4120        assert_eq!(options.overrides.len(), 2);
4121        assert!(matches!(options.overrides.get("operation1"), Some(ValidationMode::Disabled)));
4122        assert!(matches!(options.overrides.get("operation2"), Some(ValidationMode::Warn)));
4123    }
4124
4125    #[test]
4126    fn test_validation_options_with_admin_skip_prefixes() {
4127        let options = ValidationOptions {
4128            request_mode: ValidationMode::Enforce,
4129            aggregate_errors: true,
4130            validate_responses: false,
4131            overrides: HashMap::new(),
4132            admin_skip_prefixes: vec![
4133                "/admin".to_string(),
4134                "/internal".to_string(),
4135                "/debug".to_string(),
4136            ],
4137            response_template_expand: false,
4138            validation_status: None,
4139        };
4140
4141        assert_eq!(options.admin_skip_prefixes.len(), 3);
4142        assert!(options.admin_skip_prefixes.contains(&"/admin".to_string()));
4143        assert!(options.admin_skip_prefixes.contains(&"/internal".to_string()));
4144        assert!(options.admin_skip_prefixes.contains(&"/debug".to_string()));
4145    }
4146
4147    #[test]
4148    fn test_validation_options_with_validation_status() {
4149        let options1 = ValidationOptions {
4150            request_mode: ValidationMode::Enforce,
4151            aggregate_errors: true,
4152            validate_responses: false,
4153            overrides: HashMap::new(),
4154            admin_skip_prefixes: vec![],
4155            response_template_expand: false,
4156            validation_status: Some(400),
4157        };
4158
4159        let options2 = ValidationOptions {
4160            request_mode: ValidationMode::Enforce,
4161            aggregate_errors: true,
4162            validate_responses: false,
4163            overrides: HashMap::new(),
4164            admin_skip_prefixes: vec![],
4165            response_template_expand: false,
4166            validation_status: Some(422),
4167        };
4168
4169        assert_eq!(options1.validation_status, Some(400));
4170        assert_eq!(options2.validation_status, Some(422));
4171    }
4172
4173    #[test]
4174    fn test_validate_request_with_disabled_mode() {
4175        // Test validation with disabled mode (lines 1001-1007)
4176        let spec_json = json!({
4177            "openapi": "3.0.0",
4178            "info": {"title": "Test API", "version": "1.0.0"},
4179            "paths": {
4180                "/users": {
4181                    "get": {
4182                        "responses": {"200": {"description": "OK"}}
4183                    }
4184                }
4185            }
4186        });
4187        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4188        let options = ValidationOptions {
4189            request_mode: ValidationMode::Disabled,
4190            ..Default::default()
4191        };
4192        let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4193
4194        // Should pass validation when disabled (lines 1002-1003, 1005-1007)
4195        let result = registry.validate_request_with_all(
4196            "/users",
4197            "GET",
4198            &Map::new(),
4199            &Map::new(),
4200            &Map::new(),
4201            &Map::new(),
4202            None,
4203        );
4204        assert!(result.is_ok());
4205    }
4206
4207    #[test]
4208    fn test_validate_request_with_warn_mode() {
4209        // Test validation with warn mode (lines 1162-1166)
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 options = ValidationOptions {
4237            request_mode: ValidationMode::Warn,
4238            ..Default::default()
4239        };
4240        let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4241
4242        // Should pass with warnings when body is missing (lines 1162-1166)
4243        let result = registry.validate_request_with_all(
4244            "/users",
4245            "POST",
4246            &Map::new(),
4247            &Map::new(),
4248            &Map::new(),
4249            &Map::new(),
4250            None, // Missing required body
4251        );
4252        assert!(result.is_ok()); // Warn mode doesn't fail
4253    }
4254
4255    #[test]
4256    fn test_validate_request_body_validation_error() {
4257        // Test request body validation error path (lines 1072-1091)
4258        let spec_json = json!({
4259            "openapi": "3.0.0",
4260            "info": {"title": "Test API", "version": "1.0.0"},
4261            "paths": {
4262                "/users": {
4263                    "post": {
4264                        "requestBody": {
4265                            "required": true,
4266                            "content": {
4267                                "application/json": {
4268                                    "schema": {
4269                                        "type": "object",
4270                                        "required": ["name"],
4271                                        "properties": {
4272                                            "name": {"type": "string"}
4273                                        }
4274                                    }
4275                                }
4276                            }
4277                        },
4278                        "responses": {"200": {"description": "OK"}}
4279                    }
4280                }
4281            }
4282        });
4283        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4284        let registry = OpenApiRouteRegistry::new(spec);
4285
4286        // Should fail validation when body is missing (lines 1088-1091)
4287        let result = registry.validate_request_with_all(
4288            "/users",
4289            "POST",
4290            &Map::new(),
4291            &Map::new(),
4292            &Map::new(),
4293            &Map::new(),
4294            None, // Missing required body
4295        );
4296        assert!(result.is_err());
4297    }
4298
4299    #[test]
4300    fn test_validate_request_body_schema_validation_error() {
4301        // Test request body schema validation error (lines 1038-1049)
4302        let spec_json = json!({
4303            "openapi": "3.0.0",
4304            "info": {"title": "Test API", "version": "1.0.0"},
4305            "paths": {
4306                "/users": {
4307                    "post": {
4308                        "requestBody": {
4309                            "required": true,
4310                            "content": {
4311                                "application/json": {
4312                                    "schema": {
4313                                        "type": "object",
4314                                        "required": ["name"],
4315                                        "properties": {
4316                                            "name": {"type": "string"}
4317                                        }
4318                                    }
4319                                }
4320                            }
4321                        },
4322                        "responses": {"200": {"description": "OK"}}
4323                    }
4324                }
4325            }
4326        });
4327        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4328        let registry = OpenApiRouteRegistry::new(spec);
4329
4330        // Should fail validation when body doesn't match schema (lines 1038-1049)
4331        let invalid_body = json!({}); // Missing required "name" field
4332        let result = registry.validate_request_with_all(
4333            "/users",
4334            "POST",
4335            &Map::new(),
4336            &Map::new(),
4337            &Map::new(),
4338            &Map::new(),
4339            Some(&invalid_body),
4340        );
4341        assert!(result.is_err());
4342    }
4343
4344    #[test]
4345    fn test_validate_request_body_referenced_schema_error() {
4346        // Test request body with referenced schema that can't be resolved (lines 1070-1076)
4347        let spec_json = json!({
4348            "openapi": "3.0.0",
4349            "info": {"title": "Test API", "version": "1.0.0"},
4350            "paths": {
4351                "/users": {
4352                    "post": {
4353                        "requestBody": {
4354                            "required": true,
4355                            "content": {
4356                                "application/json": {
4357                                    "schema": {
4358                                        "$ref": "#/components/schemas/NonExistentSchema"
4359                                    }
4360                                }
4361                            }
4362                        },
4363                        "responses": {"200": {"description": "OK"}}
4364                    }
4365                }
4366            },
4367            "components": {}
4368        });
4369        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4370        let registry = OpenApiRouteRegistry::new(spec);
4371
4372        // Should fail validation when schema reference can't be resolved (lines 1070-1076)
4373        let body = json!({"name": "test"});
4374        let result = registry.validate_request_with_all(
4375            "/users",
4376            "POST",
4377            &Map::new(),
4378            &Map::new(),
4379            &Map::new(),
4380            &Map::new(),
4381            Some(&body),
4382        );
4383        assert!(result.is_err());
4384    }
4385
4386    #[test]
4387    fn test_validate_request_body_referenced_request_body_error() {
4388        // Test request body with referenced request body that can't be resolved (lines 1081-1087)
4389        let spec_json = json!({
4390            "openapi": "3.0.0",
4391            "info": {"title": "Test API", "version": "1.0.0"},
4392            "paths": {
4393                "/users": {
4394                    "post": {
4395                        "requestBody": {
4396                            "$ref": "#/components/requestBodies/NonExistentRequestBody"
4397                        },
4398                        "responses": {"200": {"description": "OK"}}
4399                    }
4400                }
4401            },
4402            "components": {}
4403        });
4404        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4405        let registry = OpenApiRouteRegistry::new(spec);
4406
4407        // Should fail validation when request body reference can't be resolved (lines 1081-1087)
4408        let body = json!({"name": "test"});
4409        let result = registry.validate_request_with_all(
4410            "/users",
4411            "POST",
4412            &Map::new(),
4413            &Map::new(),
4414            &Map::new(),
4415            &Map::new(),
4416            Some(&body),
4417        );
4418        assert!(result.is_err());
4419    }
4420
4421    #[test]
4422    fn test_validate_request_body_provided_when_not_expected() {
4423        // Test body provided when not expected (lines 1092-1094)
4424        let spec_json = json!({
4425            "openapi": "3.0.0",
4426            "info": {"title": "Test API", "version": "1.0.0"},
4427            "paths": {
4428                "/users": {
4429                    "get": {
4430                        "responses": {"200": {"description": "OK"}}
4431                    }
4432                }
4433            }
4434        });
4435        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4436        let registry = OpenApiRouteRegistry::new(spec);
4437
4438        // Should accept body even when not expected (lines 1092-1094)
4439        let body = json!({"extra": "data"});
4440        let result = registry.validate_request_with_all(
4441            "/users",
4442            "GET",
4443            &Map::new(),
4444            &Map::new(),
4445            &Map::new(),
4446            &Map::new(),
4447            Some(&body),
4448        );
4449        // Should not error - just logs debug message
4450        assert!(result.is_ok());
4451    }
4452
4453    #[test]
4454    fn test_get_operation() {
4455        // Test get_operation method (lines 1196-1205)
4456        let spec_json = json!({
4457            "openapi": "3.0.0",
4458            "info": {"title": "Test API", "version": "1.0.0"},
4459            "paths": {
4460                "/users": {
4461                    "get": {
4462                        "operationId": "getUsers",
4463                        "summary": "Get users",
4464                        "responses": {"200": {"description": "OK"}}
4465                    }
4466                }
4467            }
4468        });
4469        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4470        let registry = OpenApiRouteRegistry::new(spec);
4471
4472        // Should return operation details (lines 1196-1205)
4473        let operation = registry.get_operation("/users", "GET");
4474        assert!(operation.is_some());
4475        assert_eq!(operation.unwrap().method, "GET");
4476
4477        // Should return None for non-existent route
4478        assert!(registry.get_operation("/nonexistent", "GET").is_none());
4479    }
4480
4481    #[test]
4482    fn test_extract_path_parameters() {
4483        // Test extract_path_parameters method (lines 1208-1223)
4484        let spec_json = json!({
4485            "openapi": "3.0.0",
4486            "info": {"title": "Test API", "version": "1.0.0"},
4487            "paths": {
4488                "/users/{id}": {
4489                    "get": {
4490                        "parameters": [
4491                            {
4492                                "name": "id",
4493                                "in": "path",
4494                                "required": true,
4495                                "schema": {"type": "string"}
4496                            }
4497                        ],
4498                        "responses": {"200": {"description": "OK"}}
4499                    }
4500                }
4501            }
4502        });
4503        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4504        let registry = OpenApiRouteRegistry::new(spec);
4505
4506        // Should extract path parameters (lines 1208-1223)
4507        let params = registry.extract_path_parameters("/users/123", "GET");
4508        assert_eq!(params.get("id"), Some(&"123".to_string()));
4509
4510        // Should return empty map for non-matching path
4511        let empty_params = registry.extract_path_parameters("/users", "GET");
4512        assert!(empty_params.is_empty());
4513    }
4514
4515    #[test]
4516    fn extract_path_parameters_prefers_static_route_and_rejects_empty() {
4517        // #757: a literal route must win over a same-arity `{param}` route, and
4518        // a `{param}` must not capture an empty (trailing-slash) segment.
4519        let spec_json = json!({
4520            "openapi": "3.0.0",
4521            "info": {"title": "Test API", "version": "1.0.0"},
4522            "paths": {
4523                "/users/{id}": { "get": { "responses": {"200": {"description": "OK"}} } },
4524                "/users/me":   { "get": { "responses": {"200": {"description": "OK"}} } }
4525            }
4526        });
4527        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4528        let registry = OpenApiRouteRegistry::new(spec);
4529
4530        // Literal `/users/me` wins over `/users/{id}` → no `id` captured.
4531        let me = registry.extract_path_parameters("/users/me", "GET");
4532        assert!(!me.contains_key("id"), "literal route should win, got {me:?}");
4533
4534        // A real id still matches the parameter route.
4535        let by_id = registry.extract_path_parameters("/users/123", "GET");
4536        assert_eq!(by_id.get("id"), Some(&"123".to_string()));
4537
4538        // Trailing slash must NOT bind `{id}` to an empty value.
4539        let trailing = registry.extract_path_parameters("/users/", "GET");
4540        assert!(
4541            trailing.is_empty(),
4542            "empty trailing segment should not bind id, got {trailing:?}"
4543        );
4544    }
4545
4546    #[test]
4547    fn test_extract_path_parameters_multiple_params() {
4548        // Test extract_path_parameters with multiple path parameters
4549        let spec_json = json!({
4550            "openapi": "3.0.0",
4551            "info": {"title": "Test API", "version": "1.0.0"},
4552            "paths": {
4553                "/users/{userId}/posts/{postId}": {
4554                    "get": {
4555                        "parameters": [
4556                            {
4557                                "name": "userId",
4558                                "in": "path",
4559                                "required": true,
4560                                "schema": {"type": "string"}
4561                            },
4562                            {
4563                                "name": "postId",
4564                                "in": "path",
4565                                "required": true,
4566                                "schema": {"type": "string"}
4567                            }
4568                        ],
4569                        "responses": {"200": {"description": "OK"}}
4570                    }
4571                }
4572            }
4573        });
4574        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4575        let registry = OpenApiRouteRegistry::new(spec);
4576
4577        // Should extract multiple path parameters
4578        let params = registry.extract_path_parameters("/users/123/posts/456", "GET");
4579        assert_eq!(params.get("userId"), Some(&"123".to_string()));
4580        assert_eq!(params.get("postId"), Some(&"456".to_string()));
4581    }
4582
4583    #[test]
4584    fn test_validate_request_route_not_found() {
4585        // Test validation when route not found (lines 1171-1173)
4586        let spec_json = json!({
4587            "openapi": "3.0.0",
4588            "info": {"title": "Test API", "version": "1.0.0"},
4589            "paths": {
4590                "/users": {
4591                    "get": {
4592                        "responses": {"200": {"description": "OK"}}
4593                    }
4594                }
4595            }
4596        });
4597        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4598        let registry = OpenApiRouteRegistry::new(spec);
4599
4600        // Should return error when route not found (lines 1171-1173)
4601        let result = registry.validate_request_with_all(
4602            "/nonexistent",
4603            "GET",
4604            &Map::new(),
4605            &Map::new(),
4606            &Map::new(),
4607            &Map::new(),
4608            None,
4609        );
4610        assert!(result.is_err());
4611        assert!(result.unwrap_err().to_string().contains("not found"));
4612    }
4613
4614    #[test]
4615    fn test_validate_request_with_path_parameters() {
4616        // Test path parameter validation (lines 1101-1110)
4617        let spec_json = json!({
4618            "openapi": "3.0.0",
4619            "info": {"title": "Test API", "version": "1.0.0"},
4620            "paths": {
4621                "/users/{id}": {
4622                    "get": {
4623                        "parameters": [
4624                            {
4625                                "name": "id",
4626                                "in": "path",
4627                                "required": true,
4628                                "schema": {"type": "string", "minLength": 1}
4629                            }
4630                        ],
4631                        "responses": {"200": {"description": "OK"}}
4632                    }
4633                }
4634            }
4635        });
4636        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4637        let registry = OpenApiRouteRegistry::new(spec);
4638
4639        // Should pass validation with valid path parameter
4640        let mut path_params = Map::new();
4641        path_params.insert("id".to_string(), json!("123"));
4642        let result = registry.validate_request_with_all(
4643            "/users/{id}",
4644            "GET",
4645            &path_params,
4646            &Map::new(),
4647            &Map::new(),
4648            &Map::new(),
4649            None,
4650        );
4651        assert!(result.is_ok());
4652    }
4653
4654    #[test]
4655    fn test_validate_request_with_query_parameters() {
4656        // Test query parameter validation (lines 1111-1134)
4657        let spec_json = json!({
4658            "openapi": "3.0.0",
4659            "info": {"title": "Test API", "version": "1.0.0"},
4660            "paths": {
4661                "/users": {
4662                    "get": {
4663                        "parameters": [
4664                            {
4665                                "name": "page",
4666                                "in": "query",
4667                                "required": true,
4668                                "schema": {"type": "integer", "minimum": 1}
4669                            }
4670                        ],
4671                        "responses": {"200": {"description": "OK"}}
4672                    }
4673                }
4674            }
4675        });
4676        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4677        let registry = OpenApiRouteRegistry::new(spec);
4678
4679        // Should pass validation with valid query parameter
4680        let mut query_params = Map::new();
4681        query_params.insert("page".to_string(), json!(1));
4682        let result = registry.validate_request_with_all(
4683            "/users",
4684            "GET",
4685            &Map::new(),
4686            &query_params,
4687            &Map::new(),
4688            &Map::new(),
4689            None,
4690        );
4691        assert!(result.is_ok());
4692    }
4693
4694    #[test]
4695    fn test_validate_request_with_header_parameters() {
4696        // Test header parameter validation (lines 1135-1144)
4697        let spec_json = json!({
4698            "openapi": "3.0.0",
4699            "info": {"title": "Test API", "version": "1.0.0"},
4700            "paths": {
4701                "/users": {
4702                    "get": {
4703                        "parameters": [
4704                            {
4705                                "name": "X-API-Key",
4706                                "in": "header",
4707                                "required": true,
4708                                "schema": {"type": "string"}
4709                            }
4710                        ],
4711                        "responses": {"200": {"description": "OK"}}
4712                    }
4713                }
4714            }
4715        });
4716        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4717        let registry = OpenApiRouteRegistry::new(spec);
4718
4719        // Should pass validation with valid header parameter
4720        let mut header_params = Map::new();
4721        header_params.insert("X-API-Key".to_string(), json!("secret-key"));
4722        let result = registry.validate_request_with_all(
4723            "/users",
4724            "GET",
4725            &Map::new(),
4726            &Map::new(),
4727            &header_params,
4728            &Map::new(),
4729            None,
4730        );
4731        assert!(result.is_ok());
4732    }
4733
4734    #[test]
4735    fn test_validate_request_with_cookie_parameters() {
4736        // Test cookie parameter validation (lines 1145-1154)
4737        let spec_json = json!({
4738            "openapi": "3.0.0",
4739            "info": {"title": "Test API", "version": "1.0.0"},
4740            "paths": {
4741                "/users": {
4742                    "get": {
4743                        "parameters": [
4744                            {
4745                                "name": "sessionId",
4746                                "in": "cookie",
4747                                "required": true,
4748                                "schema": {"type": "string"}
4749                            }
4750                        ],
4751                        "responses": {"200": {"description": "OK"}}
4752                    }
4753                }
4754            }
4755        });
4756        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4757        let registry = OpenApiRouteRegistry::new(spec);
4758
4759        // Should pass validation with valid cookie parameter
4760        let mut cookie_params = Map::new();
4761        cookie_params.insert("sessionId".to_string(), json!("abc123"));
4762        let result = registry.validate_request_with_all(
4763            "/users",
4764            "GET",
4765            &Map::new(),
4766            &Map::new(),
4767            &Map::new(),
4768            &cookie_params,
4769            None,
4770        );
4771        assert!(result.is_ok());
4772    }
4773
4774    #[test]
4775    fn test_validate_request_no_errors_early_return() {
4776        // Test early return when no errors (lines 1158-1160)
4777        let spec_json = json!({
4778            "openapi": "3.0.0",
4779            "info": {"title": "Test API", "version": "1.0.0"},
4780            "paths": {
4781                "/users": {
4782                    "get": {
4783                        "responses": {"200": {"description": "OK"}}
4784                    }
4785                }
4786            }
4787        });
4788        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4789        let registry = OpenApiRouteRegistry::new(spec);
4790
4791        // Should return early when no errors (lines 1158-1160)
4792        let result = registry.validate_request_with_all(
4793            "/users",
4794            "GET",
4795            &Map::new(),
4796            &Map::new(),
4797            &Map::new(),
4798            &Map::new(),
4799            None,
4800        );
4801        assert!(result.is_ok());
4802    }
4803
4804    #[test]
4805    fn test_validate_request_query_parameter_different_styles() {
4806        // Test query parameter validation with different styles (lines 1118-1123)
4807        let spec_json = json!({
4808            "openapi": "3.0.0",
4809            "info": {"title": "Test API", "version": "1.0.0"},
4810            "paths": {
4811                "/users": {
4812                    "get": {
4813                        "parameters": [
4814                            {
4815                                "name": "tags",
4816                                "in": "query",
4817                                "style": "pipeDelimited",
4818                                "schema": {
4819                                    "type": "array",
4820                                    "items": {"type": "string"}
4821                                }
4822                            }
4823                        ],
4824                        "responses": {"200": {"description": "OK"}}
4825                    }
4826                }
4827            }
4828        });
4829        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4830        let registry = OpenApiRouteRegistry::new(spec);
4831
4832        // Should handle pipeDelimited style (lines 1118-1123)
4833        let mut query_params = Map::new();
4834        query_params.insert("tags".to_string(), json!(["tag1", "tag2"]));
4835        let result = registry.validate_request_with_all(
4836            "/users",
4837            "GET",
4838            &Map::new(),
4839            &query_params,
4840            &Map::new(),
4841            &Map::new(),
4842            None,
4843        );
4844        // Should not error on style handling
4845        assert!(result.is_ok() || result.is_err()); // Either is fine, just testing the path
4846    }
4847}