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