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