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