Skip to main content

mockforge_core/
openapi_routes.rs

1//! OpenAPI-based route generation for MockForge
2//!
3//! This module has been refactored into sub-modules for better organization:
4//! - registry: OpenAPI route registry and management
5//! - validation: Request/response validation logic
6//! - generation: Route generation from OpenAPI specs
7//! - builder: Axum router building from OpenAPI specs
8
9// Re-export sub-modules for backward compatibility
10pub mod builder;
11pub mod generation;
12pub mod registry;
13pub mod validation;
14
15// Re-export commonly used types
16pub use builder::*;
17pub use generation::*;
18pub use validation::*;
19
20// Legacy types and functions for backward compatibility
21use crate::ai_response::RequestContext;
22use crate::openapi::response::AiGenerator;
23use crate::openapi::{OpenApiOperation, OpenApiRoute, OpenApiSchema, OpenApiSpec};
24use crate::reality_continuum::response_trace::ResponseGenerationTrace;
25use crate::schema_diff::validation_diff;
26use crate::templating::expand_tokens as core_expand_tokens;
27use crate::{latency::LatencyInjector, overrides::Overrides, Error, Result};
28use axum::extract::{Path as AxumPath, RawQuery};
29use axum::http::HeaderMap;
30use axum::response::IntoResponse;
31use axum::routing::*;
32use axum::{Json, Router};
33use chrono::Utc;
34use once_cell::sync::Lazy;
35use openapiv3::ParameterSchemaOrContent;
36use serde_json::{json, Map, Value};
37use std::collections::{HashMap, VecDeque};
38use std::sync::{Arc, Mutex};
39use tracing;
40
41/// OpenAPI route registry that manages generated routes
42#[derive(Clone)]
43pub struct OpenApiRouteRegistry {
44    /// The OpenAPI specification
45    spec: Arc<OpenApiSpec>,
46    /// Generated routes
47    routes: Vec<OpenApiRoute>,
48    /// Validation options
49    options: ValidationOptions,
50    /// Custom fixture loader (optional)
51    custom_fixture_loader: Option<Arc<crate::CustomFixtureLoader>>,
52}
53
54/// Validation mode for request/response validation
55#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)]
56pub enum ValidationMode {
57    /// Validation is disabled (no checks performed)
58    Disabled,
59    /// Validation warnings are logged but do not fail requests
60    #[default]
61    Warn,
62    /// Validation failures return error responses
63    Enforce,
64}
65
66/// Options for configuring validation behavior
67#[derive(Debug, Clone)]
68pub struct ValidationOptions {
69    /// Validation mode for incoming requests
70    pub request_mode: ValidationMode,
71    /// Whether to aggregate multiple validation errors into a single response
72    pub aggregate_errors: bool,
73    /// Whether to validate outgoing responses against schemas
74    pub validate_responses: bool,
75    /// Per-operation validation mode overrides (operation ID -> mode)
76    pub overrides: HashMap<String, ValidationMode>,
77    /// Skip validation for request paths starting with any of these prefixes
78    pub admin_skip_prefixes: Vec<String>,
79    /// Expand templating tokens in responses/examples after generation
80    pub response_template_expand: bool,
81    /// HTTP status code to return for validation failures (e.g., 400 or 422)
82    pub validation_status: Option<u16>,
83}
84
85impl Default for ValidationOptions {
86    fn default() -> Self {
87        Self {
88            request_mode: ValidationMode::Enforce,
89            aggregate_errors: true,
90            validate_responses: false,
91            overrides: HashMap::new(),
92            admin_skip_prefixes: Vec::new(),
93            response_template_expand: false,
94            validation_status: None,
95        }
96    }
97}
98
99impl OpenApiRouteRegistry {
100    /// Create a new registry from an OpenAPI spec
101    pub fn new(spec: OpenApiSpec) -> Self {
102        Self::new_with_env(spec)
103    }
104
105    /// Create a new registry from an OpenAPI spec with environment-based validation options
106    ///
107    /// Options are read from environment variables:
108    /// - `MOCKFORGE_REQUEST_VALIDATION`: "off"/"warn"/"enforce" (default: "enforce")
109    /// - `MOCKFORGE_AGGREGATE_ERRORS`: "1"/"true" to aggregate errors (default: true)
110    /// - `MOCKFORGE_RESPONSE_VALIDATION`: "1"/"true" to validate responses (default: false)
111    /// - `MOCKFORGE_RESPONSE_TEMPLATE_EXPAND`: "1"/"true" to expand templates (default: false)
112    /// - `MOCKFORGE_VALIDATION_STATUS`: HTTP status code for validation failures (optional)
113    pub fn new_with_env(spec: OpenApiSpec) -> Self {
114        Self::new_with_env_and_persona(spec, None)
115    }
116
117    /// Create a new registry from an OpenAPI spec with environment-based validation options and persona
118    pub fn new_with_env_and_persona(
119        spec: OpenApiSpec,
120        persona: Option<Arc<crate::intelligent_behavior::config::Persona>>,
121    ) -> Self {
122        tracing::debug!("Creating OpenAPI route registry");
123        let spec = Arc::new(spec);
124        let routes = Self::generate_routes_with_persona(&spec, persona);
125        let options = ValidationOptions {
126            request_mode: match std::env::var("MOCKFORGE_REQUEST_VALIDATION")
127                .unwrap_or_else(|_| "enforce".into())
128                .to_ascii_lowercase()
129                .as_str()
130            {
131                "off" | "disable" | "disabled" => ValidationMode::Disabled,
132                "warn" | "warning" => ValidationMode::Warn,
133                _ => ValidationMode::Enforce,
134            },
135            aggregate_errors: std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
136                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
137                .unwrap_or(true),
138            validate_responses: std::env::var("MOCKFORGE_RESPONSE_VALIDATION")
139                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
140                .unwrap_or(false),
141            overrides: HashMap::new(),
142            admin_skip_prefixes: Vec::new(),
143            response_template_expand: std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
144                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
145                .unwrap_or(false),
146            validation_status: std::env::var("MOCKFORGE_VALIDATION_STATUS")
147                .ok()
148                .and_then(|s| s.parse::<u16>().ok()),
149        };
150        Self {
151            spec,
152            routes,
153            options,
154            custom_fixture_loader: None,
155        }
156    }
157
158    /// Construct with explicit options
159    pub fn new_with_options(spec: OpenApiSpec, options: ValidationOptions) -> Self {
160        Self::new_with_options_and_persona(spec, options, None)
161    }
162
163    /// Construct with explicit options and persona
164    pub fn new_with_options_and_persona(
165        spec: OpenApiSpec,
166        options: ValidationOptions,
167        persona: Option<Arc<crate::intelligent_behavior::config::Persona>>,
168    ) -> Self {
169        tracing::debug!("Creating OpenAPI route registry with custom options");
170        let spec = Arc::new(spec);
171        let routes = Self::generate_routes_with_persona(&spec, persona);
172        Self {
173            spec,
174            routes,
175            options,
176            custom_fixture_loader: None,
177        }
178    }
179
180    /// Set custom fixture loader
181    pub fn with_custom_fixture_loader(mut self, loader: Arc<crate::CustomFixtureLoader>) -> Self {
182        self.custom_fixture_loader = Some(loader);
183        self
184    }
185
186    /// Clone this registry for validation purposes (creates an independent copy)
187    ///
188    /// This is useful when you need a separate registry instance for validation
189    /// that won't interfere with the main registry's state.
190    pub fn clone_for_validation(&self) -> Self {
191        OpenApiRouteRegistry {
192            spec: self.spec.clone(),
193            routes: self.routes.clone(),
194            options: self.options.clone(),
195            custom_fixture_loader: self.custom_fixture_loader.clone(),
196        }
197    }
198
199    /// Generate routes from the OpenAPI specification with optional persona
200    fn generate_routes_with_persona(
201        spec: &Arc<OpenApiSpec>,
202        persona: Option<Arc<crate::intelligent_behavior::config::Persona>>,
203    ) -> Vec<OpenApiRoute> {
204        let mut routes = Vec::new();
205
206        let all_paths_ops = spec.all_paths_and_operations();
207        tracing::debug!("Generating routes from OpenAPI spec with {} paths", all_paths_ops.len());
208
209        for (path, operations) in all_paths_ops {
210            tracing::debug!("Processing path: {}", path);
211            for (method, operation) in operations {
212                routes.push(OpenApiRoute::from_operation_with_persona(
213                    &method,
214                    path.clone(),
215                    &operation,
216                    spec.clone(),
217                    persona.clone(),
218                ));
219            }
220        }
221
222        tracing::debug!("Generated {} total routes from OpenAPI spec", routes.len());
223        routes
224    }
225
226    /// Get all routes
227    pub fn routes(&self) -> &[OpenApiRoute] {
228        &self.routes
229    }
230
231    /// Get the OpenAPI specification
232    pub fn spec(&self) -> &OpenApiSpec {
233        &self.spec
234    }
235
236    /// Build an Axum router from the OpenAPI spec (simplified)
237    pub fn build_router(self) -> Router {
238        let mut router = Router::new();
239        tracing::debug!("Building router from {} routes", self.routes.len());
240
241        // Create individual routes for each operation
242        let custom_loader = self.custom_fixture_loader.clone();
243        for route in &self.routes {
244            if !route.is_valid_axum_path() {
245                tracing::warn!(
246                    "Skipping route with unsupported path syntax: {} {} (OData function calls or multi-param segments are converted but may still be incompatible)",
247                    route.method, route.path
248                );
249                continue;
250            }
251            tracing::debug!("Adding route: {} {}", route.method, route.path);
252            let axum_path = route.axum_path();
253            let operation = route.operation.clone();
254            let method = route.method.clone();
255            let path_template = route.path.clone();
256            let validator = self.clone_for_validation();
257            let route_clone = route.clone();
258            let custom_loader_clone = custom_loader.clone();
259
260            // Handler: check custom fixtures, then validate path/query/header/cookie/body, then return mock
261            let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
262                                RawQuery(raw_query): RawQuery,
263                                headers: HeaderMap,
264                                body: axum::body::Bytes| async move {
265                tracing::debug!("Handling OpenAPI request: {} {}", method, path_template);
266
267                // Check for custom fixture first (highest priority)
268                if let Some(ref loader) = custom_loader_clone {
269                    use crate::RequestFingerprint;
270                    use axum::http::{Method, Uri};
271
272                    // Reconstruct the full path from template and params
273                    let mut request_path = path_template.clone();
274                    for (key, value) in &path_params {
275                        request_path = request_path.replace(&format!("{{{}}}", key), value);
276                    }
277
278                    // Normalize the path to match fixture normalization
279                    let normalized_request_path =
280                        crate::CustomFixtureLoader::normalize_path(&request_path);
281
282                    // Build query string
283                    let query_string =
284                        raw_query.as_ref().map(|q| q.to_string()).unwrap_or_default();
285
286                    // Create URI for fingerprint
287                    // Note: RequestFingerprint only uses the path, not the full URI with query
288                    // So we can create a simple URI from just the path
289                    // IMPORTANT: Use normalized path to match fixture paths
290                    let uri_str = if query_string.is_empty() {
291                        normalized_request_path.clone()
292                    } else {
293                        format!("{}?{}", normalized_request_path, query_string)
294                    };
295
296                    if let Ok(uri) = uri_str.parse::<Uri>() {
297                        let http_method =
298                            Method::from_bytes(method.as_bytes()).unwrap_or(Method::GET);
299                        let body_slice = if body.is_empty() {
300                            None
301                        } else {
302                            Some(body.as_ref())
303                        };
304                        let fingerprint =
305                            RequestFingerprint::new(http_method, &uri, &headers, body_slice);
306
307                        // Debug logging for fixture matching
308                        tracing::debug!(
309                            "Checking fixture for {} {} (template: '{}', request_path: '{}', normalized: '{}', fingerprint.path: '{}')",
310                            method,
311                            path_template,
312                            path_template,
313                            request_path,
314                            normalized_request_path,
315                            fingerprint.path
316                        );
317
318                        if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
319                            tracing::debug!(
320                                "Using custom fixture for {} {}",
321                                method,
322                                path_template
323                            );
324
325                            // Apply delay if specified
326                            if custom_fixture.delay_ms > 0 {
327                                tokio::time::sleep(tokio::time::Duration::from_millis(
328                                    custom_fixture.delay_ms,
329                                ))
330                                .await;
331                            }
332
333                            // Convert response to JSON string if needed
334                            let response_body = if custom_fixture.response.is_string() {
335                                custom_fixture.response.as_str().unwrap().to_string()
336                            } else {
337                                serde_json::to_string(&custom_fixture.response)
338                                    .unwrap_or_else(|_| "{}".to_string())
339                            };
340
341                            // Parse response body as JSON
342                            let json_value: Value = serde_json::from_str(&response_body)
343                                .unwrap_or_else(|_| serde_json::json!({}));
344
345                            // Build response with status and JSON body
346                            let status = axum::http::StatusCode::from_u16(custom_fixture.status)
347                                .unwrap_or(axum::http::StatusCode::OK);
348
349                            let mut response = (status, Json(json_value)).into_response();
350
351                            // Add custom headers to response
352                            let response_headers = response.headers_mut();
353                            for (key, value) in &custom_fixture.headers {
354                                if let (Ok(header_name), Ok(header_value)) = (
355                                    axum::http::HeaderName::from_bytes(key.as_bytes()),
356                                    axum::http::HeaderValue::from_str(value),
357                                ) {
358                                    response_headers.insert(header_name, header_value);
359                                }
360                            }
361
362                            // Ensure content-type is set if not already present
363                            if !custom_fixture.headers.contains_key("content-type") {
364                                response_headers.insert(
365                                    axum::http::header::CONTENT_TYPE,
366                                    axum::http::HeaderValue::from_static("application/json"),
367                                );
368                            }
369
370                            return response;
371                        }
372                    }
373                }
374
375                // Determine scenario from header or environment variable
376                // Header takes precedence over environment variable
377                let scenario = headers
378                    .get("X-Mockforge-Scenario")
379                    .and_then(|v| v.to_str().ok())
380                    .map(|s| s.to_string())
381                    .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
382
383                // Check for status code override header
384                let status_override = headers
385                    .get("X-Mockforge-Response-Status")
386                    .and_then(|v| v.to_str().ok())
387                    .and_then(|s| s.parse::<u16>().ok());
388
389                // Generate mock response for this request with scenario support
390                let (selected_status, mock_response) = route_clone
391                    .mock_response_with_status_and_scenario_and_override(
392                        scenario.as_deref(),
393                        status_override,
394                    );
395                // Admin routes are mounted separately; no validation skip needed here.
396                // Build params maps
397                let mut path_map = Map::new();
398                for (k, v) in path_params {
399                    path_map.insert(k, Value::String(v));
400                }
401
402                // Query
403                let mut query_map = Map::new();
404                if let Some(q) = raw_query {
405                    for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
406                        query_map.insert(k.to_string(), Value::String(v.to_string()));
407                    }
408                }
409
410                // Headers: only capture those declared on this operation
411                let mut header_map = Map::new();
412                for p_ref in &operation.parameters {
413                    if let Some(openapiv3::Parameter::Header { parameter_data, .. }) =
414                        p_ref.as_item()
415                    {
416                        let name_lc = parameter_data.name.to_ascii_lowercase();
417                        if let Ok(hn) = axum::http::HeaderName::from_bytes(name_lc.as_bytes()) {
418                            if let Some(val) = headers.get(hn) {
419                                if let Ok(s) = val.to_str() {
420                                    header_map.insert(
421                                        parameter_data.name.clone(),
422                                        Value::String(s.to_string()),
423                                    );
424                                }
425                            }
426                        }
427                    }
428                }
429
430                // Cookies: parse Cookie header
431                let mut cookie_map = Map::new();
432                if let Some(val) = headers.get(axum::http::header::COOKIE) {
433                    if let Ok(s) = val.to_str() {
434                        for part in s.split(';') {
435                            let part = part.trim();
436                            if let Some((k, v)) = part.split_once('=') {
437                                cookie_map.insert(k.to_string(), Value::String(v.to_string()));
438                            }
439                        }
440                    }
441                }
442
443                // Check if this is a multipart request
444                let is_multipart = headers
445                    .get(axum::http::header::CONTENT_TYPE)
446                    .and_then(|v| v.to_str().ok())
447                    .map(|ct| ct.starts_with("multipart/form-data"))
448                    .unwrap_or(false);
449
450                // Extract multipart data if applicable
451                #[allow(unused_assignments)]
452                let mut multipart_fields = HashMap::new();
453                let mut _multipart_files = HashMap::new();
454                let mut body_json: Option<Value> = None;
455
456                if is_multipart {
457                    // For multipart requests, extract fields and files
458                    match extract_multipart_from_bytes(&body, &headers).await {
459                        Ok((fields, files)) => {
460                            multipart_fields = fields;
461                            _multipart_files = files;
462                            // Also create a JSON representation for validation
463                            let mut body_obj = Map::new();
464                            for (k, v) in &multipart_fields {
465                                body_obj.insert(k.clone(), v.clone());
466                            }
467                            if !body_obj.is_empty() {
468                                body_json = Some(Value::Object(body_obj));
469                            }
470                        }
471                        Err(e) => {
472                            tracing::warn!("Failed to parse multipart data: {}", e);
473                        }
474                    }
475                } else {
476                    // Body: try JSON when present
477                    body_json = if !body.is_empty() {
478                        serde_json::from_slice(&body).ok()
479                    } else {
480                        None
481                    };
482                }
483
484                if let Err(e) = validator.validate_request_with_all(
485                    &path_template,
486                    &method,
487                    &path_map,
488                    &query_map,
489                    &header_map,
490                    &cookie_map,
491                    body_json.as_ref(),
492                ) {
493                    // Choose status: prefer options.validation_status, fallback to env, else 400
494                    let status_code = validator.options.validation_status.unwrap_or_else(|| {
495                        std::env::var("MOCKFORGE_VALIDATION_STATUS")
496                            .ok()
497                            .and_then(|s| s.parse::<u16>().ok())
498                            .unwrap_or(400)
499                    });
500
501                    let payload = if status_code == 422 {
502                        // For 422 responses, use enhanced schema validation with detailed errors
503                        generate_enhanced_422_response(
504                            &validator,
505                            &path_template,
506                            &method,
507                            body_json.as_ref(),
508                            &path_map,
509                            &query_map,
510                            &header_map,
511                            &cookie_map,
512                        )
513                    } else {
514                        // For other status codes, use generic error format
515                        let msg = format!("{}", e);
516                        let detail_val =
517                            serde_json::from_str::<Value>(&msg).unwrap_or(serde_json::json!(msg));
518                        json!({
519                            "error": "request validation failed",
520                            "detail": detail_val,
521                            "method": method,
522                            "path": path_template,
523                            "timestamp": Utc::now().to_rfc3339(),
524                        })
525                    };
526
527                    record_validation_error(&payload);
528                    let status = axum::http::StatusCode::from_u16(status_code)
529                        .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
530
531                    // Serialize payload with fallback for serialization errors
532                    let body_bytes = serde_json::to_vec(&payload)
533                        .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
534
535                    return axum::http::Response::builder()
536                        .status(status)
537                        .header(axum::http::header::CONTENT_TYPE, "application/json")
538                        .body(axum::body::Body::from(body_bytes))
539                        .expect("Response builder should create valid response with valid headers and body");
540                }
541
542                // Expand tokens in the response if enabled (options or env)
543                let mut final_response = mock_response.clone();
544                let env_expand = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
545                    .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
546                    .unwrap_or(false);
547                let expand = validator.options.response_template_expand || env_expand;
548                if expand {
549                    final_response = core_expand_tokens(&final_response);
550                }
551
552                // Optional response validation
553                if validator.options.validate_responses {
554                    // Find the first 2xx response in the operation
555                    if let Some((status_code, _response)) = operation
556                        .responses
557                        .responses
558                        .iter()
559                        .filter_map(|(status, resp)| match status {
560                            openapiv3::StatusCode::Code(code) if *code >= 200 && *code < 300 => {
561                                resp.as_item().map(|r| ((*code), r))
562                            }
563                            openapiv3::StatusCode::Range(range)
564                                if *range >= 200 && *range < 300 =>
565                            {
566                                resp.as_item().map(|r| (200, r))
567                            }
568                            _ => None,
569                        })
570                        .next()
571                    {
572                        // Basic response validation - check if response is valid JSON
573                        if serde_json::from_value::<Value>(final_response.clone()).is_err() {
574                            tracing::warn!(
575                                "Response validation failed: invalid JSON for status {}",
576                                status_code
577                            );
578                        }
579                    }
580                }
581
582                // Capture final payload and run schema validation for trace
583                let mut trace = ResponseGenerationTrace::new();
584                trace.set_final_payload(final_response.clone());
585
586                // Extract response schema and run validation diff
587                if let Some((_status_code, response_ref)) = operation
588                    .responses
589                    .responses
590                    .iter()
591                    .filter_map(|(status, resp)| match status {
592                        openapiv3::StatusCode::Code(code) if *code == selected_status => {
593                            resp.as_item().map(|r| ((*code), r))
594                        }
595                        openapiv3::StatusCode::Range(range) if *range >= 200 && *range < 300 => {
596                            resp.as_item().map(|r| (200, r))
597                        }
598                        _ => None,
599                    })
600                    .next()
601                    .or_else(|| {
602                        // Fallback to first 2xx response
603                        operation
604                            .responses
605                            .responses
606                            .iter()
607                            .filter_map(|(status, resp)| match status {
608                                openapiv3::StatusCode::Code(code)
609                                    if *code >= 200 && *code < 300 =>
610                                {
611                                    resp.as_item().map(|r| ((*code), r))
612                                }
613                                _ => None,
614                            })
615                            .next()
616                    })
617                {
618                    // response_ref is already a Response, not a ReferenceOr
619                    let response_item = response_ref;
620                    // Extract schema from application/json content
621                    if let Some(content) = response_item.content.get("application/json") {
622                        if let Some(schema_ref) = &content.schema {
623                            // Convert OpenAPI schema to JSON Schema Value
624                            // Try to convert the schema to JSON Schema format
625                            if let Some(schema) = schema_ref.as_item() {
626                                // Convert OpenAPI Schema to JSON Schema Value
627                                // Use serde_json::to_value as a starting point
628                                if let Ok(schema_json) = serde_json::to_value(schema) {
629                                    // Run validation diff
630                                    let validation_errors =
631                                        validation_diff(&schema_json, &final_response);
632                                    trace.set_schema_validation_diff(validation_errors);
633                                }
634                            }
635                        }
636                    }
637                }
638
639                // Store trace in response extensions for later retrieval by logging middleware
640                let mut response = Json(final_response).into_response();
641                response.extensions_mut().insert(trace);
642                *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
643                    .unwrap_or(axum::http::StatusCode::OK);
644                response
645            };
646
647            // Register the handler based on HTTP method
648            router = match route.method.as_str() {
649                "GET" => router.route(&axum_path, get(handler)),
650                "POST" => router.route(&axum_path, post(handler)),
651                "PUT" => router.route(&axum_path, put(handler)),
652                "DELETE" => router.route(&axum_path, delete(handler)),
653                "PATCH" => router.route(&axum_path, patch(handler)),
654                "HEAD" => router.route(&axum_path, head(handler)),
655                "OPTIONS" => router.route(&axum_path, options(handler)),
656                _ => router, // Skip unknown methods
657            };
658        }
659
660        // Add OpenAPI documentation endpoint
661        let spec_json = serde_json::to_value(&self.spec.spec).unwrap_or(Value::Null);
662        router = router.route("/openapi.json", get(move || async move { Json(spec_json) }));
663
664        router
665    }
666
667    /// Build an Axum router from the OpenAPI spec with latency injection support
668    pub fn build_router_with_latency(self, latency_injector: LatencyInjector) -> Router {
669        self.build_router_with_injectors(latency_injector, None)
670    }
671
672    /// Build an Axum router from the OpenAPI spec with both latency and failure injection support
673    pub fn build_router_with_injectors(
674        self,
675        latency_injector: LatencyInjector,
676        failure_injector: Option<crate::FailureInjector>,
677    ) -> Router {
678        self.build_router_with_injectors_and_overrides(
679            latency_injector,
680            failure_injector,
681            None,
682            false,
683        )
684    }
685
686    /// Build an Axum router from the OpenAPI spec with latency, failure injection, and overrides support
687    pub fn build_router_with_injectors_and_overrides(
688        self,
689        latency_injector: LatencyInjector,
690        failure_injector: Option<crate::FailureInjector>,
691        overrides: Option<Overrides>,
692        overrides_enabled: bool,
693    ) -> Router {
694        let mut router = Router::new();
695
696        // Create individual routes for each operation
697        let custom_loader = self.custom_fixture_loader.clone();
698        for route in &self.routes {
699            if !route.is_valid_axum_path() {
700                tracing::warn!(
701                    "Skipping route with unsupported path syntax: {} {}",
702                    route.method,
703                    route.path
704                );
705                continue;
706            }
707            let axum_path = route.axum_path();
708            let operation = route.operation.clone();
709            let method = route.method.clone();
710            let method_str = method.clone();
711            let method_for_router = method_str.clone();
712            let path_template = route.path.clone();
713            let validator = self.clone_for_validation();
714            let route_clone = route.clone();
715            let injector = latency_injector.clone();
716            let failure_injector = failure_injector.clone();
717            let route_overrides = overrides.clone();
718            let custom_loader_clone = custom_loader.clone();
719
720            // Extract tags from operation for latency and failure injection
721            let mut operation_tags = operation.tags.clone();
722            if let Some(operation_id) = &operation.operation_id {
723                operation_tags.push(operation_id.clone());
724            }
725
726            // Handler: check custom fixtures, inject latency, validate path/query/header/cookie/body, then return mock
727            let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
728                                RawQuery(raw_query): RawQuery,
729                                headers: HeaderMap,
730                                body: axum::body::Bytes| async move {
731                // Check for custom fixture first (highest priority)
732                tracing::info!(
733                    "[FIXTURE DEBUG] Starting fixture check for {} {} (custom_loader available: {})",
734                    method_str,
735                    path_template,
736                    custom_loader_clone.is_some()
737                );
738
739                if let Some(ref loader) = custom_loader_clone {
740                    use crate::RequestFingerprint;
741                    use axum::http::{Method, Uri};
742
743                    // Reconstruct the full path from template and params
744                    let mut request_path = path_template.clone();
745                    for (key, value) in &path_params {
746                        request_path = request_path.replace(&format!("{{{}}}", key), value);
747                    }
748
749                    tracing::info!(
750                        "[FIXTURE DEBUG] Path reconstruction: template='{}', params={:?}, reconstructed='{}'",
751                        path_template,
752                        path_params,
753                        request_path
754                    );
755
756                    // Normalize the path to match fixture normalization
757                    let normalized_request_path =
758                        crate::CustomFixtureLoader::normalize_path(&request_path);
759
760                    tracing::info!(
761                        "[FIXTURE DEBUG] Path normalization: original='{}', normalized='{}'",
762                        request_path,
763                        normalized_request_path
764                    );
765
766                    // Build query string
767                    let query_string =
768                        raw_query.as_ref().map(|q| q.to_string()).unwrap_or_default();
769
770                    // Create URI for fingerprint
771                    // IMPORTANT: Use normalized path to match fixture paths
772                    let uri_str = if query_string.is_empty() {
773                        normalized_request_path.clone()
774                    } else {
775                        format!("{}?{}", normalized_request_path, query_string)
776                    };
777
778                    tracing::info!(
779                        "[FIXTURE DEBUG] URI construction: uri_str='{}', query_string='{}'",
780                        uri_str,
781                        query_string
782                    );
783
784                    if let Ok(uri) = uri_str.parse::<Uri>() {
785                        let http_method =
786                            Method::from_bytes(method_str.as_bytes()).unwrap_or(Method::GET);
787                        let body_slice = if body.is_empty() {
788                            None
789                        } else {
790                            Some(body.as_ref())
791                        };
792                        let fingerprint =
793                            RequestFingerprint::new(http_method, &uri, &headers, body_slice);
794
795                        tracing::info!(
796                            "[FIXTURE DEBUG] RequestFingerprint created: method='{}', path='{}', query='{}', body_hash={:?}",
797                            fingerprint.method,
798                            fingerprint.path,
799                            fingerprint.query,
800                            fingerprint.body_hash
801                        );
802
803                        // Check what fixtures are available for this method
804                        let available_fixtures = loader.has_fixture(&fingerprint);
805                        tracing::info!(
806                            "[FIXTURE DEBUG] Fixture check result: has_fixture={}",
807                            available_fixtures
808                        );
809
810                        if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
811                            tracing::info!(
812                                "[FIXTURE DEBUG] ✅ FIXTURE MATCHED! Using custom fixture for {} {} (status: {}, path: '{}')",
813                                method_str,
814                                path_template,
815                                custom_fixture.status,
816                                custom_fixture.path
817                            );
818                            tracing::debug!(
819                                "Using custom fixture for {} {}",
820                                method_str,
821                                path_template
822                            );
823
824                            // Apply delay if specified
825                            if custom_fixture.delay_ms > 0 {
826                                tokio::time::sleep(tokio::time::Duration::from_millis(
827                                    custom_fixture.delay_ms,
828                                ))
829                                .await;
830                            }
831
832                            // Convert response to JSON string if needed
833                            let response_body = if custom_fixture.response.is_string() {
834                                custom_fixture.response.as_str().unwrap().to_string()
835                            } else {
836                                serde_json::to_string(&custom_fixture.response)
837                                    .unwrap_or_else(|_| "{}".to_string())
838                            };
839
840                            // Parse response body as JSON
841                            let json_value: Value = serde_json::from_str(&response_body)
842                                .unwrap_or_else(|_| serde_json::json!({}));
843
844                            // Build response with status and JSON body
845                            let status = axum::http::StatusCode::from_u16(custom_fixture.status)
846                                .unwrap_or(axum::http::StatusCode::OK);
847
848                            // Return as tuple (StatusCode, Json) to match handler signature
849                            return (status, Json(json_value));
850                        } else {
851                            tracing::warn!(
852                                "[FIXTURE DEBUG] ❌ No fixture match found for {} {} (fingerprint.path='{}', normalized='{}')",
853                                method_str,
854                                path_template,
855                                fingerprint.path,
856                                normalized_request_path
857                            );
858                        }
859                    } else {
860                        tracing::warn!("[FIXTURE DEBUG] Failed to parse URI: '{}'", uri_str);
861                    }
862                } else {
863                    tracing::warn!(
864                        "[FIXTURE DEBUG] Custom fixture loader not available for {} {}",
865                        method_str,
866                        path_template
867                    );
868                }
869
870                // Check for failure injection first
871                if let Some(ref failure_injector) = failure_injector {
872                    if let Some((status_code, error_message)) =
873                        failure_injector.process_request(&operation_tags)
874                    {
875                        return (
876                            axum::http::StatusCode::from_u16(status_code)
877                                .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
878                            Json(serde_json::json!({
879                                "error": error_message,
880                                "injected_failure": true
881                            })),
882                        );
883                    }
884                }
885
886                // Inject latency before processing the request
887                if let Err(e) = injector.inject_latency(&operation_tags).await {
888                    tracing::warn!("Failed to inject latency: {}", e);
889                }
890
891                // Determine scenario from header or environment variable
892                // Header takes precedence over environment variable
893                let scenario = headers
894                    .get("X-Mockforge-Scenario")
895                    .and_then(|v| v.to_str().ok())
896                    .map(|s| s.to_string())
897                    .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
898
899                // Check for status code override header
900                let status_override = headers
901                    .get("X-Mockforge-Response-Status")
902                    .and_then(|v| v.to_str().ok())
903                    .and_then(|s| s.parse::<u16>().ok());
904
905                // Admin routes are mounted separately; no validation skip needed here.
906                // Build params maps
907                let mut path_map = Map::new();
908                for (k, v) in path_params {
909                    path_map.insert(k, Value::String(v));
910                }
911
912                // Query
913                let mut query_map = Map::new();
914                if let Some(q) = raw_query {
915                    for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
916                        query_map.insert(k.to_string(), Value::String(v.to_string()));
917                    }
918                }
919
920                // Headers: only capture those declared on this operation
921                let mut header_map = Map::new();
922                for p_ref in &operation.parameters {
923                    if let Some(openapiv3::Parameter::Header { parameter_data, .. }) =
924                        p_ref.as_item()
925                    {
926                        let name_lc = parameter_data.name.to_ascii_lowercase();
927                        if let Ok(hn) = axum::http::HeaderName::from_bytes(name_lc.as_bytes()) {
928                            if let Some(val) = headers.get(hn) {
929                                if let Ok(s) = val.to_str() {
930                                    header_map.insert(
931                                        parameter_data.name.clone(),
932                                        Value::String(s.to_string()),
933                                    );
934                                }
935                            }
936                        }
937                    }
938                }
939
940                // Cookies: parse Cookie header
941                let mut cookie_map = Map::new();
942                if let Some(val) = headers.get(axum::http::header::COOKIE) {
943                    if let Ok(s) = val.to_str() {
944                        for part in s.split(';') {
945                            let part = part.trim();
946                            if let Some((k, v)) = part.split_once('=') {
947                                cookie_map.insert(k.to_string(), Value::String(v.to_string()));
948                            }
949                        }
950                    }
951                }
952
953                // Check if this is a multipart request
954                let is_multipart = headers
955                    .get(axum::http::header::CONTENT_TYPE)
956                    .and_then(|v| v.to_str().ok())
957                    .map(|ct| ct.starts_with("multipart/form-data"))
958                    .unwrap_or(false);
959
960                // Extract multipart data if applicable
961                #[allow(unused_assignments)]
962                let mut multipart_fields = HashMap::new();
963                let mut _multipart_files = HashMap::new();
964                let mut body_json: Option<Value> = None;
965
966                if is_multipart {
967                    // For multipart requests, extract fields and files
968                    match extract_multipart_from_bytes(&body, &headers).await {
969                        Ok((fields, files)) => {
970                            multipart_fields = fields;
971                            _multipart_files = files;
972                            // Also create a JSON representation for validation
973                            let mut body_obj = Map::new();
974                            for (k, v) in &multipart_fields {
975                                body_obj.insert(k.clone(), v.clone());
976                            }
977                            if !body_obj.is_empty() {
978                                body_json = Some(Value::Object(body_obj));
979                            }
980                        }
981                        Err(e) => {
982                            tracing::warn!("Failed to parse multipart data: {}", e);
983                        }
984                    }
985                } else {
986                    // Body: try JSON when present
987                    body_json = if !body.is_empty() {
988                        serde_json::from_slice(&body).ok()
989                    } else {
990                        None
991                    };
992                }
993
994                if let Err(e) = validator.validate_request_with_all(
995                    &path_template,
996                    &method_str,
997                    &path_map,
998                    &query_map,
999                    &header_map,
1000                    &cookie_map,
1001                    body_json.as_ref(),
1002                ) {
1003                    let msg = format!("{}", e);
1004                    let detail_val =
1005                        serde_json::from_str::<Value>(&msg).unwrap_or(serde_json::json!(msg));
1006                    let payload = serde_json::json!({
1007                        "error": "request validation failed",
1008                        "detail": detail_val,
1009                        "method": method_str,
1010                        "path": path_template,
1011                        "timestamp": Utc::now().to_rfc3339(),
1012                    });
1013                    record_validation_error(&payload);
1014                    // Choose status: prefer options.validation_status, fallback to env, else 400
1015                    let status_code = validator.options.validation_status.unwrap_or_else(|| {
1016                        std::env::var("MOCKFORGE_VALIDATION_STATUS")
1017                            .ok()
1018                            .and_then(|s| s.parse::<u16>().ok())
1019                            .unwrap_or(400)
1020                    });
1021                    return (
1022                        axum::http::StatusCode::from_u16(status_code)
1023                            .unwrap_or(axum::http::StatusCode::BAD_REQUEST),
1024                        Json(payload),
1025                    );
1026                }
1027
1028                // Generate mock response with scenario support
1029                let (selected_status, mock_response) = route_clone
1030                    .mock_response_with_status_and_scenario_and_override(
1031                        scenario.as_deref(),
1032                        status_override,
1033                    );
1034
1035                // Expand templating tokens in response if enabled (options or env)
1036                let mut response = mock_response.clone();
1037                let env_expand = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
1038                    .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1039                    .unwrap_or(false);
1040                let expand = validator.options.response_template_expand || env_expand;
1041                if expand {
1042                    response = core_expand_tokens(&response);
1043                }
1044
1045                // Apply overrides if provided and enabled
1046                if let Some(ref overrides) = route_overrides {
1047                    if overrides_enabled {
1048                        // Extract tags from operation for override matching
1049                        let operation_tags =
1050                            operation.operation_id.clone().map(|id| vec![id]).unwrap_or_default();
1051                        overrides.apply(
1052                            &operation.operation_id.unwrap_or_default(),
1053                            &operation_tags,
1054                            &path_template,
1055                            &mut response,
1056                        );
1057                    }
1058                }
1059
1060                // Return the mock response
1061                (
1062                    axum::http::StatusCode::from_u16(selected_status)
1063                        .unwrap_or(axum::http::StatusCode::OK),
1064                    Json(response),
1065                )
1066            };
1067
1068            // Add route to router based on HTTP method
1069            router = match method_for_router.as_str() {
1070                "GET" => router.route(&axum_path, get(handler)),
1071                "POST" => router.route(&axum_path, post(handler)),
1072                "PUT" => router.route(&axum_path, put(handler)),
1073                "PATCH" => router.route(&axum_path, patch(handler)),
1074                "DELETE" => router.route(&axum_path, delete(handler)),
1075                "HEAD" => router.route(&axum_path, head(handler)),
1076                "OPTIONS" => router.route(&axum_path, options(handler)),
1077                _ => router.route(&axum_path, get(handler)), // Default to GET for unknown methods
1078            };
1079        }
1080
1081        // Add OpenAPI documentation endpoint
1082        let spec_json = serde_json::to_value(&self.spec.spec).unwrap_or(Value::Null);
1083        router = router.route("/openapi.json", get(move || async move { Json(spec_json) }));
1084
1085        router
1086    }
1087
1088    /// Get route by path and method
1089    pub fn get_route(&self, path: &str, method: &str) -> Option<&OpenApiRoute> {
1090        self.routes.iter().find(|route| route.path == path && route.method == method)
1091    }
1092
1093    /// Get all routes for a specific path
1094    pub fn get_routes_for_path(&self, path: &str) -> Vec<&OpenApiRoute> {
1095        self.routes.iter().filter(|route| route.path == path).collect()
1096    }
1097
1098    /// Validate request against OpenAPI spec (legacy body-only)
1099    pub fn validate_request(&self, path: &str, method: &str, body: Option<&Value>) -> Result<()> {
1100        self.validate_request_with(path, method, &Map::new(), &Map::new(), body)
1101    }
1102
1103    /// Validate request against OpenAPI spec with path/query params
1104    pub fn validate_request_with(
1105        &self,
1106        path: &str,
1107        method: &str,
1108        path_params: &Map<String, Value>,
1109        query_params: &Map<String, Value>,
1110        body: Option<&Value>,
1111    ) -> Result<()> {
1112        self.validate_request_with_all(
1113            path,
1114            method,
1115            path_params,
1116            query_params,
1117            &Map::new(),
1118            &Map::new(),
1119            body,
1120        )
1121    }
1122
1123    /// Validate request against OpenAPI spec with path/query/header/cookie params
1124    #[allow(clippy::too_many_arguments)]
1125    pub fn validate_request_with_all(
1126        &self,
1127        path: &str,
1128        method: &str,
1129        path_params: &Map<String, Value>,
1130        query_params: &Map<String, Value>,
1131        header_params: &Map<String, Value>,
1132        cookie_params: &Map<String, Value>,
1133        body: Option<&Value>,
1134    ) -> Result<()> {
1135        // Skip validation for any configured admin prefixes
1136        for pref in &self.options.admin_skip_prefixes {
1137            if !pref.is_empty() && path.starts_with(pref) {
1138                return Ok(());
1139            }
1140        }
1141        // Runtime env overrides
1142        let env_mode = std::env::var("MOCKFORGE_REQUEST_VALIDATION").ok().map(|v| {
1143            match v.to_ascii_lowercase().as_str() {
1144                "off" | "disable" | "disabled" => ValidationMode::Disabled,
1145                "warn" | "warning" => ValidationMode::Warn,
1146                _ => ValidationMode::Enforce,
1147            }
1148        });
1149        let aggregate = std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
1150            .ok()
1151            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1152            .unwrap_or(self.options.aggregate_errors);
1153        // Per-route runtime overrides via JSON env var
1154        let env_overrides: Option<Map<String, Value>> =
1155            std::env::var("MOCKFORGE_VALIDATION_OVERRIDES_JSON")
1156                .ok()
1157                .and_then(|s| serde_json::from_str::<Value>(&s).ok())
1158                .and_then(|v| v.as_object().cloned());
1159        // Response validation is handled in HTTP layer now
1160        let mut effective_mode = env_mode.unwrap_or(self.options.request_mode.clone());
1161        // Apply runtime overrides first if present
1162        if let Some(map) = &env_overrides {
1163            if let Some(v) = map.get(&format!("{} {}", method, path)) {
1164                if let Some(m) = v.as_str() {
1165                    effective_mode = match m {
1166                        "off" => ValidationMode::Disabled,
1167                        "warn" => ValidationMode::Warn,
1168                        _ => ValidationMode::Enforce,
1169                    };
1170                }
1171            }
1172        }
1173        // Then static options overrides
1174        if let Some(override_mode) = self.options.overrides.get(&format!("{} {}", method, path)) {
1175            effective_mode = override_mode.clone();
1176        }
1177        if matches!(effective_mode, ValidationMode::Disabled) {
1178            return Ok(());
1179        }
1180        if let Some(route) = self.get_route(path, method) {
1181            if matches!(effective_mode, ValidationMode::Disabled) {
1182                return Ok(());
1183            }
1184            let mut errors: Vec<String> = Vec::new();
1185            let mut details: Vec<Value> = Vec::new();
1186            // Validate request body if required
1187            if let Some(schema) = &route.operation.request_body {
1188                if let Some(value) = body {
1189                    // First resolve the request body reference if it's a reference
1190                    let request_body = match schema {
1191                        openapiv3::ReferenceOr::Item(rb) => Some(rb),
1192                        openapiv3::ReferenceOr::Reference { reference } => {
1193                            // Try to resolve request body reference through spec
1194                            self.spec
1195                                .spec
1196                                .components
1197                                .as_ref()
1198                                .and_then(|components| {
1199                                    components.request_bodies.get(
1200                                        reference.trim_start_matches("#/components/requestBodies/"),
1201                                    )
1202                                })
1203                                .and_then(|rb_ref| rb_ref.as_item())
1204                        }
1205                    };
1206
1207                    if let Some(rb) = request_body {
1208                        if let Some(content) = rb.content.get("application/json") {
1209                            if let Some(schema_ref) = &content.schema {
1210                                // Resolve schema reference and validate
1211                                match schema_ref {
1212                                    openapiv3::ReferenceOr::Item(schema) => {
1213                                        // Direct schema - validate immediately
1214                                        if let Err(validation_error) =
1215                                            OpenApiSchema::new(schema.clone()).validate(value)
1216                                        {
1217                                            let error_msg = validation_error.to_string();
1218                                            errors.push(format!(
1219                                                "body validation failed: {}",
1220                                                error_msg
1221                                            ));
1222                                            if aggregate {
1223                                                details.push(serde_json::json!({"path":"body","code":"schema_validation","message":error_msg}));
1224                                            }
1225                                        }
1226                                    }
1227                                    openapiv3::ReferenceOr::Reference { reference } => {
1228                                        // Referenced schema - resolve and validate
1229                                        if let Some(resolved_schema_ref) =
1230                                            self.spec.get_schema(reference)
1231                                        {
1232                                            if let Err(validation_error) = OpenApiSchema::new(
1233                                                resolved_schema_ref.schema.clone(),
1234                                            )
1235                                            .validate(value)
1236                                            {
1237                                                let error_msg = validation_error.to_string();
1238                                                errors.push(format!(
1239                                                    "body validation failed: {}",
1240                                                    error_msg
1241                                                ));
1242                                                if aggregate {
1243                                                    details.push(serde_json::json!({"path":"body","code":"schema_validation","message":error_msg}));
1244                                                }
1245                                            }
1246                                        } else {
1247                                            // Schema reference couldn't be resolved
1248                                            errors.push(format!("body validation failed: could not resolve schema reference {}", reference));
1249                                            if aggregate {
1250                                                details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve schema reference"}));
1251                                            }
1252                                        }
1253                                    }
1254                                }
1255                            }
1256                        }
1257                    } else {
1258                        // Request body reference couldn't be resolved or no application/json content
1259                        errors.push("body validation failed: could not resolve request body or no application/json content".to_string());
1260                        if aggregate {
1261                            details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve request body reference"}));
1262                        }
1263                    }
1264                } else {
1265                    errors.push("body: Request body is required but not provided".to_string());
1266                    details.push(serde_json::json!({"path":"body","code":"required","message":"Request body is required"}));
1267                }
1268            } else if body.is_some() {
1269                // No body expected but provided — not an error by default, but log it
1270                tracing::debug!("Body provided for operation without requestBody; accepting");
1271            }
1272
1273            // Validate path/query parameters
1274            for p_ref in &route.operation.parameters {
1275                if let Some(p) = p_ref.as_item() {
1276                    match p {
1277                        openapiv3::Parameter::Path { parameter_data, .. } => {
1278                            validate_parameter(
1279                                parameter_data,
1280                                path_params,
1281                                "path",
1282                                aggregate,
1283                                &mut errors,
1284                                &mut details,
1285                            );
1286                        }
1287                        openapiv3::Parameter::Query {
1288                            parameter_data,
1289                            style,
1290                            ..
1291                        } => {
1292                            // For deepObject style, reconstruct nested value from keys like name[prop]
1293                            // e.g., filter[name]=John&filter[age]=30 -> {"name":"John","age":"30"}
1294                            let deep_value = if matches!(style, openapiv3::QueryStyle::DeepObject) {
1295                                let prefix_bracket = format!("{}[", parameter_data.name);
1296                                let mut obj = serde_json::Map::new();
1297                                for (key, val) in query_params.iter() {
1298                                    if let Some(rest) = key.strip_prefix(&prefix_bracket) {
1299                                        if let Some(prop) = rest.strip_suffix(']') {
1300                                            obj.insert(prop.to_string(), val.clone());
1301                                        }
1302                                    }
1303                                }
1304                                if obj.is_empty() {
1305                                    None
1306                                } else {
1307                                    Some(Value::Object(obj))
1308                                }
1309                            } else {
1310                                None
1311                            };
1312                            let style_str = match style {
1313                                openapiv3::QueryStyle::Form => Some("form"),
1314                                openapiv3::QueryStyle::SpaceDelimited => Some("spaceDelimited"),
1315                                openapiv3::QueryStyle::PipeDelimited => Some("pipeDelimited"),
1316                                openapiv3::QueryStyle::DeepObject => Some("deepObject"),
1317                            };
1318                            validate_parameter_with_deep_object(
1319                                parameter_data,
1320                                query_params,
1321                                "query",
1322                                deep_value,
1323                                style_str,
1324                                aggregate,
1325                                &mut errors,
1326                                &mut details,
1327                            );
1328                        }
1329                        openapiv3::Parameter::Header { parameter_data, .. } => {
1330                            validate_parameter(
1331                                parameter_data,
1332                                header_params,
1333                                "header",
1334                                aggregate,
1335                                &mut errors,
1336                                &mut details,
1337                            );
1338                        }
1339                        openapiv3::Parameter::Cookie { parameter_data, .. } => {
1340                            validate_parameter(
1341                                parameter_data,
1342                                cookie_params,
1343                                "cookie",
1344                                aggregate,
1345                                &mut errors,
1346                                &mut details,
1347                            );
1348                        }
1349                    }
1350                }
1351            }
1352            if errors.is_empty() {
1353                return Ok(());
1354            }
1355            match effective_mode {
1356                ValidationMode::Disabled => Ok(()),
1357                ValidationMode::Warn => {
1358                    tracing::warn!("Request validation warnings: {:?}", errors);
1359                    Ok(())
1360                }
1361                ValidationMode::Enforce => Err(Error::validation(
1362                    serde_json::json!({"errors": errors, "details": details}).to_string(),
1363                )),
1364            }
1365        } else {
1366            Err(Error::generic(format!("Route {} {} not found in OpenAPI spec", method, path)))
1367        }
1368    }
1369
1370    // Legacy helper removed (mock + status selection happens in handler via route.mock_response_with_status)
1371
1372    /// Get all paths defined in the spec
1373    pub fn paths(&self) -> Vec<String> {
1374        let mut paths: Vec<String> = self.routes.iter().map(|route| route.path.clone()).collect();
1375        paths.sort();
1376        paths.dedup();
1377        paths
1378    }
1379
1380    /// Get all HTTP methods supported
1381    pub fn methods(&self) -> Vec<String> {
1382        let mut methods: Vec<String> =
1383            self.routes.iter().map(|route| route.method.clone()).collect();
1384        methods.sort();
1385        methods.dedup();
1386        methods
1387    }
1388
1389    /// Get operation details for a route
1390    pub fn get_operation(&self, path: &str, method: &str) -> Option<OpenApiOperation> {
1391        self.get_route(path, method).map(|route| {
1392            OpenApiOperation::from_operation(
1393                &route.method,
1394                route.path.clone(),
1395                &route.operation,
1396                &self.spec,
1397            )
1398        })
1399    }
1400
1401    /// Extract path parameters from a request path by matching against known routes
1402    pub fn extract_path_parameters(&self, path: &str, method: &str) -> HashMap<String, String> {
1403        for route in &self.routes {
1404            if route.method != method {
1405                continue;
1406            }
1407
1408            if let Some(params) = self.match_path_to_route(path, &route.path) {
1409                return params;
1410            }
1411        }
1412        HashMap::new()
1413    }
1414
1415    /// Match a request path against a route pattern and extract parameters
1416    fn match_path_to_route(
1417        &self,
1418        request_path: &str,
1419        route_pattern: &str,
1420    ) -> Option<HashMap<String, String>> {
1421        let mut params = HashMap::new();
1422
1423        // Split both paths into segments
1424        let request_segments: Vec<&str> = request_path.trim_start_matches('/').split('/').collect();
1425        let pattern_segments: Vec<&str> =
1426            route_pattern.trim_start_matches('/').split('/').collect();
1427
1428        if request_segments.len() != pattern_segments.len() {
1429            return None;
1430        }
1431
1432        for (req_seg, pat_seg) in request_segments.iter().zip(pattern_segments.iter()) {
1433            if pat_seg.starts_with('{') && pat_seg.ends_with('}') {
1434                // This is a parameter
1435                let param_name = &pat_seg[1..pat_seg.len() - 1];
1436                params.insert(param_name.to_string(), req_seg.to_string());
1437            } else if req_seg != pat_seg {
1438                // Static segment doesn't match
1439                return None;
1440            }
1441        }
1442
1443        Some(params)
1444    }
1445
1446    /// Convert OpenAPI path to Axum-compatible path
1447    /// This is a utility function for converting path parameters from {param} to :param format
1448    pub fn convert_path_to_axum(openapi_path: &str) -> String {
1449        // Axum v0.7+ uses {param} format, same as OpenAPI
1450        openapi_path.to_string()
1451    }
1452
1453    /// Build router with AI generator support
1454    pub fn build_router_with_ai(
1455        &self,
1456        ai_generator: Option<Arc<dyn AiGenerator + Send + Sync>>,
1457    ) -> Router {
1458        use axum::routing::{delete, get, patch, post, put};
1459
1460        let mut router = Router::new();
1461        tracing::debug!("Building router with AI support from {} routes", self.routes.len());
1462
1463        for route in &self.routes {
1464            if !route.is_valid_axum_path() {
1465                tracing::warn!(
1466                    "Skipping route with unsupported path syntax: {} {}",
1467                    route.method,
1468                    route.path
1469                );
1470                continue;
1471            }
1472            tracing::debug!("Adding AI-enabled route: {} {}", route.method, route.path);
1473
1474            let route_clone = route.clone();
1475            let ai_generator_clone = ai_generator.clone();
1476
1477            // Create async handler that extracts request data and builds context
1478            let handler = move |headers: HeaderMap, body: Option<Json<Value>>| {
1479                let route = route_clone.clone();
1480                let ai_generator = ai_generator_clone.clone();
1481
1482                async move {
1483                    tracing::debug!(
1484                        "Handling AI request for route: {} {}",
1485                        route.method,
1486                        route.path
1487                    );
1488
1489                    // Build request context
1490                    let mut context = RequestContext::new(route.method.clone(), route.path.clone());
1491
1492                    // Extract headers
1493                    context.headers = headers
1494                        .iter()
1495                        .map(|(k, v)| {
1496                            (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string()))
1497                        })
1498                        .collect();
1499
1500                    // Extract body if present
1501                    context.body = body.map(|Json(b)| b);
1502
1503                    // Generate AI response if AI generator is available and route has AI config
1504                    let (status, response) = if let (Some(generator), Some(_ai_config)) =
1505                        (ai_generator, &route.ai_config)
1506                    {
1507                        route
1508                            .mock_response_with_status_async(&context, Some(generator.as_ref()))
1509                            .await
1510                    } else {
1511                        // No AI support, use static response
1512                        route.mock_response_with_status()
1513                    };
1514
1515                    (
1516                        axum::http::StatusCode::from_u16(status)
1517                            .unwrap_or(axum::http::StatusCode::OK),
1518                        Json(response),
1519                    )
1520                }
1521            };
1522
1523            let axum_path = route.axum_path();
1524            match route.method.as_str() {
1525                "GET" => {
1526                    router = router.route(&axum_path, get(handler));
1527                }
1528                "POST" => {
1529                    router = router.route(&axum_path, post(handler));
1530                }
1531                "PUT" => {
1532                    router = router.route(&axum_path, put(handler));
1533                }
1534                "DELETE" => {
1535                    router = router.route(&axum_path, delete(handler));
1536                }
1537                "PATCH" => {
1538                    router = router.route(&axum_path, patch(handler));
1539                }
1540                _ => tracing::warn!("Unsupported HTTP method for AI: {}", route.method),
1541            }
1542        }
1543
1544        router
1545    }
1546
1547    /// Build router with MockAI (Behavioral Mock Intelligence) support
1548    ///
1549    /// This method integrates MockAI for intelligent, context-aware response generation,
1550    /// mutation detection, validation error generation, and pagination intelligence.
1551    ///
1552    /// # Arguments
1553    /// * `mockai` - Optional MockAI instance for intelligent behavior
1554    ///
1555    /// # Returns
1556    /// Axum router with MockAI-powered response generation
1557    pub fn build_router_with_mockai(
1558        &self,
1559        mockai: Option<Arc<tokio::sync::RwLock<crate::intelligent_behavior::MockAI>>>,
1560    ) -> Router {
1561        use crate::intelligent_behavior::Request as MockAIRequest;
1562
1563        use axum::routing::{delete, get, patch, post, put};
1564
1565        let mut router = Router::new();
1566        tracing::debug!("Building router with MockAI support from {} routes", self.routes.len());
1567
1568        // Get custom fixture loader for fixture checking
1569        let custom_loader = self.custom_fixture_loader.clone();
1570
1571        for route in &self.routes {
1572            if !route.is_valid_axum_path() {
1573                tracing::warn!(
1574                    "Skipping route with unsupported path syntax: {} {}",
1575                    route.method,
1576                    route.path
1577                );
1578                continue;
1579            }
1580            tracing::debug!("Adding MockAI-enabled route: {} {}", route.method, route.path);
1581
1582            let route_clone = route.clone();
1583            let mockai_clone = mockai.clone();
1584            let custom_loader_clone = custom_loader.clone();
1585
1586            // Create async handler that processes requests through MockAI
1587            // Query params are extracted via Query extractor with HashMap
1588            // Note: Using Query<HashMap<String, String>> wrapped in Option to handle missing query params
1589            let handler = move |query: axum::extract::Query<HashMap<String, String>>,
1590                                headers: HeaderMap,
1591                                body: Option<Json<Value>>| {
1592                let route = route_clone.clone();
1593                let mockai = mockai_clone.clone();
1594
1595                async move {
1596                    tracing::info!(
1597                        "[FIXTURE DEBUG] Starting fixture check for {} {} (custom_loader available: {})",
1598                        route.method,
1599                        route.path,
1600                        custom_loader_clone.is_some()
1601                    );
1602
1603                    // Check for custom fixture first (highest priority, before MockAI)
1604                    if let Some(ref loader) = custom_loader_clone {
1605                        use crate::RequestFingerprint;
1606                        use axum::http::{Method, Uri};
1607
1608                        // Build query string from parsed query params
1609                        let query_string = if query.0.is_empty() {
1610                            String::new()
1611                        } else {
1612                            query
1613                                .0
1614                                .iter()
1615                                .map(|(k, v)| format!("{}={}", k, v))
1616                                .collect::<Vec<_>>()
1617                                .join("&")
1618                        };
1619
1620                        // Normalize the path to match fixture normalization
1621                        let normalized_request_path =
1622                            crate::CustomFixtureLoader::normalize_path(&route.path);
1623
1624                        tracing::info!(
1625                            "[FIXTURE DEBUG] Path normalization: original='{}', normalized='{}'",
1626                            route.path,
1627                            normalized_request_path
1628                        );
1629
1630                        // Create URI for fingerprint
1631                        let uri_str = if query_string.is_empty() {
1632                            normalized_request_path.clone()
1633                        } else {
1634                            format!("{}?{}", normalized_request_path, query_string)
1635                        };
1636
1637                        tracing::info!(
1638                            "[FIXTURE DEBUG] URI construction: uri_str='{}', query_string='{}'",
1639                            uri_str,
1640                            query_string
1641                        );
1642
1643                        if let Ok(uri) = uri_str.parse::<Uri>() {
1644                            let http_method =
1645                                Method::from_bytes(route.method.as_bytes()).unwrap_or(Method::GET);
1646
1647                            // Convert body to bytes for fingerprint
1648                            let body_bytes =
1649                                body.as_ref().and_then(|Json(b)| serde_json::to_vec(b).ok());
1650                            let body_slice = body_bytes.as_deref();
1651
1652                            let fingerprint =
1653                                RequestFingerprint::new(http_method, &uri, &headers, body_slice);
1654
1655                            tracing::info!(
1656                                "[FIXTURE DEBUG] RequestFingerprint created: method='{}', path='{}', query='{}', body_hash={:?}",
1657                                fingerprint.method,
1658                                fingerprint.path,
1659                                fingerprint.query,
1660                                fingerprint.body_hash
1661                            );
1662
1663                            // Check what fixtures are available for this method
1664                            let available_fixtures = loader.has_fixture(&fingerprint);
1665                            tracing::info!(
1666                                "[FIXTURE DEBUG] Fixture check result: has_fixture={}",
1667                                available_fixtures
1668                            );
1669
1670                            if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
1671                                tracing::info!(
1672                                    "[FIXTURE DEBUG] ✅ FIXTURE MATCHED! Using custom fixture for {} {} (status: {}, path: '{}')",
1673                                    route.method,
1674                                    route.path,
1675                                    custom_fixture.status,
1676                                    custom_fixture.path
1677                                );
1678
1679                                // Apply delay if specified
1680                                if custom_fixture.delay_ms > 0 {
1681                                    tokio::time::sleep(tokio::time::Duration::from_millis(
1682                                        custom_fixture.delay_ms,
1683                                    ))
1684                                    .await;
1685                                }
1686
1687                                // Convert response to JSON string if needed
1688                                let response_body = if custom_fixture.response.is_string() {
1689                                    custom_fixture.response.as_str().unwrap().to_string()
1690                                } else {
1691                                    serde_json::to_string(&custom_fixture.response)
1692                                        .unwrap_or_else(|_| "{}".to_string())
1693                                };
1694
1695                                // Parse response body as JSON
1696                                let json_value: Value = serde_json::from_str(&response_body)
1697                                    .unwrap_or_else(|_| serde_json::json!({}));
1698
1699                                // Build response with status and JSON body
1700                                let status =
1701                                    axum::http::StatusCode::from_u16(custom_fixture.status)
1702                                        .unwrap_or(axum::http::StatusCode::OK);
1703
1704                                // Return as tuple (StatusCode, Json) to match handler signature
1705                                return (status, Json(json_value));
1706                            } else {
1707                                tracing::warn!(
1708                                    "[FIXTURE DEBUG] ❌ No fixture match found for {} {} (fingerprint.path='{}', normalized='{}')",
1709                                    route.method,
1710                                    route.path,
1711                                    fingerprint.path,
1712                                    normalized_request_path
1713                                );
1714                            }
1715                        } else {
1716                            tracing::warn!("[FIXTURE DEBUG] Failed to parse URI: '{}'", uri_str);
1717                        }
1718                    } else {
1719                        tracing::warn!(
1720                            "[FIXTURE DEBUG] Custom fixture loader not available for {} {}",
1721                            route.method,
1722                            route.path
1723                        );
1724                    }
1725
1726                    tracing::debug!(
1727                        "Handling MockAI request for route: {} {}",
1728                        route.method,
1729                        route.path
1730                    );
1731
1732                    // Query parameters are already parsed by Query extractor
1733                    let mockai_query = query.0;
1734
1735                    // If MockAI is enabled, use it to process the request
1736                    // CRITICAL FIX: Skip MockAI for GET, HEAD, and OPTIONS requests
1737                    // These are read-only operations and should use OpenAPI response generation
1738                    // MockAI's mutation analysis incorrectly treats GET requests as "Create" mutations
1739                    let method_upper = route.method.to_uppercase();
1740                    let should_use_mockai =
1741                        matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH" | "DELETE");
1742
1743                    if should_use_mockai {
1744                        if let Some(mockai_arc) = mockai {
1745                            let mockai_guard = mockai_arc.read().await;
1746
1747                            // Build MockAI request
1748                            let mut mockai_headers = HashMap::new();
1749                            for (k, v) in headers.iter() {
1750                                mockai_headers
1751                                    .insert(k.to_string(), v.to_str().unwrap_or("").to_string());
1752                            }
1753
1754                            let mockai_request = MockAIRequest {
1755                                method: route.method.clone(),
1756                                path: route.path.clone(),
1757                                body: body.as_ref().map(|Json(b)| b.clone()),
1758                                query_params: mockai_query,
1759                                headers: mockai_headers,
1760                            };
1761
1762                            // Process request through MockAI
1763                            match mockai_guard.process_request(&mockai_request).await {
1764                                Ok(mockai_response) => {
1765                                    // Check if MockAI returned an empty object (signals to use OpenAPI generation)
1766                                    let is_empty = mockai_response.body.is_object()
1767                                        && mockai_response
1768                                            .body
1769                                            .as_object()
1770                                            .map(|obj| obj.is_empty())
1771                                            .unwrap_or(false);
1772
1773                                    if is_empty {
1774                                        tracing::debug!(
1775                                            "MockAI returned empty object for {} {}, falling back to OpenAPI response generation",
1776                                            route.method,
1777                                            route.path
1778                                        );
1779                                        // Fall through to standard OpenAPI response generation
1780                                    } else {
1781                                        // Use the status code from the OpenAPI spec rather than
1782                                        // MockAI's hardcoded 200, so that e.g. POST returning 201
1783                                        // is honored correctly.
1784                                        let spec_status = route.find_first_available_status_code();
1785                                        tracing::debug!(
1786                                            "MockAI generated response for {} {}, using spec status: {} (MockAI suggested: {})",
1787                                            route.method,
1788                                            route.path,
1789                                            spec_status,
1790                                            mockai_response.status_code
1791                                        );
1792                                        return (
1793                                            axum::http::StatusCode::from_u16(spec_status)
1794                                                .unwrap_or(axum::http::StatusCode::OK),
1795                                            Json(mockai_response.body),
1796                                        );
1797                                    }
1798                                }
1799                                Err(e) => {
1800                                    tracing::warn!(
1801                                        "MockAI processing failed for {} {}: {}, falling back to standard response",
1802                                        route.method,
1803                                        route.path,
1804                                        e
1805                                    );
1806                                    // Fall through to standard response generation
1807                                }
1808                            }
1809                        }
1810                    } else {
1811                        tracing::debug!(
1812                            "Skipping MockAI for {} request {} - using OpenAPI response generation",
1813                            method_upper,
1814                            route.path
1815                        );
1816                    }
1817
1818                    // Check for status code override header
1819                    let status_override = headers
1820                        .get("X-Mockforge-Response-Status")
1821                        .and_then(|v| v.to_str().ok())
1822                        .and_then(|s| s.parse::<u16>().ok());
1823
1824                    // Check for scenario header
1825                    let scenario = headers
1826                        .get("X-Mockforge-Scenario")
1827                        .and_then(|v| v.to_str().ok())
1828                        .map(|s| s.to_string())
1829                        .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
1830
1831                    // Fallback to standard response generation
1832                    let (status, response) = route
1833                        .mock_response_with_status_and_scenario_and_override(
1834                            scenario.as_deref(),
1835                            status_override,
1836                        );
1837                    (
1838                        axum::http::StatusCode::from_u16(status)
1839                            .unwrap_or(axum::http::StatusCode::OK),
1840                        Json(response),
1841                    )
1842                }
1843            };
1844
1845            let axum_path = route.axum_path();
1846            match route.method.as_str() {
1847                "GET" => {
1848                    router = router.route(&axum_path, get(handler));
1849                }
1850                "POST" => {
1851                    router = router.route(&axum_path, post(handler));
1852                }
1853                "PUT" => {
1854                    router = router.route(&axum_path, put(handler));
1855                }
1856                "DELETE" => {
1857                    router = router.route(&axum_path, delete(handler));
1858                }
1859                "PATCH" => {
1860                    router = router.route(&axum_path, patch(handler));
1861                }
1862                _ => tracing::warn!("Unsupported HTTP method for MockAI: {}", route.method),
1863            }
1864        }
1865
1866        router
1867    }
1868}
1869
1870// Note: templating helpers are now in core::templating (shared across modules)
1871
1872/// Extract multipart form data from request body bytes
1873/// Returns (form_fields, file_paths) where file_paths maps field names to stored file paths
1874async fn extract_multipart_from_bytes(
1875    body: &axum::body::Bytes,
1876    headers: &HeaderMap,
1877) -> Result<(HashMap<String, Value>, HashMap<String, String>)> {
1878    // Get boundary from Content-Type header
1879    let boundary = headers
1880        .get(axum::http::header::CONTENT_TYPE)
1881        .and_then(|v| v.to_str().ok())
1882        .and_then(|ct| {
1883            ct.split(';').find_map(|part| {
1884                let part = part.trim();
1885                if part.starts_with("boundary=") {
1886                    Some(part.strip_prefix("boundary=").unwrap_or("").trim_matches('"'))
1887                } else {
1888                    None
1889                }
1890            })
1891        })
1892        .ok_or_else(|| Error::generic("Missing boundary in Content-Type header"))?;
1893
1894    let mut fields = HashMap::new();
1895    let mut files = HashMap::new();
1896
1897    // Parse multipart data using bytes directly (not string conversion)
1898    // Multipart format: --boundary\r\n...\r\n--boundary\r\n...\r\n--boundary--\r\n
1899    let boundary_prefix = format!("--{}", boundary).into_bytes();
1900    let boundary_line = format!("\r\n--{}\r\n", boundary).into_bytes();
1901    let end_boundary = format!("\r\n--{}--\r\n", boundary).into_bytes();
1902
1903    // Find all boundary positions
1904    let mut pos = 0;
1905    let mut parts = Vec::new();
1906
1907    // Skip initial boundary if present
1908    if body.starts_with(&boundary_prefix) {
1909        if let Some(first_crlf) = body.iter().position(|&b| b == b'\r') {
1910            pos = first_crlf + 2; // Skip --boundary\r\n
1911        }
1912    }
1913
1914    // Find all middle boundaries
1915    while let Some(boundary_pos) = body[pos..]
1916        .windows(boundary_line.len())
1917        .position(|window| window == boundary_line.as_slice())
1918    {
1919        let actual_pos = pos + boundary_pos;
1920        if actual_pos > pos {
1921            parts.push((pos, actual_pos));
1922        }
1923        pos = actual_pos + boundary_line.len();
1924    }
1925
1926    // Find final boundary
1927    if let Some(end_pos) = body[pos..]
1928        .windows(end_boundary.len())
1929        .position(|window| window == end_boundary.as_slice())
1930    {
1931        let actual_end = pos + end_pos;
1932        if actual_end > pos {
1933            parts.push((pos, actual_end));
1934        }
1935    } else if pos < body.len() {
1936        // No final boundary found, treat rest as last part
1937        parts.push((pos, body.len()));
1938    }
1939
1940    // Process each part
1941    for (start, end) in parts {
1942        let part_data = &body[start..end];
1943
1944        // Find header/body separator (CRLF CRLF)
1945        let separator = b"\r\n\r\n";
1946        if let Some(sep_pos) =
1947            part_data.windows(separator.len()).position(|window| window == separator)
1948        {
1949            let header_bytes = &part_data[..sep_pos];
1950            let body_start = sep_pos + separator.len();
1951            let body_data = &part_data[body_start..];
1952
1953            // Parse headers (assuming UTF-8)
1954            let header_str = String::from_utf8_lossy(header_bytes);
1955            let mut field_name = None;
1956            let mut filename = None;
1957
1958            for header_line in header_str.lines() {
1959                if header_line.starts_with("Content-Disposition:") {
1960                    // Extract field name
1961                    if let Some(name_start) = header_line.find("name=\"") {
1962                        let name_start = name_start + 6;
1963                        if let Some(name_end) = header_line[name_start..].find('"') {
1964                            field_name =
1965                                Some(header_line[name_start..name_start + name_end].to_string());
1966                        }
1967                    }
1968
1969                    // Extract filename if present
1970                    if let Some(file_start) = header_line.find("filename=\"") {
1971                        let file_start = file_start + 10;
1972                        if let Some(file_end) = header_line[file_start..].find('"') {
1973                            filename =
1974                                Some(header_line[file_start..file_start + file_end].to_string());
1975                        }
1976                    }
1977                }
1978            }
1979
1980            if let Some(name) = field_name {
1981                if let Some(file) = filename {
1982                    // This is a file upload - store to temp directory
1983                    let temp_dir = std::env::temp_dir().join("mockforge-uploads");
1984                    std::fs::create_dir_all(&temp_dir).map_err(|e| {
1985                        Error::generic(format!("Failed to create temp directory: {}", e))
1986                    })?;
1987
1988                    let file_path = temp_dir.join(format!("{}_{}", uuid::Uuid::new_v4(), file));
1989                    std::fs::write(&file_path, body_data)
1990                        .map_err(|e| Error::generic(format!("Failed to write file: {}", e)))?;
1991
1992                    let file_path_str = file_path.to_string_lossy().to_string();
1993                    files.insert(name.clone(), file_path_str.clone());
1994                    fields.insert(name, Value::String(file_path_str));
1995                } else {
1996                    // This is a regular form field - try to parse as UTF-8 string
1997                    // Trim trailing CRLF
1998                    let body_str = body_data
1999                        .strip_suffix(b"\r\n")
2000                        .or_else(|| body_data.strip_suffix(b"\n"))
2001                        .unwrap_or(body_data);
2002
2003                    if let Ok(field_value) = String::from_utf8(body_str.to_vec()) {
2004                        fields.insert(name, Value::String(field_value.trim().to_string()));
2005                    } else {
2006                        // Non-UTF-8 field value - store as base64 encoded string
2007                        use base64::{engine::general_purpose, Engine as _};
2008                        fields.insert(
2009                            name,
2010                            Value::String(general_purpose::STANDARD.encode(body_str)),
2011                        );
2012                    }
2013                }
2014            }
2015        }
2016    }
2017
2018    Ok((fields, files))
2019}
2020
2021static LAST_ERRORS: Lazy<Mutex<VecDeque<Value>>> =
2022    Lazy::new(|| Mutex::new(VecDeque::with_capacity(20)));
2023
2024/// Record last validation error for Admin UI inspection
2025pub fn record_validation_error(v: &Value) {
2026    if let Ok(mut q) = LAST_ERRORS.lock() {
2027        if q.len() >= 20 {
2028            q.pop_front();
2029        }
2030        q.push_back(v.clone());
2031    }
2032    // If mutex is poisoned, we silently fail - validation errors are informational only
2033}
2034
2035/// Get most recent validation error
2036pub fn get_last_validation_error() -> Option<Value> {
2037    LAST_ERRORS.lock().ok()?.back().cloned()
2038}
2039
2040/// Get recent validation errors (most recent last)
2041pub fn get_validation_errors() -> Vec<Value> {
2042    LAST_ERRORS.lock().map(|q| q.iter().cloned().collect()).unwrap_or_default()
2043}
2044
2045/// Coerce a parameter `value` into the expected JSON type per `schema` where reasonable.
2046/// Applies only to param contexts (not request bodies). Conservative conversions:
2047/// - integer/number: parse from string; arrays: split comma-separated strings and coerce items
2048/// - boolean: parse true/false (case-insensitive) from string
2049fn coerce_value_for_schema(value: &Value, schema: &openapiv3::Schema) -> Value {
2050    // Basic coercion: try to parse strings as appropriate types
2051    match value {
2052        Value::String(s) => {
2053            // Check if schema expects an array and we have a comma-separated string
2054            if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2055                &schema.schema_kind
2056            {
2057                if s.contains(',') {
2058                    // Split comma-separated string into array
2059                    let parts: Vec<&str> = s.split(',').map(|s| s.trim()).collect();
2060                    let mut array_values = Vec::new();
2061
2062                    for part in parts {
2063                        // Coerce each part based on array item type
2064                        if let Some(items_schema) = &array_type.items {
2065                            if let Some(items_schema_obj) = items_schema.as_item() {
2066                                let part_value = Value::String(part.to_string());
2067                                let coerced_part =
2068                                    coerce_value_for_schema(&part_value, items_schema_obj);
2069                                array_values.push(coerced_part);
2070                            } else {
2071                                // If items schema is a reference or not available, keep as string
2072                                array_values.push(Value::String(part.to_string()));
2073                            }
2074                        } else {
2075                            // No items schema defined, keep as string
2076                            array_values.push(Value::String(part.to_string()));
2077                        }
2078                    }
2079                    return Value::Array(array_values);
2080                }
2081            }
2082
2083            // Only coerce if the schema expects a different type
2084            match &schema.schema_kind {
2085                openapiv3::SchemaKind::Type(openapiv3::Type::String(_)) => {
2086                    // Schema expects string, keep as string
2087                    value.clone()
2088                }
2089                openapiv3::SchemaKind::Type(openapiv3::Type::Number(_)) => {
2090                    // Schema expects number, try to parse
2091                    if let Ok(n) = s.parse::<f64>() {
2092                        if let Some(num) = serde_json::Number::from_f64(n) {
2093                            return Value::Number(num);
2094                        }
2095                    }
2096                    value.clone()
2097                }
2098                openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_)) => {
2099                    // Schema expects integer, try to parse
2100                    if let Ok(n) = s.parse::<i64>() {
2101                        if let Some(num) = serde_json::Number::from_f64(n as f64) {
2102                            return Value::Number(num);
2103                        }
2104                    }
2105                    value.clone()
2106                }
2107                openapiv3::SchemaKind::Type(openapiv3::Type::Boolean(_)) => {
2108                    // Schema expects boolean, try to parse
2109                    match s.to_lowercase().as_str() {
2110                        "true" | "1" | "yes" | "on" => Value::Bool(true),
2111                        "false" | "0" | "no" | "off" => Value::Bool(false),
2112                        _ => value.clone(),
2113                    }
2114                }
2115                _ => {
2116                    // Unknown schema type, keep as string
2117                    value.clone()
2118                }
2119            }
2120        }
2121        _ => value.clone(),
2122    }
2123}
2124
2125/// Apply style-aware coercion for query params
2126fn coerce_by_style(value: &Value, schema: &openapiv3::Schema, style: Option<&str>) -> Value {
2127    // Style-aware coercion for query parameters
2128    match value {
2129        Value::String(s) => {
2130            // Check if schema expects an array and we have a delimited string
2131            if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2132                &schema.schema_kind
2133            {
2134                let delimiter = match style {
2135                    Some("spaceDelimited") => " ",
2136                    Some("pipeDelimited") => "|",
2137                    Some("form") | None => ",", // Default to form style (comma-separated)
2138                    _ => ",",                   // Fallback to comma
2139                };
2140
2141                if s.contains(delimiter) {
2142                    // Split delimited string into array
2143                    let parts: Vec<&str> = s.split(delimiter).map(|s| s.trim()).collect();
2144                    let mut array_values = Vec::new();
2145
2146                    for part in parts {
2147                        // Coerce each part based on array item type
2148                        if let Some(items_schema) = &array_type.items {
2149                            if let Some(items_schema_obj) = items_schema.as_item() {
2150                                let part_value = Value::String(part.to_string());
2151                                let coerced_part =
2152                                    coerce_by_style(&part_value, items_schema_obj, style);
2153                                array_values.push(coerced_part);
2154                            } else {
2155                                // If items schema is a reference or not available, keep as string
2156                                array_values.push(Value::String(part.to_string()));
2157                            }
2158                        } else {
2159                            // No items schema defined, keep as string
2160                            array_values.push(Value::String(part.to_string()));
2161                        }
2162                    }
2163                    return Value::Array(array_values);
2164                }
2165            }
2166
2167            // Try to parse as number first
2168            if let Ok(n) = s.parse::<f64>() {
2169                if let Some(num) = serde_json::Number::from_f64(n) {
2170                    return Value::Number(num);
2171                }
2172            }
2173            // Try to parse as boolean
2174            match s.to_lowercase().as_str() {
2175                "true" | "1" | "yes" | "on" => return Value::Bool(true),
2176                "false" | "0" | "no" | "off" => return Value::Bool(false),
2177                _ => {}
2178            }
2179            // Keep as string
2180            value.clone()
2181        }
2182        _ => value.clone(),
2183    }
2184}
2185
2186/// Build a deepObject from query params like `name[prop]=val`
2187fn build_deep_object(name: &str, params: &Map<String, Value>) -> Option<Value> {
2188    let prefix = format!("{}[", name);
2189    let mut obj = Map::new();
2190    for (k, v) in params.iter() {
2191        if let Some(rest) = k.strip_prefix(&prefix) {
2192            if let Some(key) = rest.strip_suffix(']') {
2193                obj.insert(key.to_string(), v.clone());
2194            }
2195        }
2196    }
2197    if obj.is_empty() {
2198        None
2199    } else {
2200        Some(Value::Object(obj))
2201    }
2202}
2203
2204// Import the enhanced schema diff functionality
2205// use crate::schema_diff::{validation_diff, to_enhanced_422_json, ValidationError}; // Not currently used
2206
2207/// Generate an enhanced 422 response with detailed schema validation errors
2208/// This function provides comprehensive error information using the new schema diff utility
2209#[allow(clippy::too_many_arguments)]
2210fn generate_enhanced_422_response(
2211    validator: &OpenApiRouteRegistry,
2212    path_template: &str,
2213    method: &str,
2214    body: Option<&Value>,
2215    path_params: &Map<String, Value>,
2216    query_params: &Map<String, Value>,
2217    header_params: &Map<String, Value>,
2218    cookie_params: &Map<String, Value>,
2219) -> Value {
2220    let mut field_errors = Vec::new();
2221
2222    // Extract schema validation details if we have a route
2223    if let Some(route) = validator.get_route(path_template, method) {
2224        // Validate request body with detailed error collection
2225        if let Some(schema) = &route.operation.request_body {
2226            if let Some(value) = body {
2227                if let Some(content) =
2228                    schema.as_item().and_then(|rb| rb.content.get("application/json"))
2229                {
2230                    if let Some(_schema_ref) = &content.schema {
2231                        // Basic JSON validation - schema validation deferred
2232                        if serde_json::from_value::<Value>(value.clone()).is_err() {
2233                            field_errors.push(json!({
2234                                "path": "body",
2235                                "message": "invalid JSON"
2236                            }));
2237                        }
2238                    }
2239                }
2240            } else {
2241                field_errors.push(json!({
2242                    "path": "body",
2243                    "expected": "object",
2244                    "found": "missing",
2245                    "message": "Request body is required but not provided"
2246                }));
2247            }
2248        }
2249
2250        // Validate parameters with detailed error collection
2251        for param_ref in &route.operation.parameters {
2252            if let Some(param) = param_ref.as_item() {
2253                match param {
2254                    openapiv3::Parameter::Path { parameter_data, .. } => {
2255                        validate_parameter_detailed(
2256                            parameter_data,
2257                            path_params,
2258                            "path",
2259                            "path parameter",
2260                            &mut field_errors,
2261                        );
2262                    }
2263                    openapiv3::Parameter::Query { parameter_data, .. } => {
2264                        let deep_value = if Some("form") == Some("deepObject") {
2265                            build_deep_object(&parameter_data.name, query_params)
2266                        } else {
2267                            None
2268                        };
2269                        validate_parameter_detailed_with_deep(
2270                            parameter_data,
2271                            query_params,
2272                            "query",
2273                            "query parameter",
2274                            deep_value,
2275                            &mut field_errors,
2276                        );
2277                    }
2278                    openapiv3::Parameter::Header { parameter_data, .. } => {
2279                        validate_parameter_detailed(
2280                            parameter_data,
2281                            header_params,
2282                            "header",
2283                            "header parameter",
2284                            &mut field_errors,
2285                        );
2286                    }
2287                    openapiv3::Parameter::Cookie { parameter_data, .. } => {
2288                        validate_parameter_detailed(
2289                            parameter_data,
2290                            cookie_params,
2291                            "cookie",
2292                            "cookie parameter",
2293                            &mut field_errors,
2294                        );
2295                    }
2296                }
2297            }
2298        }
2299    }
2300
2301    // Return the detailed 422 error format
2302    json!({
2303        "error": "Schema validation failed",
2304        "details": field_errors,
2305        "method": method,
2306        "path": path_template,
2307        "timestamp": Utc::now().to_rfc3339(),
2308        "validation_type": "openapi_schema"
2309    })
2310}
2311
2312/// Helper function to validate a parameter
2313fn validate_parameter(
2314    parameter_data: &openapiv3::ParameterData,
2315    params_map: &Map<String, Value>,
2316    prefix: &str,
2317    aggregate: bool,
2318    errors: &mut Vec<String>,
2319    details: &mut Vec<Value>,
2320) {
2321    match params_map.get(&parameter_data.name) {
2322        Some(v) => {
2323            if let ParameterSchemaOrContent::Schema(s) = &parameter_data.format {
2324                if let Some(schema) = s.as_item() {
2325                    let coerced = coerce_value_for_schema(v, schema);
2326                    // Validate the coerced value against the schema
2327                    if let Err(validation_error) =
2328                        OpenApiSchema::new(schema.clone()).validate(&coerced)
2329                    {
2330                        let error_msg = validation_error.to_string();
2331                        errors.push(format!(
2332                            "{} parameter '{}' validation failed: {}",
2333                            prefix, parameter_data.name, error_msg
2334                        ));
2335                        if aggregate {
2336                            details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2337                        }
2338                    }
2339                }
2340            }
2341        }
2342        None => {
2343            if parameter_data.required {
2344                errors.push(format!(
2345                    "missing required {} parameter '{}'",
2346                    prefix, parameter_data.name
2347                ));
2348                details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2349            }
2350        }
2351    }
2352}
2353
2354/// Helper function to validate a parameter with deep object support
2355#[allow(clippy::too_many_arguments)]
2356fn validate_parameter_with_deep_object(
2357    parameter_data: &openapiv3::ParameterData,
2358    params_map: &Map<String, Value>,
2359    prefix: &str,
2360    deep_value: Option<Value>,
2361    style: Option<&str>,
2362    aggregate: bool,
2363    errors: &mut Vec<String>,
2364    details: &mut Vec<Value>,
2365) {
2366    match deep_value.as_ref().or_else(|| params_map.get(&parameter_data.name)) {
2367        Some(v) => {
2368            if let ParameterSchemaOrContent::Schema(s) = &parameter_data.format {
2369                if let Some(schema) = s.as_item() {
2370                    let coerced = coerce_by_style(v, schema, style); // Use the actual style
2371                                                                     // Validate the coerced value against the schema
2372                    if let Err(validation_error) =
2373                        OpenApiSchema::new(schema.clone()).validate(&coerced)
2374                    {
2375                        let error_msg = validation_error.to_string();
2376                        errors.push(format!(
2377                            "{} parameter '{}' validation failed: {}",
2378                            prefix, parameter_data.name, error_msg
2379                        ));
2380                        if aggregate {
2381                            details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2382                        }
2383                    }
2384                }
2385            }
2386        }
2387        None => {
2388            if parameter_data.required {
2389                errors.push(format!(
2390                    "missing required {} parameter '{}'",
2391                    prefix, parameter_data.name
2392                ));
2393                details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2394            }
2395        }
2396    }
2397}
2398
2399/// Helper function to validate a parameter with detailed error collection
2400fn validate_parameter_detailed(
2401    parameter_data: &openapiv3::ParameterData,
2402    params_map: &Map<String, Value>,
2403    location: &str,
2404    value_type: &str,
2405    field_errors: &mut Vec<Value>,
2406) {
2407    match params_map.get(&parameter_data.name) {
2408        Some(value) => {
2409            if let ParameterSchemaOrContent::Schema(schema) = &parameter_data.format {
2410                // Collect detailed validation errors for this parameter
2411                let details: Vec<Value> = Vec::new();
2412                let param_path = format!("{}.{}", location, parameter_data.name);
2413
2414                // Apply coercion before validation
2415                if let Some(schema_ref) = schema.as_item() {
2416                    let coerced_value = coerce_value_for_schema(value, schema_ref);
2417                    // Validate the coerced value against the schema
2418                    if let Err(validation_error) =
2419                        OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2420                    {
2421                        field_errors.push(json!({
2422                            "path": param_path,
2423                            "expected": "valid according to schema",
2424                            "found": coerced_value,
2425                            "message": validation_error.to_string()
2426                        }));
2427                    }
2428                }
2429
2430                for detail in details {
2431                    field_errors.push(json!({
2432                        "path": detail["path"],
2433                        "expected": detail["expected_type"],
2434                        "found": detail["value"],
2435                        "message": detail["message"]
2436                    }));
2437                }
2438            }
2439        }
2440        None => {
2441            if parameter_data.required {
2442                field_errors.push(json!({
2443                    "path": format!("{}.{}", location, parameter_data.name),
2444                    "expected": "value",
2445                    "found": "missing",
2446                    "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
2447                }));
2448            }
2449        }
2450    }
2451}
2452
2453/// Helper function to validate a parameter with deep object support and detailed errors
2454fn validate_parameter_detailed_with_deep(
2455    parameter_data: &openapiv3::ParameterData,
2456    params_map: &Map<String, Value>,
2457    location: &str,
2458    value_type: &str,
2459    deep_value: Option<Value>,
2460    field_errors: &mut Vec<Value>,
2461) {
2462    match deep_value.as_ref().or_else(|| params_map.get(&parameter_data.name)) {
2463        Some(value) => {
2464            if let ParameterSchemaOrContent::Schema(schema) = &parameter_data.format {
2465                // Collect detailed validation errors for this parameter
2466                let details: Vec<Value> = Vec::new();
2467                let param_path = format!("{}.{}", location, parameter_data.name);
2468
2469                // Apply coercion before validation
2470                if let Some(schema_ref) = schema.as_item() {
2471                    let coerced_value = coerce_by_style(value, schema_ref, Some("form")); // Default to form style for now
2472                                                                                          // Validate the coerced value against the schema
2473                    if let Err(validation_error) =
2474                        OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2475                    {
2476                        field_errors.push(json!({
2477                            "path": param_path,
2478                            "expected": "valid according to schema",
2479                            "found": coerced_value,
2480                            "message": validation_error.to_string()
2481                        }));
2482                    }
2483                }
2484
2485                for detail in details {
2486                    field_errors.push(json!({
2487                        "path": detail["path"],
2488                        "expected": detail["expected_type"],
2489                        "found": detail["value"],
2490                        "message": detail["message"]
2491                    }));
2492                }
2493            }
2494        }
2495        None => {
2496            if parameter_data.required {
2497                field_errors.push(json!({
2498                    "path": format!("{}.{}", location, parameter_data.name),
2499                    "expected": "value",
2500                    "found": "missing",
2501                    "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
2502                }));
2503            }
2504        }
2505    }
2506}
2507
2508/// Helper function to create an OpenAPI route registry from a file
2509pub async fn create_registry_from_file<P: AsRef<std::path::Path>>(
2510    path: P,
2511) -> Result<OpenApiRouteRegistry> {
2512    let spec = OpenApiSpec::from_file(path).await?;
2513    spec.validate()?;
2514    Ok(OpenApiRouteRegistry::new(spec))
2515}
2516
2517/// Helper function to create an OpenAPI route registry from JSON
2518pub fn create_registry_from_json(json: Value) -> Result<OpenApiRouteRegistry> {
2519    let spec = OpenApiSpec::from_json(json)?;
2520    spec.validate()?;
2521    Ok(OpenApiRouteRegistry::new(spec))
2522}
2523
2524#[cfg(test)]
2525mod tests {
2526    use super::*;
2527    use serde_json::json;
2528    use tempfile::TempDir;
2529
2530    #[tokio::test]
2531    async fn test_registry_creation() {
2532        let spec_json = json!({
2533            "openapi": "3.0.0",
2534            "info": {
2535                "title": "Test API",
2536                "version": "1.0.0"
2537            },
2538            "paths": {
2539                "/users": {
2540                    "get": {
2541                        "summary": "Get users",
2542                        "responses": {
2543                            "200": {
2544                                "description": "Success",
2545                                "content": {
2546                                    "application/json": {
2547                                        "schema": {
2548                                            "type": "array",
2549                                            "items": {
2550                                                "type": "object",
2551                                                "properties": {
2552                                                    "id": {"type": "integer"},
2553                                                    "name": {"type": "string"}
2554                                                }
2555                                            }
2556                                        }
2557                                    }
2558                                }
2559                            }
2560                        }
2561                    },
2562                    "post": {
2563                        "summary": "Create user",
2564                        "requestBody": {
2565                            "content": {
2566                                "application/json": {
2567                                    "schema": {
2568                                        "type": "object",
2569                                        "properties": {
2570                                            "name": {"type": "string"}
2571                                        },
2572                                        "required": ["name"]
2573                                    }
2574                                }
2575                            }
2576                        },
2577                        "responses": {
2578                            "201": {
2579                                "description": "Created",
2580                                "content": {
2581                                    "application/json": {
2582                                        "schema": {
2583                                            "type": "object",
2584                                            "properties": {
2585                                                "id": {"type": "integer"},
2586                                                "name": {"type": "string"}
2587                                            }
2588                                        }
2589                                    }
2590                                }
2591                            }
2592                        }
2593                    }
2594                },
2595                "/users/{id}": {
2596                    "get": {
2597                        "summary": "Get user by ID",
2598                        "parameters": [
2599                            {
2600                                "name": "id",
2601                                "in": "path",
2602                                "required": true,
2603                                "schema": {"type": "integer"}
2604                            }
2605                        ],
2606                        "responses": {
2607                            "200": {
2608                                "description": "Success",
2609                                "content": {
2610                                    "application/json": {
2611                                        "schema": {
2612                                            "type": "object",
2613                                            "properties": {
2614                                                "id": {"type": "integer"},
2615                                                "name": {"type": "string"}
2616                                            }
2617                                        }
2618                                    }
2619                                }
2620                            }
2621                        }
2622                    }
2623                }
2624            }
2625        });
2626
2627        let registry = create_registry_from_json(spec_json).unwrap();
2628
2629        // Test basic properties
2630        assert_eq!(registry.paths().len(), 2);
2631        assert!(registry.paths().contains(&"/users".to_string()));
2632        assert!(registry.paths().contains(&"/users/{id}".to_string()));
2633
2634        assert_eq!(registry.methods().len(), 2);
2635        assert!(registry.methods().contains(&"GET".to_string()));
2636        assert!(registry.methods().contains(&"POST".to_string()));
2637
2638        // Test route lookup
2639        let get_users_route = registry.get_route("/users", "GET").unwrap();
2640        assert_eq!(get_users_route.method, "GET");
2641        assert_eq!(get_users_route.path, "/users");
2642
2643        let post_users_route = registry.get_route("/users", "POST").unwrap();
2644        assert_eq!(post_users_route.method, "POST");
2645        assert!(post_users_route.operation.request_body.is_some());
2646
2647        // Test path parameter conversion
2648        let user_by_id_route = registry.get_route("/users/{id}", "GET").unwrap();
2649        assert_eq!(user_by_id_route.axum_path(), "/users/{id}");
2650    }
2651
2652    #[tokio::test]
2653    async fn test_validate_request_with_params_and_formats() {
2654        let spec_json = json!({
2655            "openapi": "3.0.0",
2656            "info": { "title": "Test API", "version": "1.0.0" },
2657            "paths": {
2658                "/users/{id}": {
2659                    "post": {
2660                        "parameters": [
2661                            { "name": "id", "in": "path", "required": true, "schema": {"type": "string"} },
2662                            { "name": "q",  "in": "query", "required": false, "schema": {"type": "integer"} }
2663                        ],
2664                        "requestBody": {
2665                            "content": {
2666                                "application/json": {
2667                                    "schema": {
2668                                        "type": "object",
2669                                        "required": ["email", "website"],
2670                                        "properties": {
2671                                            "email":   {"type": "string", "format": "email"},
2672                                            "website": {"type": "string", "format": "uri"}
2673                                        }
2674                                    }
2675                                }
2676                            }
2677                        },
2678                        "responses": {"200": {"description": "ok"}}
2679                    }
2680                }
2681            }
2682        });
2683
2684        let registry = create_registry_from_json(spec_json).unwrap();
2685        let mut path_params = Map::new();
2686        path_params.insert("id".to_string(), json!("abc"));
2687        let mut query_params = Map::new();
2688        query_params.insert("q".to_string(), json!(123));
2689
2690        // valid body
2691        let body = json!({"email":"a@b.co","website":"https://example.com"});
2692        assert!(registry
2693            .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
2694            .is_ok());
2695
2696        // invalid email
2697        let bad_email = json!({"email":"not-an-email","website":"https://example.com"});
2698        assert!(registry
2699            .validate_request_with(
2700                "/users/{id}",
2701                "POST",
2702                &path_params,
2703                &query_params,
2704                Some(&bad_email)
2705            )
2706            .is_err());
2707
2708        // missing required path param
2709        let empty_path_params = Map::new();
2710        assert!(registry
2711            .validate_request_with(
2712                "/users/{id}",
2713                "POST",
2714                &empty_path_params,
2715                &query_params,
2716                Some(&body)
2717            )
2718            .is_err());
2719    }
2720
2721    #[tokio::test]
2722    async fn test_ref_resolution_for_params_and_body() {
2723        let spec_json = json!({
2724            "openapi": "3.0.0",
2725            "info": { "title": "Ref API", "version": "1.0.0" },
2726            "components": {
2727                "schemas": {
2728                    "EmailWebsite": {
2729                        "type": "object",
2730                        "required": ["email", "website"],
2731                        "properties": {
2732                            "email":   {"type": "string", "format": "email"},
2733                            "website": {"type": "string", "format": "uri"}
2734                        }
2735                    }
2736                },
2737                "parameters": {
2738                    "PathId": {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}},
2739                    "QueryQ": {"name": "q",  "in": "query", "required": false, "schema": {"type": "integer"}}
2740                },
2741                "requestBodies": {
2742                    "CreateUser": {
2743                        "content": {
2744                            "application/json": {
2745                                "schema": {"$ref": "#/components/schemas/EmailWebsite"}
2746                            }
2747                        }
2748                    }
2749                }
2750            },
2751            "paths": {
2752                "/users/{id}": {
2753                    "post": {
2754                        "parameters": [
2755                            {"$ref": "#/components/parameters/PathId"},
2756                            {"$ref": "#/components/parameters/QueryQ"}
2757                        ],
2758                        "requestBody": {"$ref": "#/components/requestBodies/CreateUser"},
2759                        "responses": {"200": {"description": "ok"}}
2760                    }
2761                }
2762            }
2763        });
2764
2765        let registry = create_registry_from_json(spec_json).unwrap();
2766        let mut path_params = Map::new();
2767        path_params.insert("id".to_string(), json!("abc"));
2768        let mut query_params = Map::new();
2769        query_params.insert("q".to_string(), json!(7));
2770
2771        let body = json!({"email":"user@example.com","website":"https://example.com"});
2772        assert!(registry
2773            .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
2774            .is_ok());
2775
2776        let bad = json!({"email":"nope","website":"https://example.com"});
2777        assert!(registry
2778            .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&bad))
2779            .is_err());
2780    }
2781
2782    #[tokio::test]
2783    async fn test_header_cookie_and_query_coercion() {
2784        let spec_json = json!({
2785            "openapi": "3.0.0",
2786            "info": { "title": "Params API", "version": "1.0.0" },
2787            "paths": {
2788                "/items": {
2789                    "get": {
2790                        "parameters": [
2791                            {"name": "X-Flag", "in": "header", "required": true, "schema": {"type": "boolean"}},
2792                            {"name": "session", "in": "cookie", "required": true, "schema": {"type": "string"}},
2793                            {"name": "ids", "in": "query", "required": false, "schema": {"type": "array", "items": {"type": "integer"}}}
2794                        ],
2795                        "responses": {"200": {"description": "ok"}}
2796                    }
2797                }
2798            }
2799        });
2800
2801        let registry = create_registry_from_json(spec_json).unwrap();
2802
2803        let path_params = Map::new();
2804        let mut query_params = Map::new();
2805        // comma-separated string for array should coerce
2806        query_params.insert("ids".to_string(), json!("1,2,3"));
2807        let mut header_params = Map::new();
2808        header_params.insert("X-Flag".to_string(), json!("true"));
2809        let mut cookie_params = Map::new();
2810        cookie_params.insert("session".to_string(), json!("abc123"));
2811
2812        assert!(registry
2813            .validate_request_with_all(
2814                "/items",
2815                "GET",
2816                &path_params,
2817                &query_params,
2818                &header_params,
2819                &cookie_params,
2820                None
2821            )
2822            .is_ok());
2823
2824        // Missing required cookie
2825        let empty_cookie = Map::new();
2826        assert!(registry
2827            .validate_request_with_all(
2828                "/items",
2829                "GET",
2830                &path_params,
2831                &query_params,
2832                &header_params,
2833                &empty_cookie,
2834                None
2835            )
2836            .is_err());
2837
2838        // Bad boolean header value (cannot coerce)
2839        let mut bad_header = Map::new();
2840        bad_header.insert("X-Flag".to_string(), json!("notabool"));
2841        assert!(registry
2842            .validate_request_with_all(
2843                "/items",
2844                "GET",
2845                &path_params,
2846                &query_params,
2847                &bad_header,
2848                &cookie_params,
2849                None
2850            )
2851            .is_err());
2852    }
2853
2854    #[tokio::test]
2855    async fn test_query_styles_space_pipe_deepobject() {
2856        let spec_json = json!({
2857            "openapi": "3.0.0",
2858            "info": { "title": "Query Styles API", "version": "1.0.0" },
2859            "paths": {"/search": {"get": {
2860                "parameters": [
2861                    {"name":"tags","in":"query","style":"spaceDelimited","schema":{"type":"array","items":{"type":"string"}}},
2862                    {"name":"ids","in":"query","style":"pipeDelimited","schema":{"type":"array","items":{"type":"integer"}}},
2863                    {"name":"filter","in":"query","style":"deepObject","schema":{"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}}
2864                ],
2865                "responses": {"200": {"description":"ok"}}
2866            }} }
2867        });
2868
2869        let registry = create_registry_from_json(spec_json).unwrap();
2870
2871        let path_params = Map::new();
2872        let mut query = Map::new();
2873        query.insert("tags".into(), json!("alpha beta gamma"));
2874        query.insert("ids".into(), json!("1|2|3"));
2875        query.insert("filter[color]".into(), json!("red"));
2876
2877        assert!(registry
2878            .validate_request_with("/search", "GET", &path_params, &query, None)
2879            .is_ok());
2880    }
2881
2882    #[tokio::test]
2883    async fn test_oneof_anyof_allof_validation() {
2884        let spec_json = json!({
2885            "openapi": "3.0.0",
2886            "info": { "title": "Composite API", "version": "1.0.0" },
2887            "paths": {
2888                "/composite": {
2889                    "post": {
2890                        "requestBody": {
2891                            "content": {
2892                                "application/json": {
2893                                    "schema": {
2894                                        "allOf": [
2895                                            {"type": "object", "required": ["base"], "properties": {"base": {"type": "string"}}}
2896                                        ],
2897                                        "oneOf": [
2898                                            {"type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"], "not": {"required": ["b"]}},
2899                                            {"type": "object", "properties": {"b": {"type": "integer"}}, "required": ["b"], "not": {"required": ["a"]}}
2900                                        ],
2901                                        "anyOf": [
2902                                            {"type": "object", "properties": {"flag": {"type": "boolean"}}, "required": ["flag"]},
2903                                            {"type": "object", "properties": {"extra": {"type": "string"}}, "required": ["extra"]}
2904                                        ]
2905                                    }
2906                                }
2907                            }
2908                        },
2909                        "responses": {"200": {"description": "ok"}}
2910                    }
2911                }
2912            }
2913        });
2914
2915        let registry = create_registry_from_json(spec_json).unwrap();
2916        // valid: satisfies base via allOf, exactly one of a/b, and at least one of flag/extra
2917        let ok = json!({"base": "x", "a": 1, "flag": true});
2918        assert!(registry
2919            .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&ok))
2920            .is_ok());
2921
2922        // invalid oneOf: both a and b present
2923        let bad_oneof = json!({"base": "x", "a": 1, "b": 2, "flag": false});
2924        assert!(registry
2925            .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_oneof))
2926            .is_err());
2927
2928        // invalid anyOf: none of flag/extra present
2929        let bad_anyof = json!({"base": "x", "a": 1});
2930        assert!(registry
2931            .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_anyof))
2932            .is_err());
2933
2934        // invalid allOf: missing base
2935        let bad_allof = json!({"a": 1, "flag": true});
2936        assert!(registry
2937            .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_allof))
2938            .is_err());
2939    }
2940
2941    #[tokio::test]
2942    async fn test_overrides_warn_mode_allows_invalid() {
2943        // Spec with a POST route expecting an integer query param
2944        let spec_json = json!({
2945            "openapi": "3.0.0",
2946            "info": { "title": "Overrides API", "version": "1.0.0" },
2947            "paths": {"/things": {"post": {
2948                "parameters": [{"name":"q","in":"query","required":true,"schema":{"type":"integer"}}],
2949                "responses": {"200": {"description":"ok"}}
2950            }}}
2951        });
2952
2953        let spec = OpenApiSpec::from_json(spec_json).unwrap();
2954        let mut overrides = HashMap::new();
2955        overrides.insert("POST /things".to_string(), ValidationMode::Warn);
2956        let registry = OpenApiRouteRegistry::new_with_options(
2957            spec,
2958            ValidationOptions {
2959                request_mode: ValidationMode::Enforce,
2960                aggregate_errors: true,
2961                validate_responses: false,
2962                overrides,
2963                admin_skip_prefixes: vec![],
2964                response_template_expand: false,
2965                validation_status: None,
2966            },
2967        );
2968
2969        // Invalid q (missing) should warn, not error
2970        let ok = registry.validate_request_with("/things", "POST", &Map::new(), &Map::new(), None);
2971        assert!(ok.is_ok());
2972    }
2973
2974    #[tokio::test]
2975    async fn test_admin_skip_prefix_short_circuit() {
2976        let spec_json = json!({
2977            "openapi": "3.0.0",
2978            "info": { "title": "Skip API", "version": "1.0.0" },
2979            "paths": {}
2980        });
2981        let spec = OpenApiSpec::from_json(spec_json).unwrap();
2982        let registry = OpenApiRouteRegistry::new_with_options(
2983            spec,
2984            ValidationOptions {
2985                request_mode: ValidationMode::Enforce,
2986                aggregate_errors: true,
2987                validate_responses: false,
2988                overrides: HashMap::new(),
2989                admin_skip_prefixes: vec!["/admin".into()],
2990                response_template_expand: false,
2991                validation_status: None,
2992            },
2993        );
2994
2995        // No route exists for this, but skip prefix means it is accepted
2996        let res = registry.validate_request_with_all(
2997            "/admin/__mockforge/health",
2998            "GET",
2999            &Map::new(),
3000            &Map::new(),
3001            &Map::new(),
3002            &Map::new(),
3003            None,
3004        );
3005        assert!(res.is_ok());
3006    }
3007
3008    #[test]
3009    fn test_path_conversion() {
3010        assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users"), "/users");
3011        assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users/{id}"), "/users/{id}");
3012        assert_eq!(
3013            OpenApiRouteRegistry::convert_path_to_axum("/users/{id}/posts/{postId}"),
3014            "/users/{id}/posts/{postId}"
3015        );
3016    }
3017
3018    #[test]
3019    fn test_validation_options_default() {
3020        let options = ValidationOptions::default();
3021        assert!(matches!(options.request_mode, ValidationMode::Enforce));
3022        assert!(options.aggregate_errors);
3023        assert!(!options.validate_responses);
3024        assert!(options.overrides.is_empty());
3025        assert!(options.admin_skip_prefixes.is_empty());
3026        assert!(!options.response_template_expand);
3027        assert!(options.validation_status.is_none());
3028    }
3029
3030    #[test]
3031    fn test_validation_mode_variants() {
3032        // Test that all variants can be created and compared
3033        let disabled = ValidationMode::Disabled;
3034        let warn = ValidationMode::Warn;
3035        let enforce = ValidationMode::Enforce;
3036        let default = ValidationMode::default();
3037
3038        // Test that default is Warn
3039        assert!(matches!(default, ValidationMode::Warn));
3040
3041        // Test that variants are distinct
3042        assert!(!matches!(disabled, ValidationMode::Warn));
3043        assert!(!matches!(warn, ValidationMode::Enforce));
3044        assert!(!matches!(enforce, ValidationMode::Disabled));
3045    }
3046
3047    #[test]
3048    fn test_registry_spec_accessor() {
3049        let spec_json = json!({
3050            "openapi": "3.0.0",
3051            "info": {
3052                "title": "Test API",
3053                "version": "1.0.0"
3054            },
3055            "paths": {}
3056        });
3057        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3058        let registry = OpenApiRouteRegistry::new(spec.clone());
3059
3060        // Test spec() accessor
3061        let accessed_spec = registry.spec();
3062        assert_eq!(accessed_spec.title(), "Test API");
3063    }
3064
3065    #[test]
3066    fn test_clone_for_validation() {
3067        let spec_json = json!({
3068            "openapi": "3.0.0",
3069            "info": {
3070                "title": "Test API",
3071                "version": "1.0.0"
3072            },
3073            "paths": {
3074                "/users": {
3075                    "get": {
3076                        "responses": {
3077                            "200": {
3078                                "description": "Success"
3079                            }
3080                        }
3081                    }
3082                }
3083            }
3084        });
3085        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3086        let registry = OpenApiRouteRegistry::new(spec);
3087
3088        // Test clone_for_validation
3089        let cloned = registry.clone_for_validation();
3090        assert_eq!(cloned.routes().len(), registry.routes().len());
3091        assert_eq!(cloned.spec().title(), registry.spec().title());
3092    }
3093
3094    #[test]
3095    fn test_with_custom_fixture_loader() {
3096        let temp_dir = TempDir::new().unwrap();
3097        let spec_json = json!({
3098            "openapi": "3.0.0",
3099            "info": {
3100                "title": "Test API",
3101                "version": "1.0.0"
3102            },
3103            "paths": {}
3104        });
3105        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3106        let registry = OpenApiRouteRegistry::new(spec);
3107        let original_routes_len = registry.routes().len();
3108
3109        // Test with_custom_fixture_loader
3110        let custom_loader =
3111            Arc::new(crate::CustomFixtureLoader::new(temp_dir.path().to_path_buf(), true));
3112        let registry_with_loader = registry.with_custom_fixture_loader(custom_loader);
3113
3114        // Verify the loader was set (we can't directly access it, but we can test it doesn't panic)
3115        assert_eq!(registry_with_loader.routes().len(), original_routes_len);
3116    }
3117
3118    #[test]
3119    fn test_get_route() {
3120        let spec_json = json!({
3121            "openapi": "3.0.0",
3122            "info": {
3123                "title": "Test API",
3124                "version": "1.0.0"
3125            },
3126            "paths": {
3127                "/users": {
3128                    "get": {
3129                        "operationId": "getUsers",
3130                        "responses": {
3131                            "200": {
3132                                "description": "Success"
3133                            }
3134                        }
3135                    },
3136                    "post": {
3137                        "operationId": "createUser",
3138                        "responses": {
3139                            "201": {
3140                                "description": "Created"
3141                            }
3142                        }
3143                    }
3144                }
3145            }
3146        });
3147        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3148        let registry = OpenApiRouteRegistry::new(spec);
3149
3150        // Test get_route for existing route
3151        let route = registry.get_route("/users", "GET");
3152        assert!(route.is_some());
3153        assert_eq!(route.unwrap().method, "GET");
3154        assert_eq!(route.unwrap().path, "/users");
3155
3156        // Test get_route for non-existent route
3157        let route = registry.get_route("/nonexistent", "GET");
3158        assert!(route.is_none());
3159
3160        // Test get_route for different method
3161        let route = registry.get_route("/users", "POST");
3162        assert!(route.is_some());
3163        assert_eq!(route.unwrap().method, "POST");
3164    }
3165
3166    #[test]
3167    fn test_get_routes_for_path() {
3168        let spec_json = json!({
3169            "openapi": "3.0.0",
3170            "info": {
3171                "title": "Test API",
3172                "version": "1.0.0"
3173            },
3174            "paths": {
3175                "/users": {
3176                    "get": {
3177                        "responses": {
3178                            "200": {
3179                                "description": "Success"
3180                            }
3181                        }
3182                    },
3183                    "post": {
3184                        "responses": {
3185                            "201": {
3186                                "description": "Created"
3187                            }
3188                        }
3189                    },
3190                    "put": {
3191                        "responses": {
3192                            "200": {
3193                                "description": "Success"
3194                            }
3195                        }
3196                    }
3197                },
3198                "/posts": {
3199                    "get": {
3200                        "responses": {
3201                            "200": {
3202                                "description": "Success"
3203                            }
3204                        }
3205                    }
3206                }
3207            }
3208        });
3209        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3210        let registry = OpenApiRouteRegistry::new(spec);
3211
3212        // Test get_routes_for_path with multiple methods
3213        let routes = registry.get_routes_for_path("/users");
3214        assert_eq!(routes.len(), 3);
3215        let methods: Vec<&str> = routes.iter().map(|r| r.method.as_str()).collect();
3216        assert!(methods.contains(&"GET"));
3217        assert!(methods.contains(&"POST"));
3218        assert!(methods.contains(&"PUT"));
3219
3220        // Test get_routes_for_path with single method
3221        let routes = registry.get_routes_for_path("/posts");
3222        assert_eq!(routes.len(), 1);
3223        assert_eq!(routes[0].method, "GET");
3224
3225        // Test get_routes_for_path with non-existent path
3226        let routes = registry.get_routes_for_path("/nonexistent");
3227        assert!(routes.is_empty());
3228    }
3229
3230    #[test]
3231    fn test_new_vs_new_with_options() {
3232        let spec_json = json!({
3233            "openapi": "3.0.0",
3234            "info": {
3235                "title": "Test API",
3236                "version": "1.0.0"
3237            },
3238            "paths": {}
3239        });
3240        let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3241        let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3242
3243        // Test new() - uses environment-based options
3244        let registry1 = OpenApiRouteRegistry::new(spec1);
3245        assert_eq!(registry1.spec().title(), "Test API");
3246
3247        // Test new_with_options() - uses explicit options
3248        let options = ValidationOptions {
3249            request_mode: ValidationMode::Disabled,
3250            aggregate_errors: false,
3251            validate_responses: true,
3252            overrides: HashMap::new(),
3253            admin_skip_prefixes: vec!["/admin".to_string()],
3254            response_template_expand: true,
3255            validation_status: Some(422),
3256        };
3257        let registry2 = OpenApiRouteRegistry::new_with_options(spec2, options);
3258        assert_eq!(registry2.spec().title(), "Test API");
3259    }
3260
3261    #[test]
3262    fn test_new_with_env_vs_new() {
3263        let spec_json = json!({
3264            "openapi": "3.0.0",
3265            "info": {
3266                "title": "Test API",
3267                "version": "1.0.0"
3268            },
3269            "paths": {}
3270        });
3271        let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3272        let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3273
3274        // Test new() calls new_with_env()
3275        let registry1 = OpenApiRouteRegistry::new(spec1);
3276
3277        // Test new_with_env() directly
3278        let registry2 = OpenApiRouteRegistry::new_with_env(spec2);
3279
3280        // Both should create valid registries
3281        assert_eq!(registry1.spec().title(), "Test API");
3282        assert_eq!(registry2.spec().title(), "Test API");
3283    }
3284
3285    #[test]
3286    fn test_validation_options_custom() {
3287        let options = ValidationOptions {
3288            request_mode: ValidationMode::Warn,
3289            aggregate_errors: false,
3290            validate_responses: true,
3291            overrides: {
3292                let mut map = HashMap::new();
3293                map.insert("getUsers".to_string(), ValidationMode::Disabled);
3294                map
3295            },
3296            admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
3297            response_template_expand: true,
3298            validation_status: Some(422),
3299        };
3300
3301        assert!(matches!(options.request_mode, ValidationMode::Warn));
3302        assert!(!options.aggregate_errors);
3303        assert!(options.validate_responses);
3304        assert_eq!(options.overrides.len(), 1);
3305        assert_eq!(options.admin_skip_prefixes.len(), 2);
3306        assert!(options.response_template_expand);
3307        assert_eq!(options.validation_status, Some(422));
3308    }
3309
3310    #[test]
3311    fn test_validation_mode_default_standalone() {
3312        let mode = ValidationMode::default();
3313        assert!(matches!(mode, ValidationMode::Warn));
3314    }
3315
3316    #[test]
3317    fn test_validation_mode_clone() {
3318        let mode1 = ValidationMode::Enforce;
3319        let mode2 = mode1.clone();
3320        assert!(matches!(mode1, ValidationMode::Enforce));
3321        assert!(matches!(mode2, ValidationMode::Enforce));
3322    }
3323
3324    #[test]
3325    fn test_validation_mode_debug() {
3326        let mode = ValidationMode::Disabled;
3327        let debug_str = format!("{:?}", mode);
3328        assert!(debug_str.contains("Disabled") || debug_str.contains("ValidationMode"));
3329    }
3330
3331    #[test]
3332    fn test_validation_options_clone() {
3333        let options1 = ValidationOptions {
3334            request_mode: ValidationMode::Warn,
3335            aggregate_errors: true,
3336            validate_responses: false,
3337            overrides: HashMap::new(),
3338            admin_skip_prefixes: vec![],
3339            response_template_expand: false,
3340            validation_status: None,
3341        };
3342        let options2 = options1.clone();
3343        assert!(matches!(options2.request_mode, ValidationMode::Warn));
3344        assert_eq!(options1.aggregate_errors, options2.aggregate_errors);
3345    }
3346
3347    #[test]
3348    fn test_validation_options_debug() {
3349        let options = ValidationOptions::default();
3350        let debug_str = format!("{:?}", options);
3351        assert!(debug_str.contains("ValidationOptions"));
3352    }
3353
3354    #[test]
3355    fn test_validation_options_with_all_fields() {
3356        let mut overrides = HashMap::new();
3357        overrides.insert("op1".to_string(), ValidationMode::Disabled);
3358        overrides.insert("op2".to_string(), ValidationMode::Warn);
3359
3360        let options = ValidationOptions {
3361            request_mode: ValidationMode::Enforce,
3362            aggregate_errors: false,
3363            validate_responses: true,
3364            overrides: overrides.clone(),
3365            admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
3366            response_template_expand: true,
3367            validation_status: Some(422),
3368        };
3369
3370        assert!(matches!(options.request_mode, ValidationMode::Enforce));
3371        assert!(!options.aggregate_errors);
3372        assert!(options.validate_responses);
3373        assert_eq!(options.overrides.len(), 2);
3374        assert_eq!(options.admin_skip_prefixes.len(), 2);
3375        assert!(options.response_template_expand);
3376        assert_eq!(options.validation_status, Some(422));
3377    }
3378
3379    #[test]
3380    fn test_openapi_route_registry_clone() {
3381        let spec_json = json!({
3382            "openapi": "3.0.0",
3383            "info": { "title": "Test API", "version": "1.0.0" },
3384            "paths": {}
3385        });
3386        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3387        let registry1 = OpenApiRouteRegistry::new(spec);
3388        let registry2 = registry1.clone();
3389        assert_eq!(registry1.spec().title(), registry2.spec().title());
3390    }
3391
3392    #[test]
3393    fn test_validation_mode_serialization() {
3394        let mode = ValidationMode::Enforce;
3395        let json = serde_json::to_string(&mode).unwrap();
3396        assert!(json.contains("Enforce") || json.contains("enforce"));
3397    }
3398
3399    #[test]
3400    fn test_validation_mode_deserialization() {
3401        let json = r#""Disabled""#;
3402        let mode: ValidationMode = serde_json::from_str(json).unwrap();
3403        assert!(matches!(mode, ValidationMode::Disabled));
3404    }
3405
3406    #[test]
3407    fn test_validation_options_default_values() {
3408        let options = ValidationOptions::default();
3409        assert!(matches!(options.request_mode, ValidationMode::Enforce));
3410        assert!(options.aggregate_errors);
3411        assert!(!options.validate_responses);
3412        assert!(options.overrides.is_empty());
3413        assert!(options.admin_skip_prefixes.is_empty());
3414        assert!(!options.response_template_expand);
3415        assert_eq!(options.validation_status, None);
3416    }
3417
3418    #[test]
3419    fn test_validation_mode_all_variants() {
3420        let disabled = ValidationMode::Disabled;
3421        let warn = ValidationMode::Warn;
3422        let enforce = ValidationMode::Enforce;
3423
3424        assert!(matches!(disabled, ValidationMode::Disabled));
3425        assert!(matches!(warn, ValidationMode::Warn));
3426        assert!(matches!(enforce, ValidationMode::Enforce));
3427    }
3428
3429    #[test]
3430    fn test_validation_options_with_overrides() {
3431        let mut overrides = HashMap::new();
3432        overrides.insert("operation1".to_string(), ValidationMode::Disabled);
3433        overrides.insert("operation2".to_string(), ValidationMode::Warn);
3434
3435        let options = ValidationOptions {
3436            request_mode: ValidationMode::Enforce,
3437            aggregate_errors: true,
3438            validate_responses: false,
3439            overrides,
3440            admin_skip_prefixes: vec![],
3441            response_template_expand: false,
3442            validation_status: None,
3443        };
3444
3445        assert_eq!(options.overrides.len(), 2);
3446        assert!(matches!(options.overrides.get("operation1"), Some(ValidationMode::Disabled)));
3447        assert!(matches!(options.overrides.get("operation2"), Some(ValidationMode::Warn)));
3448    }
3449
3450    #[test]
3451    fn test_validation_options_with_admin_skip_prefixes() {
3452        let options = ValidationOptions {
3453            request_mode: ValidationMode::Enforce,
3454            aggregate_errors: true,
3455            validate_responses: false,
3456            overrides: HashMap::new(),
3457            admin_skip_prefixes: vec![
3458                "/admin".to_string(),
3459                "/internal".to_string(),
3460                "/debug".to_string(),
3461            ],
3462            response_template_expand: false,
3463            validation_status: None,
3464        };
3465
3466        assert_eq!(options.admin_skip_prefixes.len(), 3);
3467        assert!(options.admin_skip_prefixes.contains(&"/admin".to_string()));
3468        assert!(options.admin_skip_prefixes.contains(&"/internal".to_string()));
3469        assert!(options.admin_skip_prefixes.contains(&"/debug".to_string()));
3470    }
3471
3472    #[test]
3473    fn test_validation_options_with_validation_status() {
3474        let options1 = ValidationOptions {
3475            request_mode: ValidationMode::Enforce,
3476            aggregate_errors: true,
3477            validate_responses: false,
3478            overrides: HashMap::new(),
3479            admin_skip_prefixes: vec![],
3480            response_template_expand: false,
3481            validation_status: Some(400),
3482        };
3483
3484        let options2 = ValidationOptions {
3485            request_mode: ValidationMode::Enforce,
3486            aggregate_errors: true,
3487            validate_responses: false,
3488            overrides: HashMap::new(),
3489            admin_skip_prefixes: vec![],
3490            response_template_expand: false,
3491            validation_status: Some(422),
3492        };
3493
3494        assert_eq!(options1.validation_status, Some(400));
3495        assert_eq!(options2.validation_status, Some(422));
3496    }
3497
3498    #[test]
3499    fn test_validate_request_with_disabled_mode() {
3500        // Test validation with disabled mode (lines 1001-1007)
3501        let spec_json = json!({
3502            "openapi": "3.0.0",
3503            "info": {"title": "Test API", "version": "1.0.0"},
3504            "paths": {
3505                "/users": {
3506                    "get": {
3507                        "responses": {"200": {"description": "OK"}}
3508                    }
3509                }
3510            }
3511        });
3512        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3513        let options = ValidationOptions {
3514            request_mode: ValidationMode::Disabled,
3515            ..Default::default()
3516        };
3517        let registry = OpenApiRouteRegistry::new_with_options(spec, options);
3518
3519        // Should pass validation when disabled (lines 1002-1003, 1005-1007)
3520        let result = registry.validate_request_with_all(
3521            "/users",
3522            "GET",
3523            &Map::new(),
3524            &Map::new(),
3525            &Map::new(),
3526            &Map::new(),
3527            None,
3528        );
3529        assert!(result.is_ok());
3530    }
3531
3532    #[test]
3533    fn test_validate_request_with_warn_mode() {
3534        // Test validation with warn mode (lines 1162-1166)
3535        let spec_json = json!({
3536            "openapi": "3.0.0",
3537            "info": {"title": "Test API", "version": "1.0.0"},
3538            "paths": {
3539                "/users": {
3540                    "post": {
3541                        "requestBody": {
3542                            "required": true,
3543                            "content": {
3544                                "application/json": {
3545                                    "schema": {
3546                                        "type": "object",
3547                                        "required": ["name"],
3548                                        "properties": {
3549                                            "name": {"type": "string"}
3550                                        }
3551                                    }
3552                                }
3553                            }
3554                        },
3555                        "responses": {"200": {"description": "OK"}}
3556                    }
3557                }
3558            }
3559        });
3560        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3561        let options = ValidationOptions {
3562            request_mode: ValidationMode::Warn,
3563            ..Default::default()
3564        };
3565        let registry = OpenApiRouteRegistry::new_with_options(spec, options);
3566
3567        // Should pass with warnings when body is missing (lines 1162-1166)
3568        let result = registry.validate_request_with_all(
3569            "/users",
3570            "POST",
3571            &Map::new(),
3572            &Map::new(),
3573            &Map::new(),
3574            &Map::new(),
3575            None, // Missing required body
3576        );
3577        assert!(result.is_ok()); // Warn mode doesn't fail
3578    }
3579
3580    #[test]
3581    fn test_validate_request_body_validation_error() {
3582        // Test request body validation error path (lines 1072-1091)
3583        let spec_json = json!({
3584            "openapi": "3.0.0",
3585            "info": {"title": "Test API", "version": "1.0.0"},
3586            "paths": {
3587                "/users": {
3588                    "post": {
3589                        "requestBody": {
3590                            "required": true,
3591                            "content": {
3592                                "application/json": {
3593                                    "schema": {
3594                                        "type": "object",
3595                                        "required": ["name"],
3596                                        "properties": {
3597                                            "name": {"type": "string"}
3598                                        }
3599                                    }
3600                                }
3601                            }
3602                        },
3603                        "responses": {"200": {"description": "OK"}}
3604                    }
3605                }
3606            }
3607        });
3608        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3609        let registry = OpenApiRouteRegistry::new(spec);
3610
3611        // Should fail validation when body is missing (lines 1088-1091)
3612        let result = registry.validate_request_with_all(
3613            "/users",
3614            "POST",
3615            &Map::new(),
3616            &Map::new(),
3617            &Map::new(),
3618            &Map::new(),
3619            None, // Missing required body
3620        );
3621        assert!(result.is_err());
3622    }
3623
3624    #[test]
3625    fn test_validate_request_body_schema_validation_error() {
3626        // Test request body schema validation error (lines 1038-1049)
3627        let spec_json = json!({
3628            "openapi": "3.0.0",
3629            "info": {"title": "Test API", "version": "1.0.0"},
3630            "paths": {
3631                "/users": {
3632                    "post": {
3633                        "requestBody": {
3634                            "required": true,
3635                            "content": {
3636                                "application/json": {
3637                                    "schema": {
3638                                        "type": "object",
3639                                        "required": ["name"],
3640                                        "properties": {
3641                                            "name": {"type": "string"}
3642                                        }
3643                                    }
3644                                }
3645                            }
3646                        },
3647                        "responses": {"200": {"description": "OK"}}
3648                    }
3649                }
3650            }
3651        });
3652        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3653        let registry = OpenApiRouteRegistry::new(spec);
3654
3655        // Should fail validation when body doesn't match schema (lines 1038-1049)
3656        let invalid_body = json!({}); // Missing required "name" field
3657        let result = registry.validate_request_with_all(
3658            "/users",
3659            "POST",
3660            &Map::new(),
3661            &Map::new(),
3662            &Map::new(),
3663            &Map::new(),
3664            Some(&invalid_body),
3665        );
3666        assert!(result.is_err());
3667    }
3668
3669    #[test]
3670    fn test_validate_request_body_referenced_schema_error() {
3671        // Test request body with referenced schema that can't be resolved (lines 1070-1076)
3672        let spec_json = json!({
3673            "openapi": "3.0.0",
3674            "info": {"title": "Test API", "version": "1.0.0"},
3675            "paths": {
3676                "/users": {
3677                    "post": {
3678                        "requestBody": {
3679                            "required": true,
3680                            "content": {
3681                                "application/json": {
3682                                    "schema": {
3683                                        "$ref": "#/components/schemas/NonExistentSchema"
3684                                    }
3685                                }
3686                            }
3687                        },
3688                        "responses": {"200": {"description": "OK"}}
3689                    }
3690                }
3691            },
3692            "components": {}
3693        });
3694        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3695        let registry = OpenApiRouteRegistry::new(spec);
3696
3697        // Should fail validation when schema reference can't be resolved (lines 1070-1076)
3698        let body = json!({"name": "test"});
3699        let result = registry.validate_request_with_all(
3700            "/users",
3701            "POST",
3702            &Map::new(),
3703            &Map::new(),
3704            &Map::new(),
3705            &Map::new(),
3706            Some(&body),
3707        );
3708        assert!(result.is_err());
3709    }
3710
3711    #[test]
3712    fn test_validate_request_body_referenced_request_body_error() {
3713        // Test request body with referenced request body that can't be resolved (lines 1081-1087)
3714        let spec_json = json!({
3715            "openapi": "3.0.0",
3716            "info": {"title": "Test API", "version": "1.0.0"},
3717            "paths": {
3718                "/users": {
3719                    "post": {
3720                        "requestBody": {
3721                            "$ref": "#/components/requestBodies/NonExistentRequestBody"
3722                        },
3723                        "responses": {"200": {"description": "OK"}}
3724                    }
3725                }
3726            },
3727            "components": {}
3728        });
3729        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3730        let registry = OpenApiRouteRegistry::new(spec);
3731
3732        // Should fail validation when request body reference can't be resolved (lines 1081-1087)
3733        let body = json!({"name": "test"});
3734        let result = registry.validate_request_with_all(
3735            "/users",
3736            "POST",
3737            &Map::new(),
3738            &Map::new(),
3739            &Map::new(),
3740            &Map::new(),
3741            Some(&body),
3742        );
3743        assert!(result.is_err());
3744    }
3745
3746    #[test]
3747    fn test_validate_request_body_provided_when_not_expected() {
3748        // Test body provided when not expected (lines 1092-1094)
3749        let spec_json = json!({
3750            "openapi": "3.0.0",
3751            "info": {"title": "Test API", "version": "1.0.0"},
3752            "paths": {
3753                "/users": {
3754                    "get": {
3755                        "responses": {"200": {"description": "OK"}}
3756                    }
3757                }
3758            }
3759        });
3760        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3761        let registry = OpenApiRouteRegistry::new(spec);
3762
3763        // Should accept body even when not expected (lines 1092-1094)
3764        let body = json!({"extra": "data"});
3765        let result = registry.validate_request_with_all(
3766            "/users",
3767            "GET",
3768            &Map::new(),
3769            &Map::new(),
3770            &Map::new(),
3771            &Map::new(),
3772            Some(&body),
3773        );
3774        // Should not error - just logs debug message
3775        assert!(result.is_ok());
3776    }
3777
3778    #[test]
3779    fn test_get_operation() {
3780        // Test get_operation method (lines 1196-1205)
3781        let spec_json = json!({
3782            "openapi": "3.0.0",
3783            "info": {"title": "Test API", "version": "1.0.0"},
3784            "paths": {
3785                "/users": {
3786                    "get": {
3787                        "operationId": "getUsers",
3788                        "summary": "Get users",
3789                        "responses": {"200": {"description": "OK"}}
3790                    }
3791                }
3792            }
3793        });
3794        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3795        let registry = OpenApiRouteRegistry::new(spec);
3796
3797        // Should return operation details (lines 1196-1205)
3798        let operation = registry.get_operation("/users", "GET");
3799        assert!(operation.is_some());
3800        assert_eq!(operation.unwrap().method, "GET");
3801
3802        // Should return None for non-existent route
3803        assert!(registry.get_operation("/nonexistent", "GET").is_none());
3804    }
3805
3806    #[test]
3807    fn test_extract_path_parameters() {
3808        // Test extract_path_parameters method (lines 1208-1223)
3809        let spec_json = json!({
3810            "openapi": "3.0.0",
3811            "info": {"title": "Test API", "version": "1.0.0"},
3812            "paths": {
3813                "/users/{id}": {
3814                    "get": {
3815                        "parameters": [
3816                            {
3817                                "name": "id",
3818                                "in": "path",
3819                                "required": true,
3820                                "schema": {"type": "string"}
3821                            }
3822                        ],
3823                        "responses": {"200": {"description": "OK"}}
3824                    }
3825                }
3826            }
3827        });
3828        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3829        let registry = OpenApiRouteRegistry::new(spec);
3830
3831        // Should extract path parameters (lines 1208-1223)
3832        let params = registry.extract_path_parameters("/users/123", "GET");
3833        assert_eq!(params.get("id"), Some(&"123".to_string()));
3834
3835        // Should return empty map for non-matching path
3836        let empty_params = registry.extract_path_parameters("/users", "GET");
3837        assert!(empty_params.is_empty());
3838    }
3839
3840    #[test]
3841    fn test_extract_path_parameters_multiple_params() {
3842        // Test extract_path_parameters with multiple path parameters
3843        let spec_json = json!({
3844            "openapi": "3.0.0",
3845            "info": {"title": "Test API", "version": "1.0.0"},
3846            "paths": {
3847                "/users/{userId}/posts/{postId}": {
3848                    "get": {
3849                        "parameters": [
3850                            {
3851                                "name": "userId",
3852                                "in": "path",
3853                                "required": true,
3854                                "schema": {"type": "string"}
3855                            },
3856                            {
3857                                "name": "postId",
3858                                "in": "path",
3859                                "required": true,
3860                                "schema": {"type": "string"}
3861                            }
3862                        ],
3863                        "responses": {"200": {"description": "OK"}}
3864                    }
3865                }
3866            }
3867        });
3868        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3869        let registry = OpenApiRouteRegistry::new(spec);
3870
3871        // Should extract multiple path parameters
3872        let params = registry.extract_path_parameters("/users/123/posts/456", "GET");
3873        assert_eq!(params.get("userId"), Some(&"123".to_string()));
3874        assert_eq!(params.get("postId"), Some(&"456".to_string()));
3875    }
3876
3877    #[test]
3878    fn test_validate_request_route_not_found() {
3879        // Test validation when route not found (lines 1171-1173)
3880        let spec_json = json!({
3881            "openapi": "3.0.0",
3882            "info": {"title": "Test API", "version": "1.0.0"},
3883            "paths": {
3884                "/users": {
3885                    "get": {
3886                        "responses": {"200": {"description": "OK"}}
3887                    }
3888                }
3889            }
3890        });
3891        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3892        let registry = OpenApiRouteRegistry::new(spec);
3893
3894        // Should return error when route not found (lines 1171-1173)
3895        let result = registry.validate_request_with_all(
3896            "/nonexistent",
3897            "GET",
3898            &Map::new(),
3899            &Map::new(),
3900            &Map::new(),
3901            &Map::new(),
3902            None,
3903        );
3904        assert!(result.is_err());
3905        assert!(result.unwrap_err().to_string().contains("not found"));
3906    }
3907
3908    #[test]
3909    fn test_validate_request_with_path_parameters() {
3910        // Test path parameter validation (lines 1101-1110)
3911        let spec_json = json!({
3912            "openapi": "3.0.0",
3913            "info": {"title": "Test API", "version": "1.0.0"},
3914            "paths": {
3915                "/users/{id}": {
3916                    "get": {
3917                        "parameters": [
3918                            {
3919                                "name": "id",
3920                                "in": "path",
3921                                "required": true,
3922                                "schema": {"type": "string", "minLength": 1}
3923                            }
3924                        ],
3925                        "responses": {"200": {"description": "OK"}}
3926                    }
3927                }
3928            }
3929        });
3930        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3931        let registry = OpenApiRouteRegistry::new(spec);
3932
3933        // Should pass validation with valid path parameter
3934        let mut path_params = Map::new();
3935        path_params.insert("id".to_string(), json!("123"));
3936        let result = registry.validate_request_with_all(
3937            "/users/{id}",
3938            "GET",
3939            &path_params,
3940            &Map::new(),
3941            &Map::new(),
3942            &Map::new(),
3943            None,
3944        );
3945        assert!(result.is_ok());
3946    }
3947
3948    #[test]
3949    fn test_validate_request_with_query_parameters() {
3950        // Test query parameter validation (lines 1111-1134)
3951        let spec_json = json!({
3952            "openapi": "3.0.0",
3953            "info": {"title": "Test API", "version": "1.0.0"},
3954            "paths": {
3955                "/users": {
3956                    "get": {
3957                        "parameters": [
3958                            {
3959                                "name": "page",
3960                                "in": "query",
3961                                "required": true,
3962                                "schema": {"type": "integer", "minimum": 1}
3963                            }
3964                        ],
3965                        "responses": {"200": {"description": "OK"}}
3966                    }
3967                }
3968            }
3969        });
3970        let spec = OpenApiSpec::from_json(spec_json).unwrap();
3971        let registry = OpenApiRouteRegistry::new(spec);
3972
3973        // Should pass validation with valid query parameter
3974        let mut query_params = Map::new();
3975        query_params.insert("page".to_string(), json!(1));
3976        let result = registry.validate_request_with_all(
3977            "/users",
3978            "GET",
3979            &Map::new(),
3980            &query_params,
3981            &Map::new(),
3982            &Map::new(),
3983            None,
3984        );
3985        assert!(result.is_ok());
3986    }
3987
3988    #[test]
3989    fn test_validate_request_with_header_parameters() {
3990        // Test header parameter validation (lines 1135-1144)
3991        let spec_json = json!({
3992            "openapi": "3.0.0",
3993            "info": {"title": "Test API", "version": "1.0.0"},
3994            "paths": {
3995                "/users": {
3996                    "get": {
3997                        "parameters": [
3998                            {
3999                                "name": "X-API-Key",
4000                                "in": "header",
4001                                "required": true,
4002                                "schema": {"type": "string"}
4003                            }
4004                        ],
4005                        "responses": {"200": {"description": "OK"}}
4006                    }
4007                }
4008            }
4009        });
4010        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4011        let registry = OpenApiRouteRegistry::new(spec);
4012
4013        // Should pass validation with valid header parameter
4014        let mut header_params = Map::new();
4015        header_params.insert("X-API-Key".to_string(), json!("secret-key"));
4016        let result = registry.validate_request_with_all(
4017            "/users",
4018            "GET",
4019            &Map::new(),
4020            &Map::new(),
4021            &header_params,
4022            &Map::new(),
4023            None,
4024        );
4025        assert!(result.is_ok());
4026    }
4027
4028    #[test]
4029    fn test_validate_request_with_cookie_parameters() {
4030        // Test cookie parameter validation (lines 1145-1154)
4031        let spec_json = json!({
4032            "openapi": "3.0.0",
4033            "info": {"title": "Test API", "version": "1.0.0"},
4034            "paths": {
4035                "/users": {
4036                    "get": {
4037                        "parameters": [
4038                            {
4039                                "name": "sessionId",
4040                                "in": "cookie",
4041                                "required": true,
4042                                "schema": {"type": "string"}
4043                            }
4044                        ],
4045                        "responses": {"200": {"description": "OK"}}
4046                    }
4047                }
4048            }
4049        });
4050        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4051        let registry = OpenApiRouteRegistry::new(spec);
4052
4053        // Should pass validation with valid cookie parameter
4054        let mut cookie_params = Map::new();
4055        cookie_params.insert("sessionId".to_string(), json!("abc123"));
4056        let result = registry.validate_request_with_all(
4057            "/users",
4058            "GET",
4059            &Map::new(),
4060            &Map::new(),
4061            &Map::new(),
4062            &cookie_params,
4063            None,
4064        );
4065        assert!(result.is_ok());
4066    }
4067
4068    #[test]
4069    fn test_validate_request_no_errors_early_return() {
4070        // Test early return when no errors (lines 1158-1160)
4071        let spec_json = json!({
4072            "openapi": "3.0.0",
4073            "info": {"title": "Test API", "version": "1.0.0"},
4074            "paths": {
4075                "/users": {
4076                    "get": {
4077                        "responses": {"200": {"description": "OK"}}
4078                    }
4079                }
4080            }
4081        });
4082        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4083        let registry = OpenApiRouteRegistry::new(spec);
4084
4085        // Should return early when no errors (lines 1158-1160)
4086        let result = registry.validate_request_with_all(
4087            "/users",
4088            "GET",
4089            &Map::new(),
4090            &Map::new(),
4091            &Map::new(),
4092            &Map::new(),
4093            None,
4094        );
4095        assert!(result.is_ok());
4096    }
4097
4098    #[test]
4099    fn test_validate_request_query_parameter_different_styles() {
4100        // Test query parameter validation with different styles (lines 1118-1123)
4101        let spec_json = json!({
4102            "openapi": "3.0.0",
4103            "info": {"title": "Test API", "version": "1.0.0"},
4104            "paths": {
4105                "/users": {
4106                    "get": {
4107                        "parameters": [
4108                            {
4109                                "name": "tags",
4110                                "in": "query",
4111                                "style": "pipeDelimited",
4112                                "schema": {
4113                                    "type": "array",
4114                                    "items": {"type": "string"}
4115                                }
4116                            }
4117                        ],
4118                        "responses": {"200": {"description": "OK"}}
4119                    }
4120                }
4121            }
4122        });
4123        let spec = OpenApiSpec::from_json(spec_json).unwrap();
4124        let registry = OpenApiRouteRegistry::new(spec);
4125
4126        // Should handle pipeDelimited style (lines 1118-1123)
4127        let mut query_params = Map::new();
4128        query_params.insert("tags".to_string(), json!(["tag1", "tag2"]));
4129        let result = registry.validate_request_with_all(
4130            "/users",
4131            "GET",
4132            &Map::new(),
4133            &query_params,
4134            &Map::new(),
4135            &Map::new(),
4136            None,
4137        );
4138        // Should not error on style handling
4139        assert!(result.is_ok() || result.is_err()); // Either is fine, just testing the path
4140    }
4141}