Skip to main content

mockforge_openapi/
openapi_routes.rs

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