Skip to main content

mockforge_openapi/
openapi_routes.rs

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