1pub mod builder;
13pub mod generation;
14#[doc(hidden)]
15pub mod registry;
16pub mod validation;
17
18use crate::response::AiGenerator;
19use crate::response_rewriter::ResponseRewriter;
20use crate::{OpenApiOperation, OpenApiRoute, OpenApiSchema, OpenApiSpec};
21use axum::extract::{DefaultBodyLimit, Path as AxumPath, RawQuery};
22use axum::http::HeaderMap;
23use axum::response::IntoResponse;
24use axum::routing::*;
25use axum::{Json, Router};
26pub use builder::*;
27use chrono::Utc;
28pub use generation::*;
29use mockforge_foundation::ai_response::RequestContext;
30use mockforge_foundation::error::{Error, Result};
31use mockforge_foundation::latency::LatencyInjector;
32use mockforge_foundation::response_generation_trace::ResponseGenerationTrace;
33use mockforge_foundation::schema_diff::validation_diff;
34use once_cell::sync::Lazy;
35use openapiv3::ParameterSchemaOrContent;
36use serde_json::{json, Map, Value};
37use std::collections::{HashMap, HashSet, VecDeque};
38use std::sync::{Arc, Mutex};
39use tracing;
40pub use validation::*;
41
42#[derive(Clone)]
44pub struct OpenApiRouteRegistry {
45 spec: Arc<OpenApiSpec>,
47 routes: Vec<OpenApiRoute>,
49 options: ValidationOptions,
51 custom_fixture_loader: Option<Arc<crate::custom_fixture::CustomFixtureLoader>>,
53}
54
55#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)]
57pub enum ValidationMode {
58 Disabled,
60 #[default]
62 Warn,
63 Enforce,
65}
66
67#[derive(Debug, Clone)]
69pub struct ValidationOptions {
70 pub request_mode: ValidationMode,
72 pub aggregate_errors: bool,
74 pub validate_responses: bool,
76 pub overrides: HashMap<String, ValidationMode>,
78 pub admin_skip_prefixes: Vec<String>,
80 pub response_template_expand: bool,
82 pub validation_status: Option<u16>,
84}
85
86impl Default for ValidationOptions {
87 fn default() -> Self {
88 Self {
89 request_mode: ValidationMode::Enforce,
90 aggregate_errors: true,
91 validate_responses: false,
92 overrides: HashMap::new(),
93 admin_skip_prefixes: Vec::new(),
94 response_template_expand: false,
95 validation_status: None,
96 }
97 }
98}
99
100#[derive(Clone)]
105pub struct RouterContext {
106 pub custom_fixture_loader: Option<Arc<crate::custom_fixture::CustomFixtureLoader>>,
108 pub latency_injector: Option<LatencyInjector>,
110 pub failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
112 pub response_rewriter: Option<Arc<dyn ResponseRewriter>>,
116 pub overrides_enabled: bool,
119 pub ai_generator: Option<Arc<dyn AiGenerator + Send + Sync>>,
121 pub mockai: Option<
125 Arc<
126 tokio::sync::RwLock<
127 dyn mockforge_foundation::intelligent_behavior::MockAiBehavior + Send + Sync,
128 >,
129 >,
130 >,
131 pub enable_full_validation: bool,
133 pub enable_template_expand: bool,
135 pub add_spec_endpoint: bool,
137}
138
139impl Default for RouterContext {
140 fn default() -> Self {
141 Self {
142 custom_fixture_loader: None,
143 latency_injector: None,
144 failure_injector: None,
145 response_rewriter: None,
146 overrides_enabled: false,
147 ai_generator: None,
148 mockai: None,
149 enable_full_validation: false,
150 enable_template_expand: false,
151 add_spec_endpoint: true,
152 }
153 }
154}
155
156fn openapi_body_limit_bytes() -> usize {
171 const DEFAULT_MB: usize = 50;
172 std::env::var("MOCKFORGE_HTTP_BODY_LIMIT_MB")
173 .ok()
174 .and_then(|v| v.parse::<usize>().ok())
175 .unwrap_or(DEFAULT_MB)
176 .saturating_mul(1024 * 1024)
177}
178
179impl OpenApiRouteRegistry {
180 pub fn new(spec: OpenApiSpec) -> Self {
182 Self::new_with_env(spec)
183 }
184
185 pub fn new_with_env(spec: OpenApiSpec) -> Self {
194 Self::new_with_env_and_persona(spec, None)
195 }
196
197 pub fn new_with_env_and_persona(
199 spec: OpenApiSpec,
200 persona: Option<Arc<mockforge_foundation::intelligent_behavior::Persona>>,
201 ) -> Self {
202 tracing::debug!("Creating OpenAPI route registry");
203 let spec = Arc::new(spec);
204 let routes = Self::generate_routes_with_persona(&spec, persona);
205 let options = ValidationOptions {
206 request_mode: match std::env::var("MOCKFORGE_REQUEST_VALIDATION")
207 .unwrap_or_else(|_| "enforce".into())
208 .to_ascii_lowercase()
209 .as_str()
210 {
211 "off" | "disable" | "disabled" => ValidationMode::Disabled,
212 "warn" | "warning" => ValidationMode::Warn,
213 _ => ValidationMode::Enforce,
214 },
215 aggregate_errors: std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
216 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
217 .unwrap_or(true),
218 validate_responses: std::env::var("MOCKFORGE_RESPONSE_VALIDATION")
219 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
220 .unwrap_or(false),
221 overrides: HashMap::new(),
222 admin_skip_prefixes: Vec::new(),
223 response_template_expand: std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
224 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
225 .unwrap_or(false),
226 validation_status: std::env::var("MOCKFORGE_VALIDATION_STATUS")
227 .ok()
228 .and_then(|s| s.parse::<u16>().ok()),
229 };
230 Self {
231 spec,
232 routes,
233 options,
234 custom_fixture_loader: None,
235 }
236 }
237
238 pub fn new_with_options(spec: OpenApiSpec, options: ValidationOptions) -> Self {
240 Self::new_with_options_and_persona(spec, options, None)
241 }
242
243 pub fn new_with_options_and_persona(
245 spec: OpenApiSpec,
246 options: ValidationOptions,
247 persona: Option<Arc<mockforge_foundation::intelligent_behavior::Persona>>,
248 ) -> Self {
249 tracing::debug!("Creating OpenAPI route registry with custom options");
250 let spec = Arc::new(spec);
251 let routes = Self::generate_routes_with_persona(&spec, persona);
252 Self {
253 spec,
254 routes,
255 options,
256 custom_fixture_loader: None,
257 }
258 }
259
260 pub fn with_custom_fixture_loader(
262 mut self,
263 loader: Arc<crate::custom_fixture::CustomFixtureLoader>,
264 ) -> Self {
265 self.custom_fixture_loader = Some(loader);
266 self
267 }
268
269 pub fn clone_for_validation(&self) -> Self {
274 OpenApiRouteRegistry {
275 spec: self.spec.clone(),
276 routes: self.routes.clone(),
277 options: self.options.clone(),
278 custom_fixture_loader: self.custom_fixture_loader.clone(),
279 }
280 }
281
282 fn generate_routes_with_persona(
284 spec: &Arc<OpenApiSpec>,
285 persona: Option<Arc<mockforge_foundation::intelligent_behavior::Persona>>,
286 ) -> Vec<OpenApiRoute> {
287 let mut routes = Vec::new();
288
289 let all_paths_ops = spec.all_paths_and_operations();
290 tracing::debug!("Generating routes from OpenAPI spec with {} paths", all_paths_ops.len());
291
292 for (path, operations) in all_paths_ops {
293 tracing::debug!("Processing path: {}", path);
294 for (method, operation) in operations {
295 routes.push(OpenApiRoute::from_operation_with_persona(
296 &method,
297 path.clone(),
298 &operation,
299 spec.clone(),
300 persona.clone(),
301 ));
302 }
303 }
304
305 tracing::debug!("Generated {} total routes from OpenAPI spec", routes.len());
306 routes
307 }
308
309 pub fn routes(&self) -> &[OpenApiRoute] {
311 &self.routes
312 }
313
314 pub fn spec(&self) -> &OpenApiSpec {
316 &self.spec
317 }
318
319 fn normalize_path_for_dedup(path: &str) -> String {
323 let mut result = String::with_capacity(path.len());
324 let mut in_brace = false;
325 for ch in path.chars() {
326 if ch == '{' {
327 in_brace = true;
328 result.push_str("{_}");
329 } else if ch == '}' {
330 in_brace = false;
331 } else if !in_brace {
332 result.push(ch);
333 }
334 }
335 result
336 }
337
338 fn deduplicated_routes(&self) -> Vec<(String, &OpenApiRoute)> {
344 let mut result = Vec::new();
345 let mut registered_routes: HashSet<(String, String)> = HashSet::new();
346 let mut canonical_paths: HashMap<String, String> = HashMap::new();
347
348 for route in &self.routes {
349 if !route.is_valid_axum_path() {
350 tracing::warn!(
351 "Skipping route with unsupported path syntax: {} {}",
352 route.method,
353 route.path
354 );
355 continue;
356 }
357 let axum_path = route.axum_path();
358 let normalized = Self::normalize_path_for_dedup(&axum_path);
359 let axum_path = canonical_paths
360 .entry(normalized.clone())
361 .or_insert_with(|| axum_path.clone())
362 .clone();
363 let route_key = (route.method.clone(), normalized);
364 if !registered_routes.insert(route_key) {
365 tracing::debug!(
366 "Skipping duplicate route: {} {} (axum path: {})",
367 route.method,
368 route.path,
369 axum_path
370 );
371 continue;
372 }
373 result.push((axum_path, route));
374 }
375 result
376 }
377
378 fn route_for_method<H, T>(router: Router, path: &str, method: &str, handler: H) -> Router
382 where
383 H: axum::handler::Handler<T, ()>,
384 T: 'static,
385 {
386 match method {
387 "GET" => router.route(path, get(handler)),
388 "POST" => router.route(path, post(handler)),
389 "PUT" => router.route(path, put(handler)),
390 "DELETE" => router.route(path, delete(handler)),
391 "PATCH" => router.route(path, patch(handler)),
392 "HEAD" => router.route(path, head(handler)),
393 "OPTIONS" => router.route(path, options(handler)),
394 _ => router,
395 }
396 }
397
398 pub fn build_router(self) -> Router {
400 let ctx = RouterContext {
401 custom_fixture_loader: self.custom_fixture_loader.clone(),
402 enable_full_validation: true,
403 enable_template_expand: true,
404 add_spec_endpoint: true,
405 ..Default::default()
406 };
407 self.build_router_with_context(ctx)
408 }
409
410 fn build_router_with_context(self, ctx: RouterContext) -> Router {
415 let mut router = Router::new();
416 tracing::debug!("Building router from {} routes", self.routes.len());
417
418 let deduped = self.deduplicated_routes();
419 let ctx = Arc::new(ctx);
420 let validator = Arc::new(self.clone_for_validation());
428 for (axum_path, route) in &deduped {
429 tracing::debug!("Adding route: {} {}", route.method, route.path);
430 let operation = route.operation.clone();
431 let method = route.method.clone();
432 let path_template = route.path.clone();
433 let validator = validator.clone();
434 let route_clone = (*route).clone();
435 let ctx = ctx.clone();
436
437 let mut operation_tags = operation.tags.clone();
439 if let Some(operation_id) = &operation.operation_id {
440 operation_tags.push(operation_id.clone());
441 }
442
443 let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
445 RawQuery(raw_query): RawQuery,
446 headers: HeaderMap,
447 body: axum::body::Bytes| async move {
448 tracing::debug!("Handling OpenAPI request: {} {}", method, path_template);
449
450 if let Some(ref loader) = ctx.custom_fixture_loader {
452 use crate::request_fingerprint::RequestFingerprint;
453 use axum::http::{Method, Uri};
454
455 let mut request_path = path_template.clone();
457 for (key, value) in &path_params {
458 request_path = request_path.replace(&format!("{{{}}}", key), value);
459 }
460
461 let normalized_request_path =
463 crate::custom_fixture::CustomFixtureLoader::normalize_path(&request_path);
464
465 let query_string =
467 raw_query.as_ref().map(|q| q.to_string()).unwrap_or_default();
468
469 let uri_str = if query_string.is_empty() {
472 normalized_request_path.clone()
473 } else {
474 format!("{}?{}", normalized_request_path, query_string)
475 };
476
477 if let Ok(uri) = uri_str.parse::<Uri>() {
478 let http_method =
479 Method::from_bytes(method.as_bytes()).unwrap_or(Method::GET);
480 let body_slice = if body.is_empty() {
481 None
482 } else {
483 Some(body.as_ref())
484 };
485 let fingerprint =
486 RequestFingerprint::new(http_method, &uri, &headers, body_slice);
487
488 tracing::debug!(
490 "Checking fixture for {} {} (template: '{}', request_path: '{}', normalized: '{}', fingerprint.path: '{}')",
491 method,
492 path_template,
493 path_template,
494 request_path,
495 normalized_request_path,
496 fingerprint.path
497 );
498
499 if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
500 tracing::debug!(
501 "Using custom fixture for {} {}",
502 method,
503 path_template
504 );
505
506 if custom_fixture.delay_ms > 0 {
508 tokio::time::sleep(tokio::time::Duration::from_millis(
509 custom_fixture.delay_ms,
510 ))
511 .await;
512 }
513
514 let response_body = if custom_fixture.response.is_string() {
516 custom_fixture.response.as_str().unwrap().to_string()
517 } else {
518 serde_json::to_string(&custom_fixture.response)
519 .unwrap_or_else(|_| "{}".to_string())
520 };
521
522 let json_value: Value = serde_json::from_str(&response_body)
524 .unwrap_or_else(|_| serde_json::json!({}));
525
526 let status = axum::http::StatusCode::from_u16(custom_fixture.status)
528 .unwrap_or(axum::http::StatusCode::OK);
529
530 let mut response = (status, Json(json_value)).into_response();
531
532 let response_headers = response.headers_mut();
534 for (key, value) in &custom_fixture.headers {
535 if let (Ok(header_name), Ok(header_value)) = (
536 axum::http::HeaderName::from_bytes(key.as_bytes()),
537 axum::http::HeaderValue::from_str(value),
538 ) {
539 response_headers.insert(header_name, header_value);
540 }
541 }
542
543 if !custom_fixture.headers.contains_key("content-type") {
545 response_headers.insert(
546 axum::http::header::CONTENT_TYPE,
547 axum::http::HeaderValue::from_static("application/json"),
548 );
549 }
550
551 return response;
552 }
553 }
554 }
555
556 if let Some(ref failure_injector) = ctx.failure_injector {
558 if let Some((status_code, error_message)) =
559 failure_injector.process_request(&operation_tags)
560 {
561 let payload = serde_json::json!({
562 "error": error_message,
563 "injected_failure": true
564 });
565 let body_bytes = serde_json::to_vec(&payload)
566 .unwrap_or_else(|_| br#"{"error":"injected failure"}"#.to_vec());
567 return axum::http::Response::builder()
568 .status(
569 axum::http::StatusCode::from_u16(status_code)
570 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
571 )
572 .header(axum::http::header::CONTENT_TYPE, "application/json")
573 .body(axum::body::Body::from(body_bytes))
574 .expect("Response builder should create valid response");
575 }
576 }
577
578 if let Some(ref injector) = ctx.latency_injector {
580 if let Err(e) = injector.inject_latency(&operation_tags).await {
581 tracing::warn!("Failed to inject latency: {}", e);
582 }
583 }
584
585 let scenario = headers
587 .get("X-Mockforge-Scenario")
588 .and_then(|v| v.to_str().ok())
589 .map(|s| s.to_string())
590 .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
591
592 let status_override = headers
593 .get("X-Mockforge-Response-Status")
594 .and_then(|v| v.to_str().ok())
595 .and_then(|s| s.parse::<u16>().ok());
596
597 let (selected_status, mock_response) = route_clone
599 .mock_response_with_status_and_scenario_and_override(
600 scenario.as_deref(),
601 status_override,
602 );
603
604 if ctx.enable_full_validation {
606 let mut path_map = Map::new();
608 for (k, v) in &path_params {
609 path_map.insert(k.clone(), Value::String(v.clone()));
610 }
611
612 let mut query_map = Map::new();
614 if let Some(ref q) = raw_query {
615 for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
616 query_map.insert(k.to_string(), Value::String(v.to_string()));
617 }
618 }
619
620 let mut header_map = Map::new();
622 for p_ref in &operation.parameters {
623 if let Some(openapiv3::Parameter::Header { parameter_data, .. }) =
624 p_ref.as_item()
625 {
626 let name_lc = parameter_data.name.to_ascii_lowercase();
627 if let Ok(hn) = axum::http::HeaderName::from_bytes(name_lc.as_bytes()) {
628 if let Some(val) = headers.get(hn) {
629 if let Ok(s) = val.to_str() {
630 header_map.insert(
631 parameter_data.name.clone(),
632 Value::String(s.to_string()),
633 );
634 }
635 }
636 }
637 }
638 }
639
640 let mut cookie_map = Map::new();
642 if let Some(val) = headers.get(axum::http::header::COOKIE) {
643 if let Ok(s) = val.to_str() {
644 for part in s.split(';') {
645 let part = part.trim();
646 if let Some((k, v)) = part.split_once('=') {
647 cookie_map.insert(k.to_string(), Value::String(v.to_string()));
648 }
649 }
650 }
651 }
652
653 let is_multipart = headers
655 .get(axum::http::header::CONTENT_TYPE)
656 .and_then(|v| v.to_str().ok())
657 .map(|ct| ct.starts_with("multipart/form-data"))
658 .unwrap_or(false);
659
660 #[allow(unused_assignments)]
662 let mut multipart_fields = HashMap::new();
663 let mut _multipart_files = HashMap::new();
664 let mut body_json: Option<Value> = None;
665
666 if is_multipart {
667 match extract_multipart_from_bytes(&body, &headers).await {
669 Ok((fields, files)) => {
670 multipart_fields = fields;
671 _multipart_files = files;
672 let mut body_obj = Map::new();
674 for (k, v) in &multipart_fields {
675 body_obj.insert(k.clone(), v.clone());
676 }
677 if !body_obj.is_empty() {
678 body_json = Some(Value::Object(body_obj));
679 }
680 }
681 Err(e) => {
682 tracing::warn!("Failed to parse multipart data: {}", e);
683 }
684 }
685 } else {
686 body_json = if !body.is_empty() {
688 serde_json::from_slice(&body).ok()
689 } else {
690 None
691 };
692 }
693
694 let actual_ct =
707 headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok());
708 if let Err(ct_err) =
709 validator.check_request_content_type(&path_template, &method, actual_ct)
710 {
711 let status_code =
712 validator.options.validation_status.unwrap_or_else(|| {
713 std::env::var("MOCKFORGE_VALIDATION_STATUS")
714 .ok()
715 .and_then(|s| s.parse::<u16>().ok())
716 .unwrap_or(415)
717 });
718 let (client_mockforge_version, client_sent_at) =
719 mockforge_foundation::conformance_violations::read_client_stamps(
720 |name| {
721 headers
722 .get(name)
723 .and_then(|v| v.to_str().ok())
724 .map(|s| s.to_string())
725 },
726 );
727 mockforge_foundation::conformance_violations::record(
728 mockforge_foundation::conformance_violations::ServerConformanceViolation {
729 timestamp: Utc::now(),
730 method: method.to_string(),
731 path: path_template.clone(),
732 client_ip: "unknown".to_string(),
733 status: status_code,
734 reason: ct_err.clone(),
735 category: "content-types".to_string(),
736 occurrences: 1,
737 client_mockforge_version,
738 client_sent_at,
739 summary: String::new(),
740 categories: Vec::new(),
741 },
742 );
743 let status = axum::http::StatusCode::from_u16(status_code)
744 .unwrap_or(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
745 let payload = serde_json::json!({
746 "error": "content_type_not_allowed",
747 "message": ct_err,
748 });
749 let body_bytes = serde_json::to_vec(&payload)
750 .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
751 return axum::response::Response::builder()
752 .status(status)
753 .header("content-type", "application/json")
754 .body(axum::body::Body::from(body_bytes))
755 .unwrap_or_else(|_| {
756 axum::response::Response::builder()
757 .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
758 .body(axum::body::Body::empty())
759 .unwrap()
760 });
761 }
762
763 if let Err(e) = validator.validate_request_with_all(
764 &path_template,
765 &method,
766 &path_map,
767 &query_map,
768 &header_map,
769 &cookie_map,
770 body_json.as_ref(),
771 ) {
772 let status_code =
774 validator.options.validation_status.unwrap_or_else(|| {
775 std::env::var("MOCKFORGE_VALIDATION_STATUS")
776 .ok()
777 .and_then(|s| s.parse::<u16>().ok())
778 .unwrap_or(400)
779 });
780
781 let payload = if status_code == 422 {
782 generate_enhanced_422_response(
784 &validator,
785 &path_template,
786 &method,
787 body_json.as_ref(),
788 &path_map,
789 &query_map,
790 &header_map,
791 &cookie_map,
792 )
793 } else {
794 let msg = format!("{}", e);
796 let detail_val = serde_json::from_str::<Value>(&msg)
797 .unwrap_or(serde_json::json!(msg));
798 json!({
799 "error": "request validation failed",
800 "detail": detail_val,
801 "method": method,
802 "path": path_template,
803 "timestamp": Utc::now().to_rfc3339(),
804 })
805 };
806
807 record_validation_error(&payload);
808
809 let reason = payload
816 .get("detail")
817 .and_then(|d| {
818 if d.is_string() {
819 d.as_str().map(|s| s.to_string())
820 } else {
821 serde_json::to_string(d).ok()
822 }
823 })
824 .unwrap_or_else(|| {
825 payload
826 .get("error")
827 .and_then(|v| v.as_str())
828 .unwrap_or("request validation failed")
829 .to_string()
830 });
831 let category = classify_validation_reason(&reason);
832 let (client_mockforge_version, client_sent_at) =
833 mockforge_foundation::conformance_violations::read_client_stamps(
834 |name| {
835 headers
836 .get(name)
837 .and_then(|v| v.to_str().ok())
838 .map(|s| s.to_string())
839 },
840 );
841 mockforge_foundation::conformance_violations::record(
842 mockforge_foundation::conformance_violations::ServerConformanceViolation {
843 timestamp: Utc::now(),
844 method: method.to_string(),
845 path: path_template.clone(),
846 client_ip: "unknown".to_string(),
847 status: status_code,
848 reason,
849 category,
850 occurrences: 1,
851 client_mockforge_version,
852 client_sent_at,
853 summary: String::new(),
854 categories: Vec::new(),
855 },
856 );
857
858 let status = axum::http::StatusCode::from_u16(status_code)
859 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
860
861 let body_bytes = serde_json::to_vec(&payload)
863 .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
864
865 return axum::http::Response::builder()
866 .status(status)
867 .header(axum::http::header::CONTENT_TYPE, "application/json")
868 .body(axum::body::Body::from(body_bytes))
869 .expect("Response builder should create valid response with valid headers and body");
870 }
871 }
872
873 let mut final_response = mock_response.clone();
882 let env_expand: Option<bool> = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
883 .ok()
884 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"));
885 let expand = match env_expand {
886 Some(v) => v,
887 None => {
888 ctx.enable_template_expand || validator.options.response_template_expand
889 }
890 };
891 if expand {
892 if let Some(ref rewriter) = ctx.response_rewriter {
893 rewriter.expand_tokens(&mut final_response);
894 }
895 }
896
897 if ctx.overrides_enabled {
899 if let Some(ref rewriter) = ctx.response_rewriter {
900 let op_tags =
901 operation.operation_id.clone().map(|id| vec![id]).unwrap_or_default();
902 rewriter.apply_overrides(
903 &operation.operation_id.clone().unwrap_or_default(),
904 &op_tags,
905 &path_template,
906 &mut final_response,
907 );
908 }
909 }
910
911 if ctx.enable_full_validation {
913 if validator.options.validate_responses {
915 if let Some((status_code, _response)) = operation
917 .responses
918 .responses
919 .iter()
920 .filter_map(|(status, resp)| match status {
921 openapiv3::StatusCode::Code(code)
922 if *code >= 200 && *code < 300 =>
923 {
924 resp.as_item().map(|r| ((*code), r))
925 }
926 openapiv3::StatusCode::Range(range)
927 if *range >= 200 && *range < 300 =>
928 {
929 resp.as_item().map(|r| (200, r))
930 }
931 _ => None,
932 })
933 .next()
934 {
935 if serde_json::from_value::<Value>(final_response.clone()).is_err() {
937 tracing::warn!(
938 "Response validation failed: invalid JSON for status {}",
939 status_code
940 );
941 }
942 }
943 }
944
945 let mut trace = ResponseGenerationTrace::new();
947 trace.set_final_payload(final_response.clone());
948
949 if let Some((_status_code, response_ref)) = operation
951 .responses
952 .responses
953 .iter()
954 .filter_map(|(status, resp)| match status {
955 openapiv3::StatusCode::Code(code) if *code == selected_status => {
956 resp.as_item().map(|r| ((*code), r))
957 }
958 openapiv3::StatusCode::Range(range)
959 if *range >= 200 && *range < 300 =>
960 {
961 resp.as_item().map(|r| (200, r))
962 }
963 _ => None,
964 })
965 .next()
966 .or_else(|| {
967 operation
969 .responses
970 .responses
971 .iter()
972 .filter_map(|(status, resp)| match status {
973 openapiv3::StatusCode::Code(code)
974 if *code >= 200 && *code < 300 =>
975 {
976 resp.as_item().map(|r| ((*code), r))
977 }
978 _ => None,
979 })
980 .next()
981 })
982 {
983 let response_item = response_ref;
985 if let Some(content) = response_item.content.get("application/json") {
987 if let Some(schema_ref) = &content.schema {
988 if let Some(schema) = schema_ref.as_item() {
990 if let Ok(schema_json) = serde_json::to_value(schema) {
991 let validation_errors =
993 validation_diff(&schema_json, &final_response);
994 trace.set_schema_validation_diff(validation_errors);
995 }
996 }
997 }
998 }
999 }
1000
1001 let mut response = Json(final_response).into_response();
1003 response.extensions_mut().insert(trace);
1004 *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
1005 .unwrap_or(axum::http::StatusCode::OK);
1006 inject_spec_response_headers(&mut response, &route_clone, selected_status);
1007 return response;
1008 }
1009
1010 let mut response = Json(final_response).into_response();
1012 *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
1013 .unwrap_or(axum::http::StatusCode::OK);
1014 inject_spec_response_headers(&mut response, &route_clone, selected_status);
1015 response
1016 };
1017
1018 router = Self::route_for_method(router, axum_path, &route.method, handler);
1019 }
1020
1021 if ctx.add_spec_endpoint {
1023 let spec_json = serde_json::to_value(&self.spec.spec).unwrap_or(Value::Null);
1024 router = router.route("/openapi.json", get(move || async move { Json(spec_json) }));
1025 }
1026
1027 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
1031 }
1032
1033 pub fn build_router_with_latency(self, latency_injector: LatencyInjector) -> Router {
1035 self.build_router_with_injectors(latency_injector, None)
1036 }
1037
1038 pub fn build_router_with_injectors(
1040 self,
1041 latency_injector: LatencyInjector,
1042 failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1043 ) -> Router {
1044 self.build_router_with_injectors_and_overrides(
1045 latency_injector,
1046 failure_injector,
1047 None,
1048 false,
1049 )
1050 }
1051
1052 pub fn build_router_with_injectors_and_overrides(
1056 self,
1057 latency_injector: LatencyInjector,
1058 failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1059 response_rewriter: Option<Arc<dyn ResponseRewriter>>,
1060 overrides_enabled: bool,
1061 ) -> Router {
1062 let ctx = RouterContext {
1063 custom_fixture_loader: self.custom_fixture_loader.clone(),
1064 latency_injector: Some(latency_injector),
1065 failure_injector,
1066 response_rewriter,
1067 overrides_enabled,
1068 enable_full_validation: true,
1069 enable_template_expand: true,
1070 add_spec_endpoint: true,
1071 ..Default::default()
1072 };
1073 self.build_router_with_context(ctx)
1074 }
1075
1076 pub fn get_route(&self, path: &str, method: &str) -> Option<&OpenApiRoute> {
1078 self.routes.iter().find(|route| route.path == path && route.method == method)
1079 }
1080
1081 pub fn get_routes_for_path(&self, path: &str) -> Vec<&OpenApiRoute> {
1083 self.routes.iter().filter(|route| route.path == path).collect()
1084 }
1085
1086 pub fn validate_request(&self, path: &str, method: &str, body: Option<&Value>) -> Result<()> {
1088 self.validate_request_with(path, method, &Map::new(), &Map::new(), body)
1089 }
1090
1091 pub fn check_request_content_type(
1107 &self,
1108 path: &str,
1109 method: &str,
1110 actual_content_type: Option<&str>,
1111 ) -> std::result::Result<(), String> {
1112 let Some(route) = self.get_route(path, method) else {
1113 return Ok(());
1114 };
1115 let Some(rb_ref) = &route.operation.request_body else {
1116 return Ok(());
1117 };
1118 let request_body = match rb_ref {
1119 openapiv3::ReferenceOr::Item(rb) => rb,
1120 openapiv3::ReferenceOr::Reference { reference } => {
1121 let resolved = self
1122 .spec
1123 .spec
1124 .components
1125 .as_ref()
1126 .and_then(|components| {
1127 components
1128 .request_bodies
1129 .get(reference.trim_start_matches("#/components/requestBodies/"))
1130 })
1131 .and_then(|rb_ref| rb_ref.as_item());
1132 let Some(rb) = resolved else { return Ok(()) };
1133 rb
1134 }
1135 };
1136 if request_body.content.is_empty() {
1137 return Ok(());
1138 }
1139 let actual = actual_content_type
1140 .and_then(|s| s.split(';').next())
1141 .map(|s| s.trim().to_ascii_lowercase());
1142 let Some(actual) = actual else {
1143 return Ok(());
1148 };
1149 let allowed: Vec<String> = request_body
1150 .content
1151 .keys()
1152 .map(|k| k.split(';').next().unwrap_or(k).trim().to_ascii_lowercase())
1153 .collect();
1154 if allowed.iter().any(|a| a == &actual) {
1155 return Ok(());
1156 }
1157 Err(format!(
1158 "Content-Type '{actual}' not allowed; spec declares: [{}]",
1159 allowed.join(", ")
1160 ))
1161 }
1162
1163 pub fn validate_request_with(
1165 &self,
1166 path: &str,
1167 method: &str,
1168 path_params: &Map<String, Value>,
1169 query_params: &Map<String, Value>,
1170 body: Option<&Value>,
1171 ) -> Result<()> {
1172 self.validate_request_with_all(
1173 path,
1174 method,
1175 path_params,
1176 query_params,
1177 &Map::new(),
1178 &Map::new(),
1179 body,
1180 )
1181 }
1182
1183 #[allow(clippy::too_many_arguments)]
1196 pub fn run_validation_with_recording(
1197 &self,
1198 path_template: &str,
1199 method: &str,
1200 path_params: &Map<String, Value>,
1201 query_params: &Map<String, Value>,
1202 header_map: &Map<String, Value>,
1203 cookie_map: &Map<String, Value>,
1204 body: Option<&Value>,
1205 ) -> std::result::Result<(), (u16, Value)> {
1206 let e = match self.validate_request_with_all(
1207 path_template,
1208 method,
1209 path_params,
1210 query_params,
1211 header_map,
1212 cookie_map,
1213 body,
1214 ) {
1215 Ok(()) => {
1216 mockforge_foundation::conformance_violations::record_ok();
1220 return Ok(());
1221 }
1222 Err(e) => e,
1223 };
1224
1225 let status_code = self.options.validation_status.unwrap_or_else(|| {
1226 std::env::var("MOCKFORGE_VALIDATION_STATUS")
1227 .ok()
1228 .and_then(|s| s.parse::<u16>().ok())
1229 .unwrap_or(400)
1230 });
1231
1232 let payload = if status_code == 422 {
1233 generate_enhanced_422_response(
1234 self,
1235 path_template,
1236 method,
1237 body,
1238 path_params,
1239 query_params,
1240 header_map,
1241 cookie_map,
1242 )
1243 } else {
1244 let msg = format!("{}", e);
1245 let detail_val = serde_json::from_str::<Value>(&msg).unwrap_or(serde_json::json!(msg));
1246 json!({
1247 "error": "request validation failed",
1248 "detail": detail_val,
1249 "method": method,
1250 "path": path_template,
1251 "timestamp": Utc::now().to_rfc3339(),
1252 })
1253 };
1254
1255 record_validation_error(&payload);
1256
1257 let reason = payload
1258 .get("detail")
1259 .and_then(|d| {
1260 if d.is_string() {
1261 d.as_str().map(|s| s.to_string())
1262 } else {
1263 serde_json::to_string(d).ok()
1264 }
1265 })
1266 .unwrap_or_else(|| {
1267 payload
1268 .get("error")
1269 .and_then(|v| v.as_str())
1270 .unwrap_or("request validation failed")
1271 .to_string()
1272 });
1273 let category = classify_validation_reason(&reason);
1274 tracing::debug!(
1282 target: "mockforge::conformance",
1283 method = %method,
1284 path = %path_template,
1285 status = status_code,
1286 category = %category,
1287 reason = %reason,
1288 "request conformance violation"
1289 );
1290 let (client_mockforge_version, client_sent_at) =
1291 mockforge_foundation::conformance_violations::read_client_stamps(|name| {
1292 header_map
1293 .iter()
1294 .find(|(k, _)| k.eq_ignore_ascii_case(name))
1295 .and_then(|(_, v)| v.as_str().map(|s| s.to_string()))
1296 });
1297 mockforge_foundation::conformance_violations::record(
1298 mockforge_foundation::conformance_violations::ServerConformanceViolation {
1299 timestamp: Utc::now(),
1300 method: method.to_string(),
1301 path: path_template.to_string(),
1302 client_ip: "unknown".to_string(),
1303 status: status_code,
1304 reason,
1305 category,
1306 occurrences: 1,
1307 client_mockforge_version,
1308 client_sent_at,
1309 summary: String::new(),
1310 categories: Vec::new(),
1311 },
1312 );
1313
1314 if mockforge_foundation::unknown_paths::shadow_mode_enabled() {
1321 return Ok(());
1322 }
1323
1324 Err((status_code, payload))
1325 }
1326
1327 #[allow(clippy::too_many_arguments)]
1329 pub fn validate_request_with_all(
1330 &self,
1331 path: &str,
1332 method: &str,
1333 path_params: &Map<String, Value>,
1334 query_params: &Map<String, Value>,
1335 header_params: &Map<String, Value>,
1336 cookie_params: &Map<String, Value>,
1337 body: Option<&Value>,
1338 ) -> Result<()> {
1339 for pref in &self.options.admin_skip_prefixes {
1341 if !pref.is_empty() && path.starts_with(pref) {
1342 return Ok(());
1343 }
1344 }
1345 let env_mode = std::env::var("MOCKFORGE_REQUEST_VALIDATION").ok().map(|v| {
1347 match v.to_ascii_lowercase().as_str() {
1348 "off" | "disable" | "disabled" => ValidationMode::Disabled,
1349 "warn" | "warning" => ValidationMode::Warn,
1350 _ => ValidationMode::Enforce,
1351 }
1352 });
1353 let aggregate = std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
1354 .ok()
1355 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1356 .unwrap_or(self.options.aggregate_errors);
1357 let env_overrides: Option<Map<String, Value>> =
1359 std::env::var("MOCKFORGE_VALIDATION_OVERRIDES_JSON")
1360 .ok()
1361 .and_then(|s| serde_json::from_str::<Value>(&s).ok())
1362 .and_then(|v| v.as_object().cloned());
1363 let mut effective_mode = env_mode.unwrap_or(self.options.request_mode.clone());
1365 if let Some(map) = &env_overrides {
1367 if let Some(v) = map.get(&format!("{} {}", method, path)) {
1368 if let Some(m) = v.as_str() {
1369 effective_mode = match m {
1370 "off" => ValidationMode::Disabled,
1371 "warn" => ValidationMode::Warn,
1372 _ => ValidationMode::Enforce,
1373 };
1374 }
1375 }
1376 }
1377 if let Some(override_mode) = self.options.overrides.get(&format!("{} {}", method, path)) {
1379 effective_mode = override_mode.clone();
1380 }
1381 if matches!(effective_mode, ValidationMode::Disabled) {
1382 return Ok(());
1383 }
1384 if let Some(route) = self.get_route(path, method) {
1385 if matches!(effective_mode, ValidationMode::Disabled) {
1386 return Ok(());
1387 }
1388 let mut errors: Vec<String> = Vec::new();
1389 let mut details: Vec<Value> = Vec::new();
1390 if let Some(schema) = &route.operation.request_body {
1392 if let Some(value) = body {
1393 let request_body = match schema {
1395 openapiv3::ReferenceOr::Item(rb) => Some(rb),
1396 openapiv3::ReferenceOr::Reference { reference } => {
1397 self.spec
1399 .spec
1400 .components
1401 .as_ref()
1402 .and_then(|components| {
1403 components.request_bodies.get(
1404 reference.trim_start_matches("#/components/requestBodies/"),
1405 )
1406 })
1407 .and_then(|rb_ref| rb_ref.as_item())
1408 }
1409 };
1410
1411 if let Some(rb) = request_body {
1412 if let Some(content) = rb.content.get("application/json") {
1413 if let Some(schema_ref) = &content.schema {
1414 let root_schema = match schema_ref {
1427 openapiv3::ReferenceOr::Item(s) => Some((*s).clone()),
1428 openapiv3::ReferenceOr::Reference { reference } => {
1429 self.spec.get_schema(reference).map(|s| s.schema.clone())
1430 }
1431 };
1432 if let Some(root_schema) = root_schema {
1433 let result = crate::schema_ref_resolver::build_validator(
1434 &root_schema,
1435 &self.spec.spec,
1436 )
1437 .and_then(|validator| {
1438 let errs: Vec<String> = validator
1439 .iter_errors(value)
1440 .map(|e| e.to_string())
1441 .collect();
1442 if errs.is_empty() {
1443 Ok(())
1444 } else {
1445 Err(errs.join("; "))
1446 }
1447 });
1448 if let Err(error_msg) = result {
1449 errors
1450 .push(format!("body validation failed: {}", error_msg));
1451 if aggregate {
1452 details.push(serde_json::json!({"path":"body","code":"schema_validation","message":error_msg}));
1453 }
1454 }
1455 } else if let openapiv3::ReferenceOr::Reference { reference } =
1456 schema_ref
1457 {
1458 errors.push(format!("body validation failed: could not resolve schema reference {}", reference));
1460 if aggregate {
1461 details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve schema reference"}));
1462 }
1463 }
1464 }
1465 }
1466 } else {
1467 errors.push("body validation failed: could not resolve request body or no application/json content".to_string());
1469 if aggregate {
1470 details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve request body reference"}));
1471 }
1472 }
1473 } else {
1474 errors.push("body: Request body is required but not provided".to_string());
1475 details.push(serde_json::json!({"path":"body","code":"required","message":"Request body is required"}));
1476 }
1477 } else if body.is_some() {
1478 tracing::debug!("Body provided for operation without requestBody; accepting");
1480 }
1481
1482 for p_ref in &route.operation.parameters {
1484 if let Some(p) = p_ref.as_item() {
1485 match p {
1486 openapiv3::Parameter::Path { parameter_data, .. } => {
1487 validate_parameter(
1488 parameter_data,
1489 path_params,
1490 "path",
1491 aggregate,
1492 &mut errors,
1493 &mut details,
1494 );
1495 }
1496 openapiv3::Parameter::Query {
1497 parameter_data,
1498 style,
1499 ..
1500 } => {
1501 let deep_value = if matches!(style, openapiv3::QueryStyle::DeepObject) {
1504 let prefix_bracket = format!("{}[", parameter_data.name);
1505 let mut obj = Map::new();
1506 for (key, val) in query_params.iter() {
1507 if let Some(rest) = key.strip_prefix(&prefix_bracket) {
1508 if let Some(prop) = rest.strip_suffix(']') {
1509 obj.insert(prop.to_string(), val.clone());
1510 }
1511 }
1512 }
1513 if obj.is_empty() {
1514 None
1515 } else {
1516 Some(Value::Object(obj))
1517 }
1518 } else {
1519 None
1520 };
1521 let style_str = match style {
1522 openapiv3::QueryStyle::Form => Some("form"),
1523 openapiv3::QueryStyle::SpaceDelimited => Some("spaceDelimited"),
1524 openapiv3::QueryStyle::PipeDelimited => Some("pipeDelimited"),
1525 openapiv3::QueryStyle::DeepObject => Some("deepObject"),
1526 };
1527 validate_parameter_with_deep_object(
1528 parameter_data,
1529 query_params,
1530 "query",
1531 deep_value,
1532 style_str,
1533 aggregate,
1534 &mut errors,
1535 &mut details,
1536 );
1537 }
1538 openapiv3::Parameter::Header { parameter_data, .. } => {
1539 validate_parameter(
1540 parameter_data,
1541 header_params,
1542 "header",
1543 aggregate,
1544 &mut errors,
1545 &mut details,
1546 );
1547 }
1548 openapiv3::Parameter::Cookie { parameter_data, .. } => {
1549 validate_parameter(
1550 parameter_data,
1551 cookie_params,
1552 "cookie",
1553 aggregate,
1554 &mut errors,
1555 &mut details,
1556 );
1557 }
1558 }
1559 }
1560 }
1561 if errors.is_empty() {
1562 return Ok(());
1563 }
1564 match effective_mode {
1565 ValidationMode::Disabled => Ok(()),
1566 ValidationMode::Warn => {
1567 tracing::warn!("Request validation warnings: {:?}", errors);
1568 Ok(())
1569 }
1570 ValidationMode::Enforce => Err(Error::validation(
1571 serde_json::json!({"errors": errors, "details": details}).to_string(),
1572 )),
1573 }
1574 } else {
1575 Err(Error::internal(format!("Route {} {} not found in OpenAPI spec", method, path)))
1576 }
1577 }
1578
1579 pub fn paths(&self) -> Vec<String> {
1583 let mut paths: Vec<String> = self.routes.iter().map(|route| route.path.clone()).collect();
1584 paths.sort();
1585 paths.dedup();
1586 paths
1587 }
1588
1589 pub fn methods(&self) -> Vec<String> {
1591 let mut methods: Vec<String> =
1592 self.routes.iter().map(|route| route.method.clone()).collect();
1593 methods.sort();
1594 methods.dedup();
1595 methods
1596 }
1597
1598 pub fn get_operation(&self, path: &str, method: &str) -> Option<OpenApiOperation> {
1600 self.get_route(path, method).map(|route| {
1601 OpenApiOperation::from_operation(
1602 &route.method,
1603 route.path.clone(),
1604 &route.operation,
1605 &self.spec,
1606 )
1607 })
1608 }
1609
1610 pub fn extract_path_parameters(&self, path: &str, method: &str) -> HashMap<String, String> {
1612 let mut best: Option<(usize, HashMap<String, String>)> = None;
1618 for route in &self.routes {
1619 if route.method != method {
1620 continue;
1621 }
1622
1623 if let Some(params) = self.match_path_to_route(path, &route.path) {
1624 let static_segments = route
1625 .path
1626 .trim_start_matches('/')
1627 .split('/')
1628 .filter(|s| !(s.starts_with('{') && s.ends_with('}')))
1629 .count();
1630 let is_more_specific = match &best {
1631 None => true,
1632 Some((score, _)) => static_segments > *score,
1633 };
1634 if is_more_specific {
1635 best = Some((static_segments, params));
1636 }
1637 }
1638 }
1639 best.map(|(_, params)| params).unwrap_or_default()
1640 }
1641
1642 fn match_path_to_route(
1644 &self,
1645 request_path: &str,
1646 route_pattern: &str,
1647 ) -> Option<HashMap<String, String>> {
1648 let mut params = HashMap::new();
1649
1650 let request_segments: Vec<&str> = request_path.trim_start_matches('/').split('/').collect();
1652 let pattern_segments: Vec<&str> =
1653 route_pattern.trim_start_matches('/').split('/').collect();
1654
1655 if request_segments.len() != pattern_segments.len() {
1656 return None;
1657 }
1658
1659 for (req_seg, pat_seg) in request_segments.iter().zip(pattern_segments.iter()) {
1660 if pat_seg.starts_with('{') && pat_seg.ends_with('}') {
1661 if req_seg.is_empty() {
1666 return None;
1667 }
1668 let param_name = &pat_seg[1..pat_seg.len() - 1];
1669 params.insert(param_name.to_string(), req_seg.to_string());
1670 } else if req_seg != pat_seg {
1671 return None;
1673 }
1674 }
1675
1676 Some(params)
1677 }
1678
1679 pub fn convert_path_to_axum(openapi_path: &str) -> String {
1682 openapi_path.to_string()
1684 }
1685
1686 pub fn build_router_with_ai(
1688 &self,
1689 ai_generator: Option<Arc<dyn AiGenerator + Send + Sync>>,
1690 ) -> Router {
1691 let mut router = Router::new();
1692 let deduped = self.deduplicated_routes();
1693 tracing::debug!("Building router with AI support from {} routes", self.routes.len());
1694
1695 let validator = Arc::new(self.clone_for_validation());
1699 for (axum_path, route) in &deduped {
1700 tracing::debug!("Adding AI-enabled route: {} {}", route.method, route.path);
1701
1702 let route_clone = (*route).clone();
1703 let ai_generator_clone = ai_generator.clone();
1704 let validator_clone = validator.clone();
1708
1709 let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
1711 axum::extract::Query(query_params): axum::extract::Query<
1712 HashMap<String, String>,
1713 >,
1714 headers: HeaderMap,
1715 body: Option<Json<Value>>| {
1716 let route = route_clone.clone();
1717 let ai_generator = ai_generator_clone.clone();
1718 let validator = validator_clone.clone();
1719
1720 async move {
1721 let mut path_map = Map::new();
1726 for (k, v) in &path_params {
1727 path_map.insert(k.clone(), Value::String(v.clone()));
1728 }
1729 let mut query_map = Map::new();
1730 for (k, v) in &query_params {
1731 query_map.insert(k.clone(), Value::String(v.clone()));
1732 }
1733 let mut header_map = Map::new();
1734 for (k, v) in headers.iter() {
1735 if let Ok(s) = v.to_str() {
1736 header_map.insert(k.to_string(), Value::String(s.to_string()));
1737 }
1738 }
1739 let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
1740 if let Err((status_code, payload)) = validator.run_validation_with_recording(
1741 &route.path,
1742 &route.method,
1743 &path_map,
1744 &query_map,
1745 &header_map,
1746 &Map::new(),
1747 body_val,
1748 ) {
1749 let status = axum::http::StatusCode::from_u16(status_code)
1750 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
1751 return (status, Json(payload));
1752 }
1753
1754 tracing::debug!(
1755 "Handling AI request for route: {} {}",
1756 route.method,
1757 route.path
1758 );
1759
1760 let mut context = RequestContext::new(route.method.clone(), route.path.clone());
1762
1763 context.headers = headers
1765 .iter()
1766 .map(|(k, v)| {
1767 (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string()))
1768 })
1769 .collect();
1770
1771 context.body = body.map(|Json(b)| b);
1773
1774 let (status, response) = if let (Some(generator), Some(_ai_config)) =
1776 (ai_generator, &route.ai_config)
1777 {
1778 route
1779 .mock_response_with_status_async(&context, Some(generator.as_ref()))
1780 .await
1781 } else {
1782 route.mock_response_with_status()
1784 };
1785
1786 (
1787 axum::http::StatusCode::from_u16(status)
1788 .unwrap_or(axum::http::StatusCode::OK),
1789 Json(response),
1790 )
1791 }
1792 };
1793
1794 router = Self::route_for_method(router, axum_path, &route.method, handler);
1795 }
1796
1797 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
1801 }
1802
1803 pub fn build_router_with_mockai(
1814 &self,
1815 mockai: Option<
1816 Arc<
1817 tokio::sync::RwLock<
1818 dyn mockforge_foundation::intelligent_behavior::MockAiBehavior + Send + Sync,
1819 >,
1820 >,
1821 >,
1822 ) -> Router {
1823 use mockforge_foundation::intelligent_behavior::Request as MockAIRequest;
1824
1825 let mut router = Router::new();
1826 let deduped = self.deduplicated_routes();
1827 tracing::debug!("Building router with MockAI support from {} routes", self.routes.len());
1828
1829 let custom_loader = self.custom_fixture_loader.clone();
1830 let validator = Arc::new(self.clone_for_validation());
1834 for (axum_path, route) in &deduped {
1835 tracing::debug!("Adding MockAI-enabled route: {} {}", route.method, route.path);
1836
1837 let route_clone = (*route).clone();
1838 let mockai_clone = mockai.clone();
1839 let custom_loader_clone = custom_loader.clone();
1840 let validator_clone = validator.clone();
1846
1847 let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
1863 query: axum::extract::Query<HashMap<String, String>>,
1864 headers: HeaderMap,
1865 body_bytes: axum::body::Bytes| {
1866 let route = route_clone.clone();
1867 let mockai = mockai_clone.clone();
1868 let validator = validator_clone.clone();
1869
1870 async move {
1871 let mut path_map = Map::new();
1872 for (k, v) in &path_params {
1873 path_map.insert(k.clone(), Value::String(v.clone()));
1874 }
1875 let mut query_map = Map::new();
1876 for (k, v) in &query.0 {
1877 query_map.insert(k.clone(), Value::String(v.clone()));
1878 }
1879 let mut header_map = Map::new();
1880 for (k, v) in headers.iter() {
1881 if let Ok(s) = v.to_str() {
1882 header_map.insert(k.to_string(), Value::String(s.to_string()));
1883 }
1884 }
1885
1886 if !body_bytes.is_empty() {
1892 let actual_ct = headers
1893 .get(axum::http::header::CONTENT_TYPE)
1894 .and_then(|v| v.to_str().ok());
1895 if let Err(ct_err) = validator.check_request_content_type(
1896 &route.path,
1897 &route.method,
1898 actual_ct,
1899 ) {
1900 let status_code =
1901 validator.options.validation_status.unwrap_or_else(|| {
1902 std::env::var("MOCKFORGE_VALIDATION_STATUS")
1903 .ok()
1904 .and_then(|s| s.parse::<u16>().ok())
1905 .unwrap_or(415)
1906 });
1907 let (client_mockforge_version, client_sent_at) =
1908 mockforge_foundation::conformance_violations::read_client_stamps(
1909 |name| {
1910 headers
1911 .get(name)
1912 .and_then(|v| v.to_str().ok())
1913 .map(|s| s.to_string())
1914 },
1915 );
1916 mockforge_foundation::conformance_violations::record(
1917 mockforge_foundation::conformance_violations::ServerConformanceViolation {
1918 timestamp: Utc::now(),
1919 method: route.method.clone(),
1920 path: route.path.clone(),
1921 client_ip: "unknown".to_string(),
1922 status: status_code,
1923 reason: ct_err.clone(),
1924 category: "content-types".to_string(),
1925 occurrences: 1,
1926 client_mockforge_version,
1927 client_sent_at,
1928 summary: String::new(),
1929 categories: Vec::new(),
1930 },
1931 );
1932 let status = axum::http::StatusCode::from_u16(status_code)
1933 .unwrap_or(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
1934 return (
1935 status,
1936 Json(serde_json::json!({
1937 "error": "content_type_not_allowed",
1938 "message": ct_err,
1939 })),
1940 )
1941 .into_response();
1942 }
1943 }
1944
1945 let is_multipart_req = headers
1964 .get(axum::http::header::CONTENT_TYPE)
1965 .and_then(|v| v.to_str().ok())
1966 .map(|ct| ct.starts_with("multipart/form-data"))
1967 .unwrap_or(false);
1968 let body: Option<Json<Value>> = if body_bytes.is_empty() {
1969 None
1970 } else if is_multipart_req {
1971 match extract_multipart_from_bytes(&body_bytes, &headers).await {
1972 Ok((fields, _files)) => {
1973 let mut obj = Map::new();
1974 for (k, v) in fields {
1975 obj.insert(k, v);
1976 }
1977 if obj.is_empty() {
1978 Some(Json(Value::Object(Map::new())))
1986 } else {
1987 Some(Json(Value::Object(obj)))
1988 }
1989 }
1990 Err(e) => {
1991 tracing::warn!(
1992 "multipart parse failed for {} {}: {}",
1993 route.method,
1994 route.path,
1995 e
1996 );
1997 Some(Json(Value::Object(Map::new())))
1998 }
1999 }
2000 } else {
2001 serde_json::from_slice::<Value>(&body_bytes).ok().map(Json)
2002 };
2003
2004 let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
2009 if let Err((status_code, payload)) = validator.run_validation_with_recording(
2010 &route.path,
2011 &route.method,
2012 &path_map,
2013 &query_map,
2014 &header_map,
2015 &Map::new(),
2016 body_val,
2017 ) {
2018 let status = axum::http::StatusCode::from_u16(status_code)
2019 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
2020 return (status, Json(payload)).into_response();
2021 }
2022
2023 tracing::info!(
2024 "[FIXTURE DEBUG] Starting fixture check for {} {} (custom_loader available: {})",
2025 route.method,
2026 route.path,
2027 custom_loader_clone.is_some()
2028 );
2029
2030 if let Some(ref loader) = custom_loader_clone {
2032 use crate::request_fingerprint::RequestFingerprint;
2033 use axum::http::{Method, Uri};
2034
2035 let query_string = if query.0.is_empty() {
2037 String::new()
2038 } else {
2039 query
2040 .0
2041 .iter()
2042 .map(|(k, v)| format!("{}={}", k, v))
2043 .collect::<Vec<_>>()
2044 .join("&")
2045 };
2046
2047 let normalized_request_path =
2049 crate::custom_fixture::CustomFixtureLoader::normalize_path(&route.path);
2050
2051 tracing::info!(
2052 "[FIXTURE DEBUG] Path normalization: original='{}', normalized='{}'",
2053 route.path,
2054 normalized_request_path
2055 );
2056
2057 let uri_str = if query_string.is_empty() {
2059 normalized_request_path.clone()
2060 } else {
2061 format!("{}?{}", normalized_request_path, query_string)
2062 };
2063
2064 tracing::info!(
2065 "[FIXTURE DEBUG] URI construction: uri_str='{}', query_string='{}'",
2066 uri_str,
2067 query_string
2068 );
2069
2070 if let Ok(uri) = uri_str.parse::<Uri>() {
2071 let http_method =
2072 Method::from_bytes(route.method.as_bytes()).unwrap_or(Method::GET);
2073
2074 let body_bytes =
2076 body.as_ref().and_then(|Json(b)| serde_json::to_vec(b).ok());
2077 let body_slice = body_bytes.as_deref();
2078
2079 let fingerprint =
2080 RequestFingerprint::new(http_method, &uri, &headers, body_slice);
2081
2082 tracing::info!(
2083 "[FIXTURE DEBUG] RequestFingerprint created: method='{}', path='{}', query='{}', body_hash={:?}",
2084 fingerprint.method,
2085 fingerprint.path,
2086 fingerprint.query,
2087 fingerprint.body_hash
2088 );
2089
2090 let available_fixtures = loader.has_fixture(&fingerprint);
2092 tracing::info!(
2093 "[FIXTURE DEBUG] Fixture check result: has_fixture={}",
2094 available_fixtures
2095 );
2096
2097 if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
2098 tracing::info!(
2099 "[FIXTURE DEBUG] ✅ FIXTURE MATCHED! Using custom fixture for {} {} (status: {}, path: '{}')",
2100 route.method,
2101 route.path,
2102 custom_fixture.status,
2103 custom_fixture.path
2104 );
2105
2106 if custom_fixture.delay_ms > 0 {
2108 tokio::time::sleep(tokio::time::Duration::from_millis(
2109 custom_fixture.delay_ms,
2110 ))
2111 .await;
2112 }
2113
2114 let response_body = if custom_fixture.response.is_string() {
2116 custom_fixture.response.as_str().unwrap().to_string()
2117 } else {
2118 serde_json::to_string(&custom_fixture.response)
2119 .unwrap_or_else(|_| "{}".to_string())
2120 };
2121
2122 let json_value: Value = serde_json::from_str(&response_body)
2124 .unwrap_or_else(|_| serde_json::json!({}));
2125
2126 let status =
2128 axum::http::StatusCode::from_u16(custom_fixture.status)
2129 .unwrap_or(axum::http::StatusCode::OK);
2130
2131 return (status, Json(json_value)).into_response();
2133 } else {
2134 tracing::warn!(
2135 "[FIXTURE DEBUG] ❌ No fixture match found for {} {} (fingerprint.path='{}', normalized='{}')",
2136 route.method,
2137 route.path,
2138 fingerprint.path,
2139 normalized_request_path
2140 );
2141 }
2142 } else {
2143 tracing::warn!("[FIXTURE DEBUG] Failed to parse URI: '{}'", uri_str);
2144 }
2145 } else {
2146 tracing::warn!(
2147 "[FIXTURE DEBUG] Custom fixture loader not available for {} {}",
2148 route.method,
2149 route.path
2150 );
2151 }
2152
2153 tracing::debug!(
2154 "Handling MockAI request for route: {} {}",
2155 route.method,
2156 route.path
2157 );
2158
2159 let mockai_query = query.0;
2161
2162 let method_upper = route.method.to_uppercase();
2167 let should_use_mockai =
2168 matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH" | "DELETE");
2169
2170 if should_use_mockai {
2171 if let Some(mockai_arc) = mockai {
2172 let mockai_guard = mockai_arc.read().await;
2173
2174 let mut mockai_headers = HashMap::new();
2176 for (k, v) in headers.iter() {
2177 mockai_headers
2178 .insert(k.to_string(), v.to_str().unwrap_or("").to_string());
2179 }
2180
2181 let mockai_request = MockAIRequest {
2182 method: route.method.clone(),
2183 path: route.path.clone(),
2184 body: body.as_ref().map(|Json(b)| b.clone()),
2185 query_params: mockai_query,
2186 headers: mockai_headers,
2187 };
2188
2189 match mockai_guard.process_request(&mockai_request).await {
2191 Ok(mockai_response) => {
2192 let is_empty = mockai_response.body.is_object()
2194 && mockai_response
2195 .body
2196 .as_object()
2197 .map(|obj| obj.is_empty())
2198 .unwrap_or(false);
2199
2200 if is_empty {
2201 tracing::debug!(
2202 "MockAI returned empty object for {} {}, falling back to OpenAPI response generation",
2203 route.method,
2204 route.path
2205 );
2206 } else {
2208 let spec_status = route.find_first_available_status_code();
2212 tracing::debug!(
2213 "MockAI generated response for {} {}, using spec status: {} (MockAI suggested: {})",
2214 route.method,
2215 route.path,
2216 spec_status,
2217 mockai_response.status_code
2218 );
2219 let status = axum::http::StatusCode::from_u16(spec_status)
2220 .unwrap_or(axum::http::StatusCode::OK);
2221 let mut resp =
2222 (status, Json(mockai_response.body)).into_response();
2223 inject_spec_response_headers(
2224 &mut resp,
2225 &route,
2226 spec_status,
2227 );
2228 return resp;
2229 }
2230 }
2231 Err(e) => {
2232 tracing::warn!(
2233 "MockAI processing failed for {} {}: {}, falling back to standard response",
2234 route.method,
2235 route.path,
2236 e
2237 );
2238 }
2240 }
2241 }
2242 } else {
2243 tracing::debug!(
2244 "Skipping MockAI for {} request {} - using OpenAPI response generation",
2245 method_upper,
2246 route.path
2247 );
2248 }
2249
2250 let status_override = headers
2252 .get("X-Mockforge-Response-Status")
2253 .and_then(|v| v.to_str().ok())
2254 .and_then(|s| s.parse::<u16>().ok());
2255
2256 let scenario = headers
2258 .get("X-Mockforge-Scenario")
2259 .and_then(|v| v.to_str().ok())
2260 .map(|s| s.to_string())
2261 .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
2262
2263 let (status, response) = route
2265 .mock_response_with_status_and_scenario_and_override(
2266 scenario.as_deref(),
2267 status_override,
2268 );
2269 let status_code = axum::http::StatusCode::from_u16(status)
2270 .unwrap_or(axum::http::StatusCode::OK);
2271 let mut resp = (status_code, Json(response)).into_response();
2272 inject_spec_response_headers(&mut resp, &route, status);
2273 resp
2274 }
2275 };
2276
2277 router = Self::route_for_method(router, axum_path, &route.method, handler);
2278 }
2279
2280 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
2283 }
2284}
2285
2286fn inject_spec_response_headers(
2299 response: &mut axum::response::Response,
2300 route: &crate::route::OpenApiRoute,
2301 status_code: u16,
2302) {
2303 let synthesized = route.mock_response_headers_for_status(status_code);
2304 if synthesized.is_empty() {
2305 return;
2306 }
2307 let response_headers = response.headers_mut();
2308 for (name, value) in synthesized {
2309 let Ok(header_name) = axum::http::HeaderName::from_bytes(name.as_bytes()) else {
2310 continue;
2311 };
2312 if response_headers.contains_key(&header_name) {
2313 continue;
2314 }
2315 let Ok(header_value) = axum::http::HeaderValue::from_str(&value) else {
2316 continue;
2317 };
2318 response_headers.insert(header_name, header_value);
2319 }
2320}
2321
2322async fn extract_multipart_from_bytes(
2327 body: &axum::body::Bytes,
2328 headers: &HeaderMap,
2329) -> Result<(HashMap<String, Value>, HashMap<String, String>)> {
2330 let boundary = headers
2332 .get(axum::http::header::CONTENT_TYPE)
2333 .and_then(|v| v.to_str().ok())
2334 .and_then(|ct| {
2335 ct.split(';').find_map(|part| {
2336 let part = part.trim();
2337 if part.starts_with("boundary=") {
2338 Some(part.strip_prefix("boundary=").unwrap_or("").trim_matches('"'))
2339 } else {
2340 None
2341 }
2342 })
2343 })
2344 .ok_or_else(|| Error::internal("Missing boundary in Content-Type header"))?;
2345
2346 let mut fields = HashMap::new();
2347 let mut files = HashMap::new();
2348
2349 let boundary_prefix = format!("--{}", boundary).into_bytes();
2352 let boundary_line = format!("\r\n--{}\r\n", boundary).into_bytes();
2353 let end_boundary = format!("\r\n--{}--\r\n", boundary).into_bytes();
2354
2355 let mut pos = 0;
2357 let mut parts = Vec::new();
2358
2359 if body.starts_with(&boundary_prefix) {
2361 if let Some(first_crlf) = body.iter().position(|&b| b == b'\r') {
2362 pos = first_crlf + 2; }
2364 }
2365
2366 while let Some(boundary_pos) = body[pos..]
2368 .windows(boundary_line.len())
2369 .position(|window| window == boundary_line.as_slice())
2370 {
2371 let actual_pos = pos + boundary_pos;
2372 if actual_pos > pos {
2373 parts.push((pos, actual_pos));
2374 }
2375 pos = actual_pos + boundary_line.len();
2376 }
2377
2378 if let Some(end_pos) = body[pos..]
2380 .windows(end_boundary.len())
2381 .position(|window| window == end_boundary.as_slice())
2382 {
2383 let actual_end = pos + end_pos;
2384 if actual_end > pos {
2385 parts.push((pos, actual_end));
2386 }
2387 } else if pos < body.len() {
2388 parts.push((pos, body.len()));
2390 }
2391
2392 for (start, end) in parts {
2394 let part_data = &body[start..end];
2395
2396 let separator = b"\r\n\r\n";
2398 if let Some(sep_pos) =
2399 part_data.windows(separator.len()).position(|window| window == separator)
2400 {
2401 let header_bytes = &part_data[..sep_pos];
2402 let body_start = sep_pos + separator.len();
2403 let body_data = &part_data[body_start..];
2404
2405 let header_str = String::from_utf8_lossy(header_bytes);
2407 let mut field_name = None;
2408 let mut filename = None;
2409
2410 for header_line in header_str.lines() {
2411 if header_line.starts_with("Content-Disposition:") {
2412 if let Some(name_start) = header_line.find("name=\"") {
2414 let name_start = name_start + 6;
2415 if let Some(name_end) = header_line[name_start..].find('"') {
2416 field_name =
2417 Some(header_line[name_start..name_start + name_end].to_string());
2418 }
2419 }
2420
2421 if let Some(file_start) = header_line.find("filename=\"") {
2423 let file_start = file_start + 10;
2424 if let Some(file_end) = header_line[file_start..].find('"') {
2425 filename =
2426 Some(header_line[file_start..file_start + file_end].to_string());
2427 }
2428 }
2429 }
2430 }
2431
2432 if let Some(name) = field_name {
2433 if let Some(file) = filename {
2434 let temp_dir = std::env::temp_dir().join("mockforge-uploads");
2436 std::fs::create_dir_all(&temp_dir)
2437 .map_err(|e| Error::io_with_context("temp directory", e.to_string()))?;
2438
2439 let file_path = temp_dir.join(format!("{}_{}", uuid::Uuid::new_v4(), file));
2440 std::fs::write(&file_path, body_data)
2441 .map_err(|e| Error::io_with_context("file", e.to_string()))?;
2442
2443 let file_path_str = file_path.to_string_lossy().to_string();
2444 files.insert(name.clone(), file_path_str.clone());
2445 fields.insert(name, Value::String(file_path_str));
2446 } else {
2447 let body_str = body_data
2450 .strip_suffix(b"\r\n")
2451 .or_else(|| body_data.strip_suffix(b"\n"))
2452 .unwrap_or(body_data);
2453
2454 if let Ok(field_value) = String::from_utf8(body_str.to_vec()) {
2455 fields.insert(name, Value::String(field_value.trim().to_string()));
2456 } else {
2457 use base64::{engine::general_purpose, Engine as _};
2459 fields.insert(
2460 name,
2461 Value::String(general_purpose::STANDARD.encode(body_str)),
2462 );
2463 }
2464 }
2465 }
2466 }
2467 }
2468
2469 Ok((fields, files))
2470}
2471
2472static LAST_ERRORS: Lazy<Mutex<VecDeque<Value>>> =
2473 Lazy::new(|| Mutex::new(VecDeque::with_capacity(20)));
2474
2475pub fn classify_validation_reason(reason: &str) -> String {
2483 let r = reason.to_ascii_lowercase();
2492
2493 if r.contains("method") && (r.contains("not allowed") || r.contains("unsupported")) {
2506 return "http-methods".into();
2507 }
2508
2509 let path_starts_with = |prefix: &str| r.contains(&format!("\"path\":\"{}", prefix));
2511 if path_starts_with("query.") {
2512 return "query".into();
2513 }
2514 if path_starts_with("header.") {
2515 return "headers".into();
2516 }
2517 if path_starts_with("cookie.") {
2518 return "cookies".into();
2519 }
2520 if path_starts_with("path.") {
2521 return "parameters".into();
2522 }
2523 if path_starts_with("body") {
2524 return "request-body".into();
2525 }
2526
2527 if r.contains("content-type") || r.contains("content type") {
2530 return "content-types".into();
2531 }
2532
2533 if r.contains("required")
2537 && (r.contains("param") || r.contains("query") || r.contains("header"))
2538 {
2539 return "parameters".into();
2540 }
2541 if r.contains("auth") || r.contains("security") {
2542 return "security".into();
2543 }
2544 if r.contains("method") {
2545 return "http-methods".into();
2546 }
2547 if r.contains("schema") || r.contains("body") || r.contains("json") {
2548 return "request-body".into();
2549 }
2550 if r.contains("enum") || r.contains("min") || r.contains("max") || r.contains("pattern") {
2551 return "constraints".into();
2552 }
2553 String::new()
2554}
2555
2556pub fn record_validation_error(v: &Value) {
2558 if let Ok(mut q) = LAST_ERRORS.lock() {
2559 if q.len() >= 20 {
2560 q.pop_front();
2561 }
2562 q.push_back(v.clone());
2563 }
2564 }
2566
2567pub fn get_last_validation_error() -> Option<Value> {
2569 LAST_ERRORS.lock().ok()?.back().cloned()
2570}
2571
2572pub fn get_validation_errors() -> Vec<Value> {
2574 LAST_ERRORS.lock().map(|q| q.iter().cloned().collect()).unwrap_or_default()
2575}
2576
2577fn coerce_value_for_schema(value: &Value, schema: &openapiv3::Schema) -> Value {
2582 match value {
2584 Value::String(s) => {
2585 if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2587 &schema.schema_kind
2588 {
2589 if s.contains(',') {
2590 let parts: Vec<&str> = s.split(',').map(|s| s.trim()).collect();
2592 let mut array_values = Vec::new();
2593
2594 for part in parts {
2595 if let Some(items_schema) = &array_type.items {
2597 if let Some(items_schema_obj) = items_schema.as_item() {
2598 let part_value = Value::String(part.to_string());
2599 let coerced_part =
2600 coerce_value_for_schema(&part_value, items_schema_obj);
2601 array_values.push(coerced_part);
2602 } else {
2603 array_values.push(Value::String(part.to_string()));
2605 }
2606 } else {
2607 array_values.push(Value::String(part.to_string()));
2609 }
2610 }
2611 return Value::Array(array_values);
2612 }
2613 }
2614
2615 match &schema.schema_kind {
2617 openapiv3::SchemaKind::Type(openapiv3::Type::String(_)) => {
2618 value.clone()
2620 }
2621 openapiv3::SchemaKind::Type(openapiv3::Type::Number(_)) => {
2622 if let Ok(n) = s.parse::<f64>() {
2624 if let Some(num) = serde_json::Number::from_f64(n) {
2625 return Value::Number(num);
2626 }
2627 }
2628 value.clone()
2629 }
2630 openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_)) => {
2631 if let Ok(n) = s.parse::<i64>() {
2633 if let Some(num) = serde_json::Number::from_f64(n as f64) {
2634 return Value::Number(num);
2635 }
2636 }
2637 value.clone()
2638 }
2639 openapiv3::SchemaKind::Type(openapiv3::Type::Boolean(_)) => {
2640 match s.to_lowercase().as_str() {
2642 "true" | "1" | "yes" | "on" => Value::Bool(true),
2643 "false" | "0" | "no" | "off" => Value::Bool(false),
2644 _ => value.clone(),
2645 }
2646 }
2647 _ => {
2648 value.clone()
2650 }
2651 }
2652 }
2653 _ => value.clone(),
2654 }
2655}
2656
2657fn coerce_by_style(value: &Value, schema: &openapiv3::Schema, style: Option<&str>) -> Value {
2659 match value {
2661 Value::String(s) => {
2662 if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2664 &schema.schema_kind
2665 {
2666 let delimiter = match style {
2667 Some("spaceDelimited") => " ",
2668 Some("pipeDelimited") => "|",
2669 Some("form") | None => ",", _ => ",", };
2672
2673 if s.contains(delimiter) {
2674 let parts: Vec<&str> = s.split(delimiter).map(|s| s.trim()).collect();
2676 let mut array_values = Vec::new();
2677
2678 for part in parts {
2679 if let Some(items_schema) = &array_type.items {
2681 if let Some(items_schema_obj) = items_schema.as_item() {
2682 let part_value = Value::String(part.to_string());
2683 let coerced_part =
2684 coerce_by_style(&part_value, items_schema_obj, style);
2685 array_values.push(coerced_part);
2686 } else {
2687 array_values.push(Value::String(part.to_string()));
2689 }
2690 } else {
2691 array_values.push(Value::String(part.to_string()));
2693 }
2694 }
2695 return Value::Array(array_values);
2696 }
2697 }
2698
2699 if let Ok(n) = s.parse::<f64>() {
2701 if let Some(num) = serde_json::Number::from_f64(n) {
2702 return Value::Number(num);
2703 }
2704 }
2705 match s.to_lowercase().as_str() {
2707 "true" | "1" | "yes" | "on" => return Value::Bool(true),
2708 "false" | "0" | "no" | "off" => return Value::Bool(false),
2709 _ => {}
2710 }
2711 value.clone()
2713 }
2714 _ => value.clone(),
2715 }
2716}
2717
2718fn build_deep_object(name: &str, params: &Map<String, Value>) -> Option<Value> {
2720 let prefix = format!("{}[", name);
2721 let mut obj = Map::new();
2722 for (k, v) in params.iter() {
2723 if let Some(rest) = k.strip_prefix(&prefix) {
2724 if let Some(key) = rest.strip_suffix(']') {
2725 obj.insert(key.to_string(), v.clone());
2726 }
2727 }
2728 }
2729 if obj.is_empty() {
2730 None
2731 } else {
2732 Some(Value::Object(obj))
2733 }
2734}
2735
2736#[allow(clippy::too_many_arguments)]
2742fn generate_enhanced_422_response(
2743 validator: &OpenApiRouteRegistry,
2744 path_template: &str,
2745 method: &str,
2746 body: Option<&Value>,
2747 path_params: &Map<String, Value>,
2748 query_params: &Map<String, Value>,
2749 header_params: &Map<String, Value>,
2750 cookie_params: &Map<String, Value>,
2751) -> Value {
2752 let mut field_errors = Vec::new();
2753
2754 if let Some(route) = validator.get_route(path_template, method) {
2756 if let Some(schema) = &route.operation.request_body {
2758 if let Some(value) = body {
2759 if let Some(content) =
2760 schema.as_item().and_then(|rb| rb.content.get("application/json"))
2761 {
2762 if let Some(_schema_ref) = &content.schema {
2763 if serde_json::from_value::<Value>(value.clone()).is_err() {
2765 field_errors.push(json!({
2766 "path": "body",
2767 "message": "invalid JSON"
2768 }));
2769 }
2770 }
2771 }
2772 } else {
2773 field_errors.push(json!({
2774 "path": "body",
2775 "expected": "object",
2776 "found": "missing",
2777 "message": "Request body is required but not provided"
2778 }));
2779 }
2780 }
2781
2782 for param_ref in &route.operation.parameters {
2784 if let Some(param) = param_ref.as_item() {
2785 match param {
2786 openapiv3::Parameter::Path { parameter_data, .. } => {
2787 validate_parameter_detailed(
2788 parameter_data,
2789 path_params,
2790 "path",
2791 "path parameter",
2792 &mut field_errors,
2793 );
2794 }
2795 openapiv3::Parameter::Query { parameter_data, .. } => {
2796 let deep_value = if Some("form") == Some("deepObject") {
2797 build_deep_object(¶meter_data.name, query_params)
2798 } else {
2799 None
2800 };
2801 validate_parameter_detailed_with_deep(
2802 parameter_data,
2803 query_params,
2804 "query",
2805 "query parameter",
2806 deep_value,
2807 &mut field_errors,
2808 );
2809 }
2810 openapiv3::Parameter::Header { parameter_data, .. } => {
2811 validate_parameter_detailed(
2812 parameter_data,
2813 header_params,
2814 "header",
2815 "header parameter",
2816 &mut field_errors,
2817 );
2818 }
2819 openapiv3::Parameter::Cookie { parameter_data, .. } => {
2820 validate_parameter_detailed(
2821 parameter_data,
2822 cookie_params,
2823 "cookie",
2824 "cookie parameter",
2825 &mut field_errors,
2826 );
2827 }
2828 }
2829 }
2830 }
2831 }
2832
2833 json!({
2835 "error": "Schema validation failed",
2836 "details": field_errors,
2837 "method": method,
2838 "path": path_template,
2839 "timestamp": Utc::now().to_rfc3339(),
2840 "validation_type": "openapi_schema"
2841 })
2842}
2843
2844fn validate_parameter(
2846 parameter_data: &openapiv3::ParameterData,
2847 params_map: &Map<String, Value>,
2848 prefix: &str,
2849 aggregate: bool,
2850 errors: &mut Vec<String>,
2851 details: &mut Vec<Value>,
2852) {
2853 match params_map.get(¶meter_data.name) {
2854 Some(v) => {
2855 if let ParameterSchemaOrContent::Schema(s) = ¶meter_data.format {
2856 if let Some(schema) = s.as_item() {
2857 let coerced = coerce_value_for_schema(v, schema);
2858 if let Err(validation_error) =
2860 OpenApiSchema::new(schema.clone()).validate(&coerced)
2861 {
2862 let error_msg = validation_error.to_string();
2863 errors.push(format!(
2864 "{} parameter '{}' validation failed: {}",
2865 prefix, parameter_data.name, error_msg
2866 ));
2867 if aggregate {
2868 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2869 }
2870 }
2871 }
2872 }
2873 }
2874 None => {
2875 if parameter_data.required {
2876 errors.push(format!(
2877 "missing required {} parameter '{}'",
2878 prefix, parameter_data.name
2879 ));
2880 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2881 }
2882 }
2883 }
2884}
2885
2886#[allow(clippy::too_many_arguments)]
2888fn validate_parameter_with_deep_object(
2889 parameter_data: &openapiv3::ParameterData,
2890 params_map: &Map<String, Value>,
2891 prefix: &str,
2892 deep_value: Option<Value>,
2893 style: Option<&str>,
2894 aggregate: bool,
2895 errors: &mut Vec<String>,
2896 details: &mut Vec<Value>,
2897) {
2898 match deep_value.as_ref().or_else(|| params_map.get(¶meter_data.name)) {
2899 Some(v) => {
2900 if let ParameterSchemaOrContent::Schema(s) = ¶meter_data.format {
2901 if let Some(schema) = s.as_item() {
2902 let coerced = coerce_by_style(v, schema, style); if let Err(validation_error) =
2905 OpenApiSchema::new(schema.clone()).validate(&coerced)
2906 {
2907 let error_msg = validation_error.to_string();
2908 errors.push(format!(
2909 "{} parameter '{}' validation failed: {}",
2910 prefix, parameter_data.name, error_msg
2911 ));
2912 if aggregate {
2913 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2914 }
2915 }
2916 }
2917 }
2918 }
2919 None => {
2920 if parameter_data.required {
2921 errors.push(format!(
2922 "missing required {} parameter '{}'",
2923 prefix, parameter_data.name
2924 ));
2925 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2926 }
2927 }
2928 }
2929}
2930
2931fn validate_parameter_detailed(
2933 parameter_data: &openapiv3::ParameterData,
2934 params_map: &Map<String, Value>,
2935 location: &str,
2936 value_type: &str,
2937 field_errors: &mut Vec<Value>,
2938) {
2939 match params_map.get(¶meter_data.name) {
2940 Some(value) => {
2941 if let ParameterSchemaOrContent::Schema(schema) = ¶meter_data.format {
2942 let details: Vec<Value> = Vec::new();
2944 let param_path = format!("{}.{}", location, parameter_data.name);
2945
2946 if let Some(schema_ref) = schema.as_item() {
2948 let coerced_value = coerce_value_for_schema(value, schema_ref);
2949 if let Err(validation_error) =
2951 OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2952 {
2953 field_errors.push(json!({
2954 "path": param_path,
2955 "expected": "valid according to schema",
2956 "found": coerced_value,
2957 "message": validation_error.to_string()
2958 }));
2959 }
2960 }
2961
2962 for detail in details {
2963 field_errors.push(json!({
2964 "path": detail["path"],
2965 "expected": detail["expected_type"],
2966 "found": detail["value"],
2967 "message": detail["message"]
2968 }));
2969 }
2970 }
2971 }
2972 None => {
2973 if parameter_data.required {
2974 field_errors.push(json!({
2975 "path": format!("{}.{}", location, parameter_data.name),
2976 "expected": "value",
2977 "found": "missing",
2978 "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
2979 }));
2980 }
2981 }
2982 }
2983}
2984
2985fn validate_parameter_detailed_with_deep(
2987 parameter_data: &openapiv3::ParameterData,
2988 params_map: &Map<String, Value>,
2989 location: &str,
2990 value_type: &str,
2991 deep_value: Option<Value>,
2992 field_errors: &mut Vec<Value>,
2993) {
2994 match deep_value.as_ref().or_else(|| params_map.get(¶meter_data.name)) {
2995 Some(value) => {
2996 if let ParameterSchemaOrContent::Schema(schema) = ¶meter_data.format {
2997 let details: Vec<Value> = Vec::new();
2999 let param_path = format!("{}.{}", location, parameter_data.name);
3000
3001 if let Some(schema_ref) = schema.as_item() {
3003 let coerced_value = coerce_by_style(value, schema_ref, Some("form")); if let Err(validation_error) =
3006 OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
3007 {
3008 field_errors.push(json!({
3009 "path": param_path,
3010 "expected": "valid according to schema",
3011 "found": coerced_value,
3012 "message": validation_error.to_string()
3013 }));
3014 }
3015 }
3016
3017 for detail in details {
3018 field_errors.push(json!({
3019 "path": detail["path"],
3020 "expected": detail["expected_type"],
3021 "found": detail["value"],
3022 "message": detail["message"]
3023 }));
3024 }
3025 }
3026 }
3027 None => {
3028 if parameter_data.required {
3029 field_errors.push(json!({
3030 "path": format!("{}.{}", location, parameter_data.name),
3031 "expected": "value",
3032 "found": "missing",
3033 "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
3034 }));
3035 }
3036 }
3037 }
3038}
3039
3040pub async fn create_registry_from_file<P: AsRef<std::path::Path>>(
3042 path: P,
3043) -> Result<OpenApiRouteRegistry> {
3044 let spec = OpenApiSpec::from_file(path).await?;
3045 spec.validate()?;
3046 Ok(OpenApiRouteRegistry::new(spec))
3047}
3048
3049pub fn create_registry_from_json(json: Value) -> Result<OpenApiRouteRegistry> {
3051 let spec = OpenApiSpec::from_json(json)?;
3052 spec.validate()?;
3053 Ok(OpenApiRouteRegistry::new(spec))
3054}
3055
3056#[cfg(test)]
3057mod tests {
3058 use super::*;
3059 use serde_json::json;
3060 use tempfile::TempDir;
3061
3062 #[test]
3071 fn classify_validation_reason_uses_structured_path_field_first() {
3072 let query_only = r#"{"details":[{"code":"schema_validation","message":"Validation error","path":"query.$.xgafv"}]}"#;
3075 assert_eq!(classify_validation_reason(query_only), "query");
3076
3077 let header_only = r#"{"details":[{"code":"schema_validation","message":"missing required X-Trace","path":"header.X-Trace"}]}"#;
3078 assert_eq!(classify_validation_reason(header_only), "headers");
3079
3080 let cookie_only = r#"{"details":[{"code":"schema_validation","message":"missing session","path":"cookie.session"}]}"#;
3081 assert_eq!(classify_validation_reason(cookie_only), "cookies");
3082
3083 let body_only = r#"{"details":[{"code":"schema_validation","message":"name required","path":"body.name"}]}"#;
3085 assert_eq!(classify_validation_reason(body_only), "request-body");
3086
3087 assert_eq!(
3089 classify_validation_reason("Content-Type application/xml not allowed"),
3090 "content-types"
3091 );
3092 }
3093
3094 #[tokio::test]
3095 async fn test_registry_creation() {
3096 let spec_json = json!({
3097 "openapi": "3.0.0",
3098 "info": {
3099 "title": "Test API",
3100 "version": "1.0.0"
3101 },
3102 "paths": {
3103 "/users": {
3104 "get": {
3105 "summary": "Get users",
3106 "responses": {
3107 "200": {
3108 "description": "Success",
3109 "content": {
3110 "application/json": {
3111 "schema": {
3112 "type": "array",
3113 "items": {
3114 "type": "object",
3115 "properties": {
3116 "id": {"type": "integer"},
3117 "name": {"type": "string"}
3118 }
3119 }
3120 }
3121 }
3122 }
3123 }
3124 }
3125 },
3126 "post": {
3127 "summary": "Create user",
3128 "requestBody": {
3129 "content": {
3130 "application/json": {
3131 "schema": {
3132 "type": "object",
3133 "properties": {
3134 "name": {"type": "string"}
3135 },
3136 "required": ["name"]
3137 }
3138 }
3139 }
3140 },
3141 "responses": {
3142 "201": {
3143 "description": "Created",
3144 "content": {
3145 "application/json": {
3146 "schema": {
3147 "type": "object",
3148 "properties": {
3149 "id": {"type": "integer"},
3150 "name": {"type": "string"}
3151 }
3152 }
3153 }
3154 }
3155 }
3156 }
3157 }
3158 },
3159 "/users/{id}": {
3160 "get": {
3161 "summary": "Get user by ID",
3162 "parameters": [
3163 {
3164 "name": "id",
3165 "in": "path",
3166 "required": true,
3167 "schema": {"type": "integer"}
3168 }
3169 ],
3170 "responses": {
3171 "200": {
3172 "description": "Success",
3173 "content": {
3174 "application/json": {
3175 "schema": {
3176 "type": "object",
3177 "properties": {
3178 "id": {"type": "integer"},
3179 "name": {"type": "string"}
3180 }
3181 }
3182 }
3183 }
3184 }
3185 }
3186 }
3187 }
3188 }
3189 });
3190
3191 let registry = create_registry_from_json(spec_json).unwrap();
3192
3193 assert_eq!(registry.paths().len(), 2);
3195 assert!(registry.paths().contains(&"/users".to_string()));
3196 assert!(registry.paths().contains(&"/users/{id}".to_string()));
3197
3198 assert_eq!(registry.methods().len(), 2);
3199 assert!(registry.methods().contains(&"GET".to_string()));
3200 assert!(registry.methods().contains(&"POST".to_string()));
3201
3202 let get_users_route = registry.get_route("/users", "GET").unwrap();
3204 assert_eq!(get_users_route.method, "GET");
3205 assert_eq!(get_users_route.path, "/users");
3206
3207 let post_users_route = registry.get_route("/users", "POST").unwrap();
3208 assert_eq!(post_users_route.method, "POST");
3209 assert!(post_users_route.operation.request_body.is_some());
3210
3211 let user_by_id_route = registry.get_route("/users/{id}", "GET").unwrap();
3213 assert_eq!(user_by_id_route.axum_path(), "/users/{id}");
3214 }
3215
3216 #[tokio::test]
3222 async fn check_request_content_type_flags_mismatch() {
3223 let spec_json = json!({
3224 "openapi": "3.0.0",
3225 "info": { "title": "T", "version": "1" },
3226 "paths": {
3227 "/api/appliance/access/consolecli": {
3228 "put": {
3229 "requestBody": {
3230 "required": true,
3231 "content": {
3232 "application/json": {
3233 "schema": {
3234 "type": "object",
3235 "required": ["enabled"],
3236 "properties": {"enabled": {"type": "boolean"}}
3237 }
3238 }
3239 }
3240 },
3241 "responses": { "204": { "description": "ok" } }
3242 }
3243 }
3244 }
3245 });
3246 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3247 let registry = OpenApiRouteRegistry::new(spec);
3248
3249 let r = registry.check_request_content_type(
3251 "/api/appliance/access/consolecli",
3252 "PUT",
3253 Some("application/xml"),
3254 );
3255 assert!(r.is_err(), "should flag application/xml: {:?}", r);
3256 let msg = r.unwrap_err();
3257 assert!(msg.contains("application/xml"), "{msg}");
3258 assert!(msg.contains("application/json"), "{msg}");
3259
3260 let r = registry.check_request_content_type(
3262 "/api/appliance/access/consolecli",
3263 "PUT",
3264 Some("application/json"),
3265 );
3266 assert!(r.is_ok(), "should accept application/json: {:?}", r);
3267
3268 let r = registry.check_request_content_type(
3270 "/api/appliance/access/consolecli",
3271 "PUT",
3272 Some("application/json; charset=utf-8"),
3273 );
3274 assert!(r.is_ok(), "should strip charset: {:?}", r);
3275
3276 let r = registry.check_request_content_type(
3278 "/api/appliance/access/consolecli",
3279 "GET",
3280 Some("application/xml"),
3281 );
3282 assert!(r.is_ok(), "GET has no requestBody on this op: {:?}", r);
3283
3284 let r =
3286 registry.check_request_content_type("/api/appliance/access/consolecli", "PUT", None);
3287 assert!(r.is_ok(), "no Content-Type → don't double-report: {:?}", r);
3288 }
3289
3290 #[tokio::test]
3291 async fn test_validate_request_with_params_and_formats() {
3292 let spec_json = json!({
3293 "openapi": "3.0.0",
3294 "info": { "title": "Test API", "version": "1.0.0" },
3295 "paths": {
3296 "/users/{id}": {
3297 "post": {
3298 "parameters": [
3299 { "name": "id", "in": "path", "required": true, "schema": {"type": "string"} },
3300 { "name": "q", "in": "query", "required": false, "schema": {"type": "integer"} }
3301 ],
3302 "requestBody": {
3303 "content": {
3304 "application/json": {
3305 "schema": {
3306 "type": "object",
3307 "required": ["email", "website"],
3308 "properties": {
3309 "email": {"type": "string", "format": "email"},
3310 "website": {"type": "string", "format": "uri"}
3311 }
3312 }
3313 }
3314 }
3315 },
3316 "responses": {"200": {"description": "ok"}}
3317 }
3318 }
3319 }
3320 });
3321
3322 let registry = create_registry_from_json(spec_json).unwrap();
3323 let mut path_params = Map::new();
3324 path_params.insert("id".to_string(), json!("abc"));
3325 let mut query_params = Map::new();
3326 query_params.insert("q".to_string(), json!(123));
3327
3328 let body = json!({"email":"a@b.co","website":"https://example.com"});
3330 assert!(registry
3331 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3332 .is_ok());
3333
3334 let bad_email = json!({"email":"not-an-email","website":"https://example.com"});
3336 assert!(registry
3337 .validate_request_with(
3338 "/users/{id}",
3339 "POST",
3340 &path_params,
3341 &query_params,
3342 Some(&bad_email)
3343 )
3344 .is_err());
3345
3346 let empty_path_params = Map::new();
3348 assert!(registry
3349 .validate_request_with(
3350 "/users/{id}",
3351 "POST",
3352 &empty_path_params,
3353 &query_params,
3354 Some(&body)
3355 )
3356 .is_err());
3357 }
3358
3359 #[tokio::test]
3360 async fn test_ref_resolution_for_params_and_body() {
3361 let spec_json = json!({
3362 "openapi": "3.0.0",
3363 "info": { "title": "Ref API", "version": "1.0.0" },
3364 "components": {
3365 "schemas": {
3366 "EmailWebsite": {
3367 "type": "object",
3368 "required": ["email", "website"],
3369 "properties": {
3370 "email": {"type": "string", "format": "email"},
3371 "website": {"type": "string", "format": "uri"}
3372 }
3373 }
3374 },
3375 "parameters": {
3376 "PathId": {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}},
3377 "QueryQ": {"name": "q", "in": "query", "required": false, "schema": {"type": "integer"}}
3378 },
3379 "requestBodies": {
3380 "CreateUser": {
3381 "content": {
3382 "application/json": {
3383 "schema": {"$ref": "#/components/schemas/EmailWebsite"}
3384 }
3385 }
3386 }
3387 }
3388 },
3389 "paths": {
3390 "/users/{id}": {
3391 "post": {
3392 "parameters": [
3393 {"$ref": "#/components/parameters/PathId"},
3394 {"$ref": "#/components/parameters/QueryQ"}
3395 ],
3396 "requestBody": {"$ref": "#/components/requestBodies/CreateUser"},
3397 "responses": {"200": {"description": "ok"}}
3398 }
3399 }
3400 }
3401 });
3402
3403 let registry = create_registry_from_json(spec_json).unwrap();
3404 let mut path_params = Map::new();
3405 path_params.insert("id".to_string(), json!("abc"));
3406 let mut query_params = Map::new();
3407 query_params.insert("q".to_string(), json!(7));
3408
3409 let body = json!({"email":"user@example.com","website":"https://example.com"});
3410 assert!(registry
3411 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3412 .is_ok());
3413
3414 let bad = json!({"email":"nope","website":"https://example.com"});
3415 assert!(registry
3416 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&bad))
3417 .is_err());
3418 }
3419
3420 #[tokio::test]
3421 async fn test_header_cookie_and_query_coercion() {
3422 let spec_json = json!({
3423 "openapi": "3.0.0",
3424 "info": { "title": "Params API", "version": "1.0.0" },
3425 "paths": {
3426 "/items": {
3427 "get": {
3428 "parameters": [
3429 {"name": "X-Flag", "in": "header", "required": true, "schema": {"type": "boolean"}},
3430 {"name": "session", "in": "cookie", "required": true, "schema": {"type": "string"}},
3431 {"name": "ids", "in": "query", "required": false, "schema": {"type": "array", "items": {"type": "integer"}}}
3432 ],
3433 "responses": {"200": {"description": "ok"}}
3434 }
3435 }
3436 }
3437 });
3438
3439 let registry = create_registry_from_json(spec_json).unwrap();
3440
3441 let path_params = Map::new();
3442 let mut query_params = Map::new();
3443 query_params.insert("ids".to_string(), json!("1,2,3"));
3445 let mut header_params = Map::new();
3446 header_params.insert("X-Flag".to_string(), json!("true"));
3447 let mut cookie_params = Map::new();
3448 cookie_params.insert("session".to_string(), json!("abc123"));
3449
3450 assert!(registry
3451 .validate_request_with_all(
3452 "/items",
3453 "GET",
3454 &path_params,
3455 &query_params,
3456 &header_params,
3457 &cookie_params,
3458 None
3459 )
3460 .is_ok());
3461
3462 let empty_cookie = Map::new();
3464 assert!(registry
3465 .validate_request_with_all(
3466 "/items",
3467 "GET",
3468 &path_params,
3469 &query_params,
3470 &header_params,
3471 &empty_cookie,
3472 None
3473 )
3474 .is_err());
3475
3476 let mut bad_header = Map::new();
3478 bad_header.insert("X-Flag".to_string(), json!("notabool"));
3479 assert!(registry
3480 .validate_request_with_all(
3481 "/items",
3482 "GET",
3483 &path_params,
3484 &query_params,
3485 &bad_header,
3486 &cookie_params,
3487 None
3488 )
3489 .is_err());
3490 }
3491
3492 #[tokio::test]
3493 async fn test_query_styles_space_pipe_deepobject() {
3494 let spec_json = json!({
3495 "openapi": "3.0.0",
3496 "info": { "title": "Query Styles API", "version": "1.0.0" },
3497 "paths": {"/search": {"get": {
3498 "parameters": [
3499 {"name":"tags","in":"query","style":"spaceDelimited","schema":{"type":"array","items":{"type":"string"}}},
3500 {"name":"ids","in":"query","style":"pipeDelimited","schema":{"type":"array","items":{"type":"integer"}}},
3501 {"name":"filter","in":"query","style":"deepObject","schema":{"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}}
3502 ],
3503 "responses": {"200": {"description":"ok"}}
3504 }} }
3505 });
3506
3507 let registry = create_registry_from_json(spec_json).unwrap();
3508
3509 let path_params = Map::new();
3510 let mut query = Map::new();
3511 query.insert("tags".into(), json!("alpha beta gamma"));
3512 query.insert("ids".into(), json!("1|2|3"));
3513 query.insert("filter[color]".into(), json!("red"));
3514
3515 assert!(registry
3516 .validate_request_with("/search", "GET", &path_params, &query, None)
3517 .is_ok());
3518 }
3519
3520 #[tokio::test]
3521 async fn test_oneof_anyof_allof_validation() {
3522 let spec_json = json!({
3523 "openapi": "3.0.0",
3524 "info": { "title": "Composite API", "version": "1.0.0" },
3525 "paths": {
3526 "/composite": {
3527 "post": {
3528 "requestBody": {
3529 "content": {
3530 "application/json": {
3531 "schema": {
3532 "allOf": [
3533 {"type": "object", "required": ["base"], "properties": {"base": {"type": "string"}}}
3534 ],
3535 "oneOf": [
3536 {"type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"], "not": {"required": ["b"]}},
3537 {"type": "object", "properties": {"b": {"type": "integer"}}, "required": ["b"], "not": {"required": ["a"]}}
3538 ],
3539 "anyOf": [
3540 {"type": "object", "properties": {"flag": {"type": "boolean"}}, "required": ["flag"]},
3541 {"type": "object", "properties": {"extra": {"type": "string"}}, "required": ["extra"]}
3542 ]
3543 }
3544 }
3545 }
3546 },
3547 "responses": {"200": {"description": "ok"}}
3548 }
3549 }
3550 }
3551 });
3552
3553 let registry = create_registry_from_json(spec_json).unwrap();
3554 let ok = json!({"base": "x", "a": 1, "flag": true});
3556 assert!(registry
3557 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&ok))
3558 .is_ok());
3559
3560 let bad_oneof = json!({"base": "x", "a": 1, "b": 2, "flag": false});
3562 assert!(registry
3563 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_oneof))
3564 .is_err());
3565
3566 let bad_anyof = json!({"base": "x", "a": 1});
3568 assert!(registry
3569 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_anyof))
3570 .is_err());
3571
3572 let bad_allof = json!({"a": 1, "flag": true});
3574 assert!(registry
3575 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_allof))
3576 .is_err());
3577 }
3578
3579 #[tokio::test]
3588 async fn dotted_schema_ref_resolves_in_route_validator() {
3589 let spec_json = json!({
3590 "openapi": "3.0.0",
3591 "info": { "title": "Dotted", "version": "1.0.0" },
3592 "paths": {
3593 "/x": {
3594 "post": {
3595 "requestBody": {
3596 "required": true,
3597 "content": {
3598 "application/json": {
3599 "schema": {
3600 "$ref": "#/components/schemas/Esx.Settings.Inventory.EntitySpec"
3601 }
3602 }
3603 }
3604 },
3605 "responses": {"200": {"description": "ok"}}
3606 }
3607 }
3608 },
3609 "components": {
3610 "schemas": {
3611 "Esx.Settings.Inventory.EntitySpec": {
3612 "type": "object",
3613 "required": ["type"],
3614 "properties": {"type": {"type": "string"}}
3615 }
3616 }
3617 }
3618 });
3619 let registry = create_registry_from_json(spec_json).unwrap();
3620 let good = json!({"type": "HOST"});
3623 let res =
3624 registry.validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&good));
3625 assert!(res.is_ok(), "valid body should pass; got {res:?}");
3626 let bad = json!({"unrelated": 1});
3628 let err = registry
3629 .validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&bad))
3630 .unwrap_err();
3631 let msg = format!("{err}");
3632 assert!(
3633 !msg.contains("Pointer") || !msg.contains("does not exist"),
3634 "should not be a pointer-resolution failure; got: {msg}"
3635 );
3636 }
3637
3638 #[tokio::test]
3639 async fn test_overrides_warn_mode_allows_invalid() {
3640 let spec_json = json!({
3642 "openapi": "3.0.0",
3643 "info": { "title": "Overrides API", "version": "1.0.0" },
3644 "paths": {"/things": {"post": {
3645 "parameters": [{"name":"q","in":"query","required":true,"schema":{"type":"integer"}}],
3646 "responses": {"200": {"description":"ok"}}
3647 }}}
3648 });
3649
3650 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3651 let mut overrides = HashMap::new();
3652 overrides.insert("POST /things".to_string(), ValidationMode::Warn);
3653 let registry = OpenApiRouteRegistry::new_with_options(
3654 spec,
3655 ValidationOptions {
3656 request_mode: ValidationMode::Enforce,
3657 aggregate_errors: true,
3658 validate_responses: false,
3659 overrides,
3660 admin_skip_prefixes: vec![],
3661 response_template_expand: false,
3662 validation_status: None,
3663 },
3664 );
3665
3666 let ok = registry.validate_request_with("/things", "POST", &Map::new(), &Map::new(), None);
3668 assert!(ok.is_ok());
3669 }
3670
3671 #[tokio::test]
3672 async fn test_admin_skip_prefix_short_circuit() {
3673 let spec_json = json!({
3674 "openapi": "3.0.0",
3675 "info": { "title": "Skip API", "version": "1.0.0" },
3676 "paths": {}
3677 });
3678 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3679 let registry = OpenApiRouteRegistry::new_with_options(
3680 spec,
3681 ValidationOptions {
3682 request_mode: ValidationMode::Enforce,
3683 aggregate_errors: true,
3684 validate_responses: false,
3685 overrides: HashMap::new(),
3686 admin_skip_prefixes: vec!["/admin".into()],
3687 response_template_expand: false,
3688 validation_status: None,
3689 },
3690 );
3691
3692 let res = registry.validate_request_with_all(
3694 "/admin/__mockforge/health",
3695 "GET",
3696 &Map::new(),
3697 &Map::new(),
3698 &Map::new(),
3699 &Map::new(),
3700 None,
3701 );
3702 assert!(res.is_ok());
3703 }
3704
3705 #[test]
3706 fn test_path_conversion() {
3707 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users"), "/users");
3708 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users/{id}"), "/users/{id}");
3709 assert_eq!(
3710 OpenApiRouteRegistry::convert_path_to_axum("/users/{id}/posts/{postId}"),
3711 "/users/{id}/posts/{postId}"
3712 );
3713 }
3714
3715 #[test]
3716 fn test_validation_options_default() {
3717 let options = ValidationOptions::default();
3718 assert!(matches!(options.request_mode, ValidationMode::Enforce));
3719 assert!(options.aggregate_errors);
3720 assert!(!options.validate_responses);
3721 assert!(options.overrides.is_empty());
3722 assert!(options.admin_skip_prefixes.is_empty());
3723 assert!(!options.response_template_expand);
3724 assert!(options.validation_status.is_none());
3725 }
3726
3727 #[test]
3728 fn test_validation_mode_variants() {
3729 let disabled = ValidationMode::Disabled;
3731 let warn = ValidationMode::Warn;
3732 let enforce = ValidationMode::Enforce;
3733 let default = ValidationMode::default();
3734
3735 assert!(matches!(default, ValidationMode::Warn));
3737
3738 assert!(!matches!(disabled, ValidationMode::Warn));
3740 assert!(!matches!(warn, ValidationMode::Enforce));
3741 assert!(!matches!(enforce, ValidationMode::Disabled));
3742 }
3743
3744 #[test]
3745 fn test_registry_spec_accessor() {
3746 let spec_json = json!({
3747 "openapi": "3.0.0",
3748 "info": {
3749 "title": "Test API",
3750 "version": "1.0.0"
3751 },
3752 "paths": {}
3753 });
3754 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3755 let registry = OpenApiRouteRegistry::new(spec.clone());
3756
3757 let accessed_spec = registry.spec();
3759 assert_eq!(accessed_spec.title(), "Test API");
3760 }
3761
3762 #[test]
3763 fn test_clone_for_validation() {
3764 let spec_json = json!({
3765 "openapi": "3.0.0",
3766 "info": {
3767 "title": "Test API",
3768 "version": "1.0.0"
3769 },
3770 "paths": {
3771 "/users": {
3772 "get": {
3773 "responses": {
3774 "200": {
3775 "description": "Success"
3776 }
3777 }
3778 }
3779 }
3780 }
3781 });
3782 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3783 let registry = OpenApiRouteRegistry::new(spec);
3784
3785 let cloned = registry.clone_for_validation();
3787 assert_eq!(cloned.routes().len(), registry.routes().len());
3788 assert_eq!(cloned.spec().title(), registry.spec().title());
3789 }
3790
3791 #[test]
3792 fn test_with_custom_fixture_loader() {
3793 let temp_dir = TempDir::new().unwrap();
3794 let spec_json = json!({
3795 "openapi": "3.0.0",
3796 "info": {
3797 "title": "Test API",
3798 "version": "1.0.0"
3799 },
3800 "paths": {}
3801 });
3802 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3803 let registry = OpenApiRouteRegistry::new(spec);
3804 let original_routes_len = registry.routes().len();
3805
3806 let custom_loader = Arc::new(crate::custom_fixture::CustomFixtureLoader::new(
3808 temp_dir.path().to_path_buf(),
3809 true,
3810 ));
3811 let registry_with_loader = registry.with_custom_fixture_loader(custom_loader);
3812
3813 assert_eq!(registry_with_loader.routes().len(), original_routes_len);
3815 }
3816
3817 #[test]
3818 fn test_get_route() {
3819 let spec_json = json!({
3820 "openapi": "3.0.0",
3821 "info": {
3822 "title": "Test API",
3823 "version": "1.0.0"
3824 },
3825 "paths": {
3826 "/users": {
3827 "get": {
3828 "operationId": "getUsers",
3829 "responses": {
3830 "200": {
3831 "description": "Success"
3832 }
3833 }
3834 },
3835 "post": {
3836 "operationId": "createUser",
3837 "responses": {
3838 "201": {
3839 "description": "Created"
3840 }
3841 }
3842 }
3843 }
3844 }
3845 });
3846 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3847 let registry = OpenApiRouteRegistry::new(spec);
3848
3849 let route = registry.get_route("/users", "GET");
3851 assert!(route.is_some());
3852 assert_eq!(route.unwrap().method, "GET");
3853 assert_eq!(route.unwrap().path, "/users");
3854
3855 let route = registry.get_route("/nonexistent", "GET");
3857 assert!(route.is_none());
3858
3859 let route = registry.get_route("/users", "POST");
3861 assert!(route.is_some());
3862 assert_eq!(route.unwrap().method, "POST");
3863 }
3864
3865 #[test]
3866 fn test_get_routes_for_path() {
3867 let spec_json = json!({
3868 "openapi": "3.0.0",
3869 "info": {
3870 "title": "Test API",
3871 "version": "1.0.0"
3872 },
3873 "paths": {
3874 "/users": {
3875 "get": {
3876 "responses": {
3877 "200": {
3878 "description": "Success"
3879 }
3880 }
3881 },
3882 "post": {
3883 "responses": {
3884 "201": {
3885 "description": "Created"
3886 }
3887 }
3888 },
3889 "put": {
3890 "responses": {
3891 "200": {
3892 "description": "Success"
3893 }
3894 }
3895 }
3896 },
3897 "/posts": {
3898 "get": {
3899 "responses": {
3900 "200": {
3901 "description": "Success"
3902 }
3903 }
3904 }
3905 }
3906 }
3907 });
3908 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3909 let registry = OpenApiRouteRegistry::new(spec);
3910
3911 let routes = registry.get_routes_for_path("/users");
3913 assert_eq!(routes.len(), 3);
3914 let methods: Vec<&str> = routes.iter().map(|r| r.method.as_str()).collect();
3915 assert!(methods.contains(&"GET"));
3916 assert!(methods.contains(&"POST"));
3917 assert!(methods.contains(&"PUT"));
3918
3919 let routes = registry.get_routes_for_path("/posts");
3921 assert_eq!(routes.len(), 1);
3922 assert_eq!(routes[0].method, "GET");
3923
3924 let routes = registry.get_routes_for_path("/nonexistent");
3926 assert!(routes.is_empty());
3927 }
3928
3929 #[test]
3930 fn test_new_vs_new_with_options() {
3931 let spec_json = json!({
3932 "openapi": "3.0.0",
3933 "info": {
3934 "title": "Test API",
3935 "version": "1.0.0"
3936 },
3937 "paths": {}
3938 });
3939 let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3940 let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3941
3942 let registry1 = OpenApiRouteRegistry::new(spec1);
3944 assert_eq!(registry1.spec().title(), "Test API");
3945
3946 let options = ValidationOptions {
3948 request_mode: ValidationMode::Disabled,
3949 aggregate_errors: false,
3950 validate_responses: true,
3951 overrides: HashMap::new(),
3952 admin_skip_prefixes: vec!["/admin".to_string()],
3953 response_template_expand: true,
3954 validation_status: Some(422),
3955 };
3956 let registry2 = OpenApiRouteRegistry::new_with_options(spec2, options);
3957 assert_eq!(registry2.spec().title(), "Test API");
3958 }
3959
3960 #[test]
3961 fn test_new_with_env_vs_new() {
3962 let spec_json = json!({
3963 "openapi": "3.0.0",
3964 "info": {
3965 "title": "Test API",
3966 "version": "1.0.0"
3967 },
3968 "paths": {}
3969 });
3970 let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3971 let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3972
3973 let registry1 = OpenApiRouteRegistry::new(spec1);
3975
3976 let registry2 = OpenApiRouteRegistry::new_with_env(spec2);
3978
3979 assert_eq!(registry1.spec().title(), "Test API");
3981 assert_eq!(registry2.spec().title(), "Test API");
3982 }
3983
3984 #[test]
3985 fn test_validation_options_custom() {
3986 let options = ValidationOptions {
3987 request_mode: ValidationMode::Warn,
3988 aggregate_errors: false,
3989 validate_responses: true,
3990 overrides: {
3991 let mut map = HashMap::new();
3992 map.insert("getUsers".to_string(), ValidationMode::Disabled);
3993 map
3994 },
3995 admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
3996 response_template_expand: true,
3997 validation_status: Some(422),
3998 };
3999
4000 assert!(matches!(options.request_mode, ValidationMode::Warn));
4001 assert!(!options.aggregate_errors);
4002 assert!(options.validate_responses);
4003 assert_eq!(options.overrides.len(), 1);
4004 assert_eq!(options.admin_skip_prefixes.len(), 2);
4005 assert!(options.response_template_expand);
4006 assert_eq!(options.validation_status, Some(422));
4007 }
4008
4009 #[test]
4010 fn test_validation_mode_default_standalone() {
4011 let mode = ValidationMode::default();
4012 assert!(matches!(mode, ValidationMode::Warn));
4013 }
4014
4015 #[test]
4016 fn test_validation_mode_clone() {
4017 let mode1 = ValidationMode::Enforce;
4018 let mode2 = mode1.clone();
4019 assert!(matches!(mode1, ValidationMode::Enforce));
4020 assert!(matches!(mode2, ValidationMode::Enforce));
4021 }
4022
4023 #[test]
4024 fn test_validation_mode_debug() {
4025 let mode = ValidationMode::Disabled;
4026 let debug_str = format!("{:?}", mode);
4027 assert!(debug_str.contains("Disabled") || debug_str.contains("ValidationMode"));
4028 }
4029
4030 #[test]
4031 fn test_validation_options_clone() {
4032 let options1 = ValidationOptions {
4033 request_mode: ValidationMode::Warn,
4034 aggregate_errors: true,
4035 validate_responses: false,
4036 overrides: HashMap::new(),
4037 admin_skip_prefixes: vec![],
4038 response_template_expand: false,
4039 validation_status: None,
4040 };
4041 let options2 = options1.clone();
4042 assert!(matches!(options2.request_mode, ValidationMode::Warn));
4043 assert_eq!(options1.aggregate_errors, options2.aggregate_errors);
4044 }
4045
4046 #[test]
4047 fn test_validation_options_debug() {
4048 let options = ValidationOptions::default();
4049 let debug_str = format!("{:?}", options);
4050 assert!(debug_str.contains("ValidationOptions"));
4051 }
4052
4053 #[test]
4054 fn test_validation_options_with_all_fields() {
4055 let mut overrides = HashMap::new();
4056 overrides.insert("op1".to_string(), ValidationMode::Disabled);
4057 overrides.insert("op2".to_string(), ValidationMode::Warn);
4058
4059 let options = ValidationOptions {
4060 request_mode: ValidationMode::Enforce,
4061 aggregate_errors: false,
4062 validate_responses: true,
4063 overrides: overrides.clone(),
4064 admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
4065 response_template_expand: true,
4066 validation_status: Some(422),
4067 };
4068
4069 assert!(matches!(options.request_mode, ValidationMode::Enforce));
4070 assert!(!options.aggregate_errors);
4071 assert!(options.validate_responses);
4072 assert_eq!(options.overrides.len(), 2);
4073 assert_eq!(options.admin_skip_prefixes.len(), 2);
4074 assert!(options.response_template_expand);
4075 assert_eq!(options.validation_status, Some(422));
4076 }
4077
4078 #[test]
4079 fn test_openapi_route_registry_clone() {
4080 let spec_json = json!({
4081 "openapi": "3.0.0",
4082 "info": { "title": "Test API", "version": "1.0.0" },
4083 "paths": {}
4084 });
4085 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4086 let registry1 = OpenApiRouteRegistry::new(spec);
4087 let registry2 = registry1.clone();
4088 assert_eq!(registry1.spec().title(), registry2.spec().title());
4089 }
4090
4091 #[test]
4092 fn test_validation_mode_serialization() {
4093 let mode = ValidationMode::Enforce;
4094 let json = serde_json::to_string(&mode).unwrap();
4095 assert!(json.contains("Enforce") || json.contains("enforce"));
4096 }
4097
4098 #[test]
4099 fn test_validation_mode_deserialization() {
4100 let json = r#""Disabled""#;
4101 let mode: ValidationMode = serde_json::from_str(json).unwrap();
4102 assert!(matches!(mode, ValidationMode::Disabled));
4103 }
4104
4105 #[test]
4106 fn test_validation_options_default_values() {
4107 let options = ValidationOptions::default();
4108 assert!(matches!(options.request_mode, ValidationMode::Enforce));
4109 assert!(options.aggregate_errors);
4110 assert!(!options.validate_responses);
4111 assert!(options.overrides.is_empty());
4112 assert!(options.admin_skip_prefixes.is_empty());
4113 assert!(!options.response_template_expand);
4114 assert_eq!(options.validation_status, None);
4115 }
4116
4117 #[test]
4118 fn test_validation_mode_all_variants() {
4119 let disabled = ValidationMode::Disabled;
4120 let warn = ValidationMode::Warn;
4121 let enforce = ValidationMode::Enforce;
4122
4123 assert!(matches!(disabled, ValidationMode::Disabled));
4124 assert!(matches!(warn, ValidationMode::Warn));
4125 assert!(matches!(enforce, ValidationMode::Enforce));
4126 }
4127
4128 #[test]
4129 fn test_validation_options_with_overrides() {
4130 let mut overrides = HashMap::new();
4131 overrides.insert("operation1".to_string(), ValidationMode::Disabled);
4132 overrides.insert("operation2".to_string(), ValidationMode::Warn);
4133
4134 let options = ValidationOptions {
4135 request_mode: ValidationMode::Enforce,
4136 aggregate_errors: true,
4137 validate_responses: false,
4138 overrides,
4139 admin_skip_prefixes: vec![],
4140 response_template_expand: false,
4141 validation_status: None,
4142 };
4143
4144 assert_eq!(options.overrides.len(), 2);
4145 assert!(matches!(options.overrides.get("operation1"), Some(ValidationMode::Disabled)));
4146 assert!(matches!(options.overrides.get("operation2"), Some(ValidationMode::Warn)));
4147 }
4148
4149 #[test]
4150 fn test_validation_options_with_admin_skip_prefixes() {
4151 let options = ValidationOptions {
4152 request_mode: ValidationMode::Enforce,
4153 aggregate_errors: true,
4154 validate_responses: false,
4155 overrides: HashMap::new(),
4156 admin_skip_prefixes: vec![
4157 "/admin".to_string(),
4158 "/internal".to_string(),
4159 "/debug".to_string(),
4160 ],
4161 response_template_expand: false,
4162 validation_status: None,
4163 };
4164
4165 assert_eq!(options.admin_skip_prefixes.len(), 3);
4166 assert!(options.admin_skip_prefixes.contains(&"/admin".to_string()));
4167 assert!(options.admin_skip_prefixes.contains(&"/internal".to_string()));
4168 assert!(options.admin_skip_prefixes.contains(&"/debug".to_string()));
4169 }
4170
4171 #[test]
4172 fn test_validation_options_with_validation_status() {
4173 let options1 = ValidationOptions {
4174 request_mode: ValidationMode::Enforce,
4175 aggregate_errors: true,
4176 validate_responses: false,
4177 overrides: HashMap::new(),
4178 admin_skip_prefixes: vec![],
4179 response_template_expand: false,
4180 validation_status: Some(400),
4181 };
4182
4183 let options2 = ValidationOptions {
4184 request_mode: ValidationMode::Enforce,
4185 aggregate_errors: true,
4186 validate_responses: false,
4187 overrides: HashMap::new(),
4188 admin_skip_prefixes: vec![],
4189 response_template_expand: false,
4190 validation_status: Some(422),
4191 };
4192
4193 assert_eq!(options1.validation_status, Some(400));
4194 assert_eq!(options2.validation_status, Some(422));
4195 }
4196
4197 #[test]
4198 fn test_validate_request_with_disabled_mode() {
4199 let spec_json = json!({
4201 "openapi": "3.0.0",
4202 "info": {"title": "Test API", "version": "1.0.0"},
4203 "paths": {
4204 "/users": {
4205 "get": {
4206 "responses": {"200": {"description": "OK"}}
4207 }
4208 }
4209 }
4210 });
4211 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4212 let options = ValidationOptions {
4213 request_mode: ValidationMode::Disabled,
4214 ..Default::default()
4215 };
4216 let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4217
4218 let result = registry.validate_request_with_all(
4220 "/users",
4221 "GET",
4222 &Map::new(),
4223 &Map::new(),
4224 &Map::new(),
4225 &Map::new(),
4226 None,
4227 );
4228 assert!(result.is_ok());
4229 }
4230
4231 #[test]
4232 fn test_validate_request_with_warn_mode() {
4233 let spec_json = json!({
4235 "openapi": "3.0.0",
4236 "info": {"title": "Test API", "version": "1.0.0"},
4237 "paths": {
4238 "/users": {
4239 "post": {
4240 "requestBody": {
4241 "required": true,
4242 "content": {
4243 "application/json": {
4244 "schema": {
4245 "type": "object",
4246 "required": ["name"],
4247 "properties": {
4248 "name": {"type": "string"}
4249 }
4250 }
4251 }
4252 }
4253 },
4254 "responses": {"200": {"description": "OK"}}
4255 }
4256 }
4257 }
4258 });
4259 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4260 let options = ValidationOptions {
4261 request_mode: ValidationMode::Warn,
4262 ..Default::default()
4263 };
4264 let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4265
4266 let result = registry.validate_request_with_all(
4268 "/users",
4269 "POST",
4270 &Map::new(),
4271 &Map::new(),
4272 &Map::new(),
4273 &Map::new(),
4274 None, );
4276 assert!(result.is_ok()); }
4278
4279 #[test]
4280 fn test_validate_request_body_validation_error() {
4281 let spec_json = json!({
4283 "openapi": "3.0.0",
4284 "info": {"title": "Test API", "version": "1.0.0"},
4285 "paths": {
4286 "/users": {
4287 "post": {
4288 "requestBody": {
4289 "required": true,
4290 "content": {
4291 "application/json": {
4292 "schema": {
4293 "type": "object",
4294 "required": ["name"],
4295 "properties": {
4296 "name": {"type": "string"}
4297 }
4298 }
4299 }
4300 }
4301 },
4302 "responses": {"200": {"description": "OK"}}
4303 }
4304 }
4305 }
4306 });
4307 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4308 let registry = OpenApiRouteRegistry::new(spec);
4309
4310 let result = registry.validate_request_with_all(
4312 "/users",
4313 "POST",
4314 &Map::new(),
4315 &Map::new(),
4316 &Map::new(),
4317 &Map::new(),
4318 None, );
4320 assert!(result.is_err());
4321 }
4322
4323 #[test]
4324 fn test_validate_request_body_schema_validation_error() {
4325 let spec_json = json!({
4327 "openapi": "3.0.0",
4328 "info": {"title": "Test API", "version": "1.0.0"},
4329 "paths": {
4330 "/users": {
4331 "post": {
4332 "requestBody": {
4333 "required": true,
4334 "content": {
4335 "application/json": {
4336 "schema": {
4337 "type": "object",
4338 "required": ["name"],
4339 "properties": {
4340 "name": {"type": "string"}
4341 }
4342 }
4343 }
4344 }
4345 },
4346 "responses": {"200": {"description": "OK"}}
4347 }
4348 }
4349 }
4350 });
4351 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4352 let registry = OpenApiRouteRegistry::new(spec);
4353
4354 let invalid_body = json!({}); let result = registry.validate_request_with_all(
4357 "/users",
4358 "POST",
4359 &Map::new(),
4360 &Map::new(),
4361 &Map::new(),
4362 &Map::new(),
4363 Some(&invalid_body),
4364 );
4365 assert!(result.is_err());
4366 }
4367
4368 #[test]
4369 fn test_validate_request_body_referenced_schema_error() {
4370 let spec_json = json!({
4372 "openapi": "3.0.0",
4373 "info": {"title": "Test API", "version": "1.0.0"},
4374 "paths": {
4375 "/users": {
4376 "post": {
4377 "requestBody": {
4378 "required": true,
4379 "content": {
4380 "application/json": {
4381 "schema": {
4382 "$ref": "#/components/schemas/NonExistentSchema"
4383 }
4384 }
4385 }
4386 },
4387 "responses": {"200": {"description": "OK"}}
4388 }
4389 }
4390 },
4391 "components": {}
4392 });
4393 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4394 let registry = OpenApiRouteRegistry::new(spec);
4395
4396 let body = json!({"name": "test"});
4398 let result = registry.validate_request_with_all(
4399 "/users",
4400 "POST",
4401 &Map::new(),
4402 &Map::new(),
4403 &Map::new(),
4404 &Map::new(),
4405 Some(&body),
4406 );
4407 assert!(result.is_err());
4408 }
4409
4410 #[test]
4411 fn test_validate_request_body_referenced_request_body_error() {
4412 let spec_json = json!({
4414 "openapi": "3.0.0",
4415 "info": {"title": "Test API", "version": "1.0.0"},
4416 "paths": {
4417 "/users": {
4418 "post": {
4419 "requestBody": {
4420 "$ref": "#/components/requestBodies/NonExistentRequestBody"
4421 },
4422 "responses": {"200": {"description": "OK"}}
4423 }
4424 }
4425 },
4426 "components": {}
4427 });
4428 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4429 let registry = OpenApiRouteRegistry::new(spec);
4430
4431 let body = json!({"name": "test"});
4433 let result = registry.validate_request_with_all(
4434 "/users",
4435 "POST",
4436 &Map::new(),
4437 &Map::new(),
4438 &Map::new(),
4439 &Map::new(),
4440 Some(&body),
4441 );
4442 assert!(result.is_err());
4443 }
4444
4445 #[test]
4446 fn test_validate_request_body_provided_when_not_expected() {
4447 let spec_json = json!({
4449 "openapi": "3.0.0",
4450 "info": {"title": "Test API", "version": "1.0.0"},
4451 "paths": {
4452 "/users": {
4453 "get": {
4454 "responses": {"200": {"description": "OK"}}
4455 }
4456 }
4457 }
4458 });
4459 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4460 let registry = OpenApiRouteRegistry::new(spec);
4461
4462 let body = json!({"extra": "data"});
4464 let result = registry.validate_request_with_all(
4465 "/users",
4466 "GET",
4467 &Map::new(),
4468 &Map::new(),
4469 &Map::new(),
4470 &Map::new(),
4471 Some(&body),
4472 );
4473 assert!(result.is_ok());
4475 }
4476
4477 #[test]
4478 fn test_get_operation() {
4479 let spec_json = json!({
4481 "openapi": "3.0.0",
4482 "info": {"title": "Test API", "version": "1.0.0"},
4483 "paths": {
4484 "/users": {
4485 "get": {
4486 "operationId": "getUsers",
4487 "summary": "Get users",
4488 "responses": {"200": {"description": "OK"}}
4489 }
4490 }
4491 }
4492 });
4493 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4494 let registry = OpenApiRouteRegistry::new(spec);
4495
4496 let operation = registry.get_operation("/users", "GET");
4498 assert!(operation.is_some());
4499 assert_eq!(operation.unwrap().method, "GET");
4500
4501 assert!(registry.get_operation("/nonexistent", "GET").is_none());
4503 }
4504
4505 #[test]
4506 fn test_extract_path_parameters() {
4507 let spec_json = json!({
4509 "openapi": "3.0.0",
4510 "info": {"title": "Test API", "version": "1.0.0"},
4511 "paths": {
4512 "/users/{id}": {
4513 "get": {
4514 "parameters": [
4515 {
4516 "name": "id",
4517 "in": "path",
4518 "required": true,
4519 "schema": {"type": "string"}
4520 }
4521 ],
4522 "responses": {"200": {"description": "OK"}}
4523 }
4524 }
4525 }
4526 });
4527 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4528 let registry = OpenApiRouteRegistry::new(spec);
4529
4530 let params = registry.extract_path_parameters("/users/123", "GET");
4532 assert_eq!(params.get("id"), Some(&"123".to_string()));
4533
4534 let empty_params = registry.extract_path_parameters("/users", "GET");
4536 assert!(empty_params.is_empty());
4537 }
4538
4539 #[test]
4540 fn extract_path_parameters_prefers_static_route_and_rejects_empty() {
4541 let spec_json = json!({
4544 "openapi": "3.0.0",
4545 "info": {"title": "Test API", "version": "1.0.0"},
4546 "paths": {
4547 "/users/{id}": { "get": { "responses": {"200": {"description": "OK"}} } },
4548 "/users/me": { "get": { "responses": {"200": {"description": "OK"}} } }
4549 }
4550 });
4551 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4552 let registry = OpenApiRouteRegistry::new(spec);
4553
4554 let me = registry.extract_path_parameters("/users/me", "GET");
4556 assert!(!me.contains_key("id"), "literal route should win, got {me:?}");
4557
4558 let by_id = registry.extract_path_parameters("/users/123", "GET");
4560 assert_eq!(by_id.get("id"), Some(&"123".to_string()));
4561
4562 let trailing = registry.extract_path_parameters("/users/", "GET");
4564 assert!(
4565 trailing.is_empty(),
4566 "empty trailing segment should not bind id, got {trailing:?}"
4567 );
4568 }
4569
4570 #[test]
4571 fn test_extract_path_parameters_multiple_params() {
4572 let spec_json = json!({
4574 "openapi": "3.0.0",
4575 "info": {"title": "Test API", "version": "1.0.0"},
4576 "paths": {
4577 "/users/{userId}/posts/{postId}": {
4578 "get": {
4579 "parameters": [
4580 {
4581 "name": "userId",
4582 "in": "path",
4583 "required": true,
4584 "schema": {"type": "string"}
4585 },
4586 {
4587 "name": "postId",
4588 "in": "path",
4589 "required": true,
4590 "schema": {"type": "string"}
4591 }
4592 ],
4593 "responses": {"200": {"description": "OK"}}
4594 }
4595 }
4596 }
4597 });
4598 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4599 let registry = OpenApiRouteRegistry::new(spec);
4600
4601 let params = registry.extract_path_parameters("/users/123/posts/456", "GET");
4603 assert_eq!(params.get("userId"), Some(&"123".to_string()));
4604 assert_eq!(params.get("postId"), Some(&"456".to_string()));
4605 }
4606
4607 #[test]
4608 fn test_validate_request_route_not_found() {
4609 let spec_json = json!({
4611 "openapi": "3.0.0",
4612 "info": {"title": "Test API", "version": "1.0.0"},
4613 "paths": {
4614 "/users": {
4615 "get": {
4616 "responses": {"200": {"description": "OK"}}
4617 }
4618 }
4619 }
4620 });
4621 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4622 let registry = OpenApiRouteRegistry::new(spec);
4623
4624 let result = registry.validate_request_with_all(
4626 "/nonexistent",
4627 "GET",
4628 &Map::new(),
4629 &Map::new(),
4630 &Map::new(),
4631 &Map::new(),
4632 None,
4633 );
4634 assert!(result.is_err());
4635 assert!(result.unwrap_err().to_string().contains("not found"));
4636 }
4637
4638 #[test]
4639 fn test_validate_request_with_path_parameters() {
4640 let spec_json = json!({
4642 "openapi": "3.0.0",
4643 "info": {"title": "Test API", "version": "1.0.0"},
4644 "paths": {
4645 "/users/{id}": {
4646 "get": {
4647 "parameters": [
4648 {
4649 "name": "id",
4650 "in": "path",
4651 "required": true,
4652 "schema": {"type": "string", "minLength": 1}
4653 }
4654 ],
4655 "responses": {"200": {"description": "OK"}}
4656 }
4657 }
4658 }
4659 });
4660 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4661 let registry = OpenApiRouteRegistry::new(spec);
4662
4663 let mut path_params = Map::new();
4665 path_params.insert("id".to_string(), json!("123"));
4666 let result = registry.validate_request_with_all(
4667 "/users/{id}",
4668 "GET",
4669 &path_params,
4670 &Map::new(),
4671 &Map::new(),
4672 &Map::new(),
4673 None,
4674 );
4675 assert!(result.is_ok());
4676 }
4677
4678 #[test]
4679 fn test_validate_request_with_query_parameters() {
4680 let spec_json = json!({
4682 "openapi": "3.0.0",
4683 "info": {"title": "Test API", "version": "1.0.0"},
4684 "paths": {
4685 "/users": {
4686 "get": {
4687 "parameters": [
4688 {
4689 "name": "page",
4690 "in": "query",
4691 "required": true,
4692 "schema": {"type": "integer", "minimum": 1}
4693 }
4694 ],
4695 "responses": {"200": {"description": "OK"}}
4696 }
4697 }
4698 }
4699 });
4700 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4701 let registry = OpenApiRouteRegistry::new(spec);
4702
4703 let mut query_params = Map::new();
4705 query_params.insert("page".to_string(), json!(1));
4706 let result = registry.validate_request_with_all(
4707 "/users",
4708 "GET",
4709 &Map::new(),
4710 &query_params,
4711 &Map::new(),
4712 &Map::new(),
4713 None,
4714 );
4715 assert!(result.is_ok());
4716 }
4717
4718 #[test]
4719 fn test_validate_request_with_header_parameters() {
4720 let spec_json = json!({
4722 "openapi": "3.0.0",
4723 "info": {"title": "Test API", "version": "1.0.0"},
4724 "paths": {
4725 "/users": {
4726 "get": {
4727 "parameters": [
4728 {
4729 "name": "X-API-Key",
4730 "in": "header",
4731 "required": true,
4732 "schema": {"type": "string"}
4733 }
4734 ],
4735 "responses": {"200": {"description": "OK"}}
4736 }
4737 }
4738 }
4739 });
4740 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4741 let registry = OpenApiRouteRegistry::new(spec);
4742
4743 let mut header_params = Map::new();
4745 header_params.insert("X-API-Key".to_string(), json!("secret-key"));
4746 let result = registry.validate_request_with_all(
4747 "/users",
4748 "GET",
4749 &Map::new(),
4750 &Map::new(),
4751 &header_params,
4752 &Map::new(),
4753 None,
4754 );
4755 assert!(result.is_ok());
4756 }
4757
4758 #[test]
4759 fn test_validate_request_with_cookie_parameters() {
4760 let spec_json = json!({
4762 "openapi": "3.0.0",
4763 "info": {"title": "Test API", "version": "1.0.0"},
4764 "paths": {
4765 "/users": {
4766 "get": {
4767 "parameters": [
4768 {
4769 "name": "sessionId",
4770 "in": "cookie",
4771 "required": true,
4772 "schema": {"type": "string"}
4773 }
4774 ],
4775 "responses": {"200": {"description": "OK"}}
4776 }
4777 }
4778 }
4779 });
4780 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4781 let registry = OpenApiRouteRegistry::new(spec);
4782
4783 let mut cookie_params = Map::new();
4785 cookie_params.insert("sessionId".to_string(), json!("abc123"));
4786 let result = registry.validate_request_with_all(
4787 "/users",
4788 "GET",
4789 &Map::new(),
4790 &Map::new(),
4791 &Map::new(),
4792 &cookie_params,
4793 None,
4794 );
4795 assert!(result.is_ok());
4796 }
4797
4798 #[test]
4799 fn test_validate_request_no_errors_early_return() {
4800 let spec_json = json!({
4802 "openapi": "3.0.0",
4803 "info": {"title": "Test API", "version": "1.0.0"},
4804 "paths": {
4805 "/users": {
4806 "get": {
4807 "responses": {"200": {"description": "OK"}}
4808 }
4809 }
4810 }
4811 });
4812 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4813 let registry = OpenApiRouteRegistry::new(spec);
4814
4815 let result = registry.validate_request_with_all(
4817 "/users",
4818 "GET",
4819 &Map::new(),
4820 &Map::new(),
4821 &Map::new(),
4822 &Map::new(),
4823 None,
4824 );
4825 assert!(result.is_ok());
4826 }
4827
4828 #[test]
4829 fn test_validate_request_query_parameter_different_styles() {
4830 let spec_json = json!({
4832 "openapi": "3.0.0",
4833 "info": {"title": "Test API", "version": "1.0.0"},
4834 "paths": {
4835 "/users": {
4836 "get": {
4837 "parameters": [
4838 {
4839 "name": "tags",
4840 "in": "query",
4841 "style": "pipeDelimited",
4842 "schema": {
4843 "type": "array",
4844 "items": {"type": "string"}
4845 }
4846 }
4847 ],
4848 "responses": {"200": {"description": "OK"}}
4849 }
4850 }
4851 }
4852 });
4853 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4854 let registry = OpenApiRouteRegistry::new(spec);
4855
4856 let mut query_params = Map::new();
4858 query_params.insert("tags".to_string(), json!(["tag1", "tag2"]));
4859 let result = registry.validate_request_with_all(
4860 "/users",
4861 "GET",
4862 &Map::new(),
4863 &query_params,
4864 &Map::new(),
4865 &Map::new(),
4866 None,
4867 );
4868 assert!(result.is_ok() || result.is_err()); }
4871}