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