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