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