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 },
741 );
742 let status = axum::http::StatusCode::from_u16(status_code)
743 .unwrap_or(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
744 let payload = serde_json::json!({
745 "error": "content_type_not_allowed",
746 "message": ct_err,
747 });
748 let body_bytes = serde_json::to_vec(&payload)
749 .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
750 return axum::response::Response::builder()
751 .status(status)
752 .header("content-type", "application/json")
753 .body(axum::body::Body::from(body_bytes))
754 .unwrap_or_else(|_| {
755 axum::response::Response::builder()
756 .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
757 .body(axum::body::Body::empty())
758 .unwrap()
759 });
760 }
761
762 if let Err(e) = validator.validate_request_with_all(
763 &path_template,
764 &method,
765 &path_map,
766 &query_map,
767 &header_map,
768 &cookie_map,
769 body_json.as_ref(),
770 ) {
771 let status_code =
773 validator.options.validation_status.unwrap_or_else(|| {
774 std::env::var("MOCKFORGE_VALIDATION_STATUS")
775 .ok()
776 .and_then(|s| s.parse::<u16>().ok())
777 .unwrap_or(400)
778 });
779
780 let payload = if status_code == 422 {
781 generate_enhanced_422_response(
783 &validator,
784 &path_template,
785 &method,
786 body_json.as_ref(),
787 &path_map,
788 &query_map,
789 &header_map,
790 &cookie_map,
791 )
792 } else {
793 let msg = format!("{}", e);
795 let detail_val = serde_json::from_str::<Value>(&msg)
796 .unwrap_or(serde_json::json!(msg));
797 json!({
798 "error": "request validation failed",
799 "detail": detail_val,
800 "method": method,
801 "path": path_template,
802 "timestamp": Utc::now().to_rfc3339(),
803 })
804 };
805
806 record_validation_error(&payload);
807
808 let reason = payload
815 .get("detail")
816 .and_then(|d| {
817 if d.is_string() {
818 d.as_str().map(|s| s.to_string())
819 } else {
820 serde_json::to_string(d).ok()
821 }
822 })
823 .unwrap_or_else(|| {
824 payload
825 .get("error")
826 .and_then(|v| v.as_str())
827 .unwrap_or("request validation failed")
828 .to_string()
829 });
830 let category = classify_validation_reason(&reason);
831 let (client_mockforge_version, client_sent_at) =
832 mockforge_foundation::conformance_violations::read_client_stamps(
833 |name| {
834 headers
835 .get(name)
836 .and_then(|v| v.to_str().ok())
837 .map(|s| s.to_string())
838 },
839 );
840 mockforge_foundation::conformance_violations::record(
841 mockforge_foundation::conformance_violations::ServerConformanceViolation {
842 timestamp: Utc::now(),
843 method: method.to_string(),
844 path: path_template.clone(),
845 client_ip: "unknown".to_string(),
846 status: status_code,
847 reason,
848 category,
849 occurrences: 1,
850 client_mockforge_version,
851 client_sent_at,
852 summary: String::new(),
853 },
854 );
855
856 let status = axum::http::StatusCode::from_u16(status_code)
857 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
858
859 let body_bytes = serde_json::to_vec(&payload)
861 .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
862
863 return axum::http::Response::builder()
864 .status(status)
865 .header(axum::http::header::CONTENT_TYPE, "application/json")
866 .body(axum::body::Body::from(body_bytes))
867 .expect("Response builder should create valid response with valid headers and body");
868 }
869 }
870
871 let mut final_response = mock_response.clone();
880 let env_expand: Option<bool> = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
881 .ok()
882 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"));
883 let expand = match env_expand {
884 Some(v) => v,
885 None => {
886 ctx.enable_template_expand || validator.options.response_template_expand
887 }
888 };
889 if expand {
890 if let Some(ref rewriter) = ctx.response_rewriter {
891 rewriter.expand_tokens(&mut final_response);
892 }
893 }
894
895 if ctx.overrides_enabled {
897 if let Some(ref rewriter) = ctx.response_rewriter {
898 let op_tags =
899 operation.operation_id.clone().map(|id| vec![id]).unwrap_or_default();
900 rewriter.apply_overrides(
901 &operation.operation_id.clone().unwrap_or_default(),
902 &op_tags,
903 &path_template,
904 &mut final_response,
905 );
906 }
907 }
908
909 if ctx.enable_full_validation {
911 if validator.options.validate_responses {
913 if let Some((status_code, _response)) = operation
915 .responses
916 .responses
917 .iter()
918 .filter_map(|(status, resp)| match status {
919 openapiv3::StatusCode::Code(code)
920 if *code >= 200 && *code < 300 =>
921 {
922 resp.as_item().map(|r| ((*code), r))
923 }
924 openapiv3::StatusCode::Range(range)
925 if *range >= 200 && *range < 300 =>
926 {
927 resp.as_item().map(|r| (200, r))
928 }
929 _ => None,
930 })
931 .next()
932 {
933 if serde_json::from_value::<Value>(final_response.clone()).is_err() {
935 tracing::warn!(
936 "Response validation failed: invalid JSON for status {}",
937 status_code
938 );
939 }
940 }
941 }
942
943 let mut trace = ResponseGenerationTrace::new();
945 trace.set_final_payload(final_response.clone());
946
947 if let Some((_status_code, response_ref)) = operation
949 .responses
950 .responses
951 .iter()
952 .filter_map(|(status, resp)| match status {
953 openapiv3::StatusCode::Code(code) if *code == selected_status => {
954 resp.as_item().map(|r| ((*code), r))
955 }
956 openapiv3::StatusCode::Range(range)
957 if *range >= 200 && *range < 300 =>
958 {
959 resp.as_item().map(|r| (200, r))
960 }
961 _ => None,
962 })
963 .next()
964 .or_else(|| {
965 operation
967 .responses
968 .responses
969 .iter()
970 .filter_map(|(status, resp)| match status {
971 openapiv3::StatusCode::Code(code)
972 if *code >= 200 && *code < 300 =>
973 {
974 resp.as_item().map(|r| ((*code), r))
975 }
976 _ => None,
977 })
978 .next()
979 })
980 {
981 let response_item = response_ref;
983 if let Some(content) = response_item.content.get("application/json") {
985 if let Some(schema_ref) = &content.schema {
986 if let Some(schema) = schema_ref.as_item() {
988 if let Ok(schema_json) = serde_json::to_value(schema) {
989 let validation_errors =
991 validation_diff(&schema_json, &final_response);
992 trace.set_schema_validation_diff(validation_errors);
993 }
994 }
995 }
996 }
997 }
998
999 let mut response = Json(final_response).into_response();
1001 response.extensions_mut().insert(trace);
1002 *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
1003 .unwrap_or(axum::http::StatusCode::OK);
1004 inject_spec_response_headers(&mut response, &route_clone, selected_status);
1005 return response;
1006 }
1007
1008 let mut response = Json(final_response).into_response();
1010 *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
1011 .unwrap_or(axum::http::StatusCode::OK);
1012 inject_spec_response_headers(&mut response, &route_clone, selected_status);
1013 response
1014 };
1015
1016 router = Self::route_for_method(router, axum_path, &route.method, handler);
1017 }
1018
1019 if ctx.add_spec_endpoint {
1021 let spec_json = serde_json::to_value(&self.spec.spec).unwrap_or(Value::Null);
1022 router = router.route("/openapi.json", get(move || async move { Json(spec_json) }));
1023 }
1024
1025 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
1029 }
1030
1031 pub fn build_router_with_latency(self, latency_injector: LatencyInjector) -> Router {
1033 self.build_router_with_injectors(latency_injector, None)
1034 }
1035
1036 pub fn build_router_with_injectors(
1038 self,
1039 latency_injector: LatencyInjector,
1040 failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1041 ) -> Router {
1042 self.build_router_with_injectors_and_overrides(
1043 latency_injector,
1044 failure_injector,
1045 None,
1046 false,
1047 )
1048 }
1049
1050 pub fn build_router_with_injectors_and_overrides(
1054 self,
1055 latency_injector: LatencyInjector,
1056 failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1057 response_rewriter: Option<Arc<dyn ResponseRewriter>>,
1058 overrides_enabled: bool,
1059 ) -> Router {
1060 let ctx = RouterContext {
1061 custom_fixture_loader: self.custom_fixture_loader.clone(),
1062 latency_injector: Some(latency_injector),
1063 failure_injector,
1064 response_rewriter,
1065 overrides_enabled,
1066 enable_full_validation: true,
1067 enable_template_expand: true,
1068 add_spec_endpoint: true,
1069 ..Default::default()
1070 };
1071 self.build_router_with_context(ctx)
1072 }
1073
1074 pub fn get_route(&self, path: &str, method: &str) -> Option<&OpenApiRoute> {
1076 self.routes.iter().find(|route| route.path == path && route.method == method)
1077 }
1078
1079 pub fn get_routes_for_path(&self, path: &str) -> Vec<&OpenApiRoute> {
1081 self.routes.iter().filter(|route| route.path == path).collect()
1082 }
1083
1084 pub fn validate_request(&self, path: &str, method: &str, body: Option<&Value>) -> Result<()> {
1086 self.validate_request_with(path, method, &Map::new(), &Map::new(), body)
1087 }
1088
1089 pub fn check_request_content_type(
1105 &self,
1106 path: &str,
1107 method: &str,
1108 actual_content_type: Option<&str>,
1109 ) -> std::result::Result<(), String> {
1110 let Some(route) = self.get_route(path, method) else {
1111 return Ok(());
1112 };
1113 let Some(rb_ref) = &route.operation.request_body else {
1114 return Ok(());
1115 };
1116 let request_body = match rb_ref {
1117 openapiv3::ReferenceOr::Item(rb) => rb,
1118 openapiv3::ReferenceOr::Reference { reference } => {
1119 let resolved = self
1120 .spec
1121 .spec
1122 .components
1123 .as_ref()
1124 .and_then(|components| {
1125 components
1126 .request_bodies
1127 .get(reference.trim_start_matches("#/components/requestBodies/"))
1128 })
1129 .and_then(|rb_ref| rb_ref.as_item());
1130 let Some(rb) = resolved else { return Ok(()) };
1131 rb
1132 }
1133 };
1134 if request_body.content.is_empty() {
1135 return Ok(());
1136 }
1137 let actual = actual_content_type
1138 .and_then(|s| s.split(';').next())
1139 .map(|s| s.trim().to_ascii_lowercase());
1140 let Some(actual) = actual else {
1141 return Ok(());
1146 };
1147 let allowed: Vec<String> = request_body
1148 .content
1149 .keys()
1150 .map(|k| k.split(';').next().unwrap_or(k).trim().to_ascii_lowercase())
1151 .collect();
1152 if allowed.iter().any(|a| a == &actual) {
1153 return Ok(());
1154 }
1155 Err(format!(
1156 "Content-Type '{actual}' not allowed; spec declares: [{}]",
1157 allowed.join(", ")
1158 ))
1159 }
1160
1161 pub fn validate_request_with(
1163 &self,
1164 path: &str,
1165 method: &str,
1166 path_params: &Map<String, Value>,
1167 query_params: &Map<String, Value>,
1168 body: Option<&Value>,
1169 ) -> Result<()> {
1170 self.validate_request_with_all(
1171 path,
1172 method,
1173 path_params,
1174 query_params,
1175 &Map::new(),
1176 &Map::new(),
1177 body,
1178 )
1179 }
1180
1181 #[allow(clippy::too_many_arguments)]
1194 pub fn run_validation_with_recording(
1195 &self,
1196 path_template: &str,
1197 method: &str,
1198 path_params: &Map<String, Value>,
1199 query_params: &Map<String, Value>,
1200 header_map: &Map<String, Value>,
1201 cookie_map: &Map<String, Value>,
1202 body: Option<&Value>,
1203 ) -> std::result::Result<(), (u16, Value)> {
1204 let e = match self.validate_request_with_all(
1205 path_template,
1206 method,
1207 path_params,
1208 query_params,
1209 header_map,
1210 cookie_map,
1211 body,
1212 ) {
1213 Ok(()) => {
1214 mockforge_foundation::conformance_violations::record_ok();
1218 return Ok(());
1219 }
1220 Err(e) => e,
1221 };
1222
1223 let status_code = self.options.validation_status.unwrap_or_else(|| {
1224 std::env::var("MOCKFORGE_VALIDATION_STATUS")
1225 .ok()
1226 .and_then(|s| s.parse::<u16>().ok())
1227 .unwrap_or(400)
1228 });
1229
1230 let payload = if status_code == 422 {
1231 generate_enhanced_422_response(
1232 self,
1233 path_template,
1234 method,
1235 body,
1236 path_params,
1237 query_params,
1238 header_map,
1239 cookie_map,
1240 )
1241 } else {
1242 let msg = format!("{}", e);
1243 let detail_val = serde_json::from_str::<Value>(&msg).unwrap_or(serde_json::json!(msg));
1244 json!({
1245 "error": "request validation failed",
1246 "detail": detail_val,
1247 "method": method,
1248 "path": path_template,
1249 "timestamp": Utc::now().to_rfc3339(),
1250 })
1251 };
1252
1253 record_validation_error(&payload);
1254
1255 let reason = payload
1256 .get("detail")
1257 .and_then(|d| {
1258 if d.is_string() {
1259 d.as_str().map(|s| s.to_string())
1260 } else {
1261 serde_json::to_string(d).ok()
1262 }
1263 })
1264 .unwrap_or_else(|| {
1265 payload
1266 .get("error")
1267 .and_then(|v| v.as_str())
1268 .unwrap_or("request validation failed")
1269 .to_string()
1270 });
1271 let category = classify_validation_reason(&reason);
1272 tracing::debug!(
1280 target: "mockforge::conformance",
1281 method = %method,
1282 path = %path_template,
1283 status = status_code,
1284 category = %category,
1285 reason = %reason,
1286 "request conformance violation"
1287 );
1288 let (client_mockforge_version, client_sent_at) =
1289 mockforge_foundation::conformance_violations::read_client_stamps(|name| {
1290 header_map
1291 .iter()
1292 .find(|(k, _)| k.eq_ignore_ascii_case(name))
1293 .and_then(|(_, v)| v.as_str().map(|s| s.to_string()))
1294 });
1295 mockforge_foundation::conformance_violations::record(
1296 mockforge_foundation::conformance_violations::ServerConformanceViolation {
1297 timestamp: Utc::now(),
1298 method: method.to_string(),
1299 path: path_template.to_string(),
1300 client_ip: "unknown".to_string(),
1301 status: status_code,
1302 reason,
1303 category,
1304 occurrences: 1,
1305 client_mockforge_version,
1306 client_sent_at,
1307 summary: String::new(),
1308 },
1309 );
1310
1311 if mockforge_foundation::unknown_paths::shadow_mode_enabled() {
1318 return Ok(());
1319 }
1320
1321 Err((status_code, payload))
1322 }
1323
1324 #[allow(clippy::too_many_arguments)]
1326 pub fn validate_request_with_all(
1327 &self,
1328 path: &str,
1329 method: &str,
1330 path_params: &Map<String, Value>,
1331 query_params: &Map<String, Value>,
1332 header_params: &Map<String, Value>,
1333 cookie_params: &Map<String, Value>,
1334 body: Option<&Value>,
1335 ) -> Result<()> {
1336 for pref in &self.options.admin_skip_prefixes {
1338 if !pref.is_empty() && path.starts_with(pref) {
1339 return Ok(());
1340 }
1341 }
1342 let env_mode = std::env::var("MOCKFORGE_REQUEST_VALIDATION").ok().map(|v| {
1344 match v.to_ascii_lowercase().as_str() {
1345 "off" | "disable" | "disabled" => ValidationMode::Disabled,
1346 "warn" | "warning" => ValidationMode::Warn,
1347 _ => ValidationMode::Enforce,
1348 }
1349 });
1350 let aggregate = std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
1351 .ok()
1352 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1353 .unwrap_or(self.options.aggregate_errors);
1354 let env_overrides: Option<Map<String, Value>> =
1356 std::env::var("MOCKFORGE_VALIDATION_OVERRIDES_JSON")
1357 .ok()
1358 .and_then(|s| serde_json::from_str::<Value>(&s).ok())
1359 .and_then(|v| v.as_object().cloned());
1360 let mut effective_mode = env_mode.unwrap_or(self.options.request_mode.clone());
1362 if let Some(map) = &env_overrides {
1364 if let Some(v) = map.get(&format!("{} {}", method, path)) {
1365 if let Some(m) = v.as_str() {
1366 effective_mode = match m {
1367 "off" => ValidationMode::Disabled,
1368 "warn" => ValidationMode::Warn,
1369 _ => ValidationMode::Enforce,
1370 };
1371 }
1372 }
1373 }
1374 if let Some(override_mode) = self.options.overrides.get(&format!("{} {}", method, path)) {
1376 effective_mode = override_mode.clone();
1377 }
1378 if matches!(effective_mode, ValidationMode::Disabled) {
1379 return Ok(());
1380 }
1381 if let Some(route) = self.get_route(path, method) {
1382 if matches!(effective_mode, ValidationMode::Disabled) {
1383 return Ok(());
1384 }
1385 let mut errors: Vec<String> = Vec::new();
1386 let mut details: Vec<Value> = Vec::new();
1387 if let Some(schema) = &route.operation.request_body {
1389 if let Some(value) = body {
1390 let request_body = match schema {
1392 openapiv3::ReferenceOr::Item(rb) => Some(rb),
1393 openapiv3::ReferenceOr::Reference { reference } => {
1394 self.spec
1396 .spec
1397 .components
1398 .as_ref()
1399 .and_then(|components| {
1400 components.request_bodies.get(
1401 reference.trim_start_matches("#/components/requestBodies/"),
1402 )
1403 })
1404 .and_then(|rb_ref| rb_ref.as_item())
1405 }
1406 };
1407
1408 if let Some(rb) = request_body {
1409 if let Some(content) = rb.content.get("application/json") {
1410 if let Some(schema_ref) = &content.schema {
1411 let root_schema = match schema_ref {
1424 openapiv3::ReferenceOr::Item(s) => Some((*s).clone()),
1425 openapiv3::ReferenceOr::Reference { reference } => {
1426 self.spec.get_schema(reference).map(|s| s.schema.clone())
1427 }
1428 };
1429 if let Some(root_schema) = root_schema {
1430 let result = crate::schema_ref_resolver::build_validator(
1431 &root_schema,
1432 &self.spec.spec,
1433 )
1434 .and_then(|validator| {
1435 let errs: Vec<String> = validator
1436 .iter_errors(value)
1437 .map(|e| e.to_string())
1438 .collect();
1439 if errs.is_empty() {
1440 Ok(())
1441 } else {
1442 Err(errs.join("; "))
1443 }
1444 });
1445 if let Err(error_msg) = result {
1446 errors
1447 .push(format!("body validation failed: {}", error_msg));
1448 if aggregate {
1449 details.push(serde_json::json!({"path":"body","code":"schema_validation","message":error_msg}));
1450 }
1451 }
1452 } else if let openapiv3::ReferenceOr::Reference { reference } =
1453 schema_ref
1454 {
1455 errors.push(format!("body validation failed: could not resolve schema reference {}", reference));
1457 if aggregate {
1458 details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve schema reference"}));
1459 }
1460 }
1461 }
1462 }
1463 } else {
1464 errors.push("body validation failed: could not resolve request body or no application/json content".to_string());
1466 if aggregate {
1467 details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve request body reference"}));
1468 }
1469 }
1470 } else {
1471 errors.push("body: Request body is required but not provided".to_string());
1472 details.push(serde_json::json!({"path":"body","code":"required","message":"Request body is required"}));
1473 }
1474 } else if body.is_some() {
1475 tracing::debug!("Body provided for operation without requestBody; accepting");
1477 }
1478
1479 for p_ref in &route.operation.parameters {
1481 if let Some(p) = p_ref.as_item() {
1482 match p {
1483 openapiv3::Parameter::Path { parameter_data, .. } => {
1484 validate_parameter(
1485 parameter_data,
1486 path_params,
1487 "path",
1488 aggregate,
1489 &mut errors,
1490 &mut details,
1491 );
1492 }
1493 openapiv3::Parameter::Query {
1494 parameter_data,
1495 style,
1496 ..
1497 } => {
1498 let deep_value = if matches!(style, openapiv3::QueryStyle::DeepObject) {
1501 let prefix_bracket = format!("{}[", parameter_data.name);
1502 let mut obj = Map::new();
1503 for (key, val) in query_params.iter() {
1504 if let Some(rest) = key.strip_prefix(&prefix_bracket) {
1505 if let Some(prop) = rest.strip_suffix(']') {
1506 obj.insert(prop.to_string(), val.clone());
1507 }
1508 }
1509 }
1510 if obj.is_empty() {
1511 None
1512 } else {
1513 Some(Value::Object(obj))
1514 }
1515 } else {
1516 None
1517 };
1518 let style_str = match style {
1519 openapiv3::QueryStyle::Form => Some("form"),
1520 openapiv3::QueryStyle::SpaceDelimited => Some("spaceDelimited"),
1521 openapiv3::QueryStyle::PipeDelimited => Some("pipeDelimited"),
1522 openapiv3::QueryStyle::DeepObject => Some("deepObject"),
1523 };
1524 validate_parameter_with_deep_object(
1525 parameter_data,
1526 query_params,
1527 "query",
1528 deep_value,
1529 style_str,
1530 aggregate,
1531 &mut errors,
1532 &mut details,
1533 );
1534 }
1535 openapiv3::Parameter::Header { parameter_data, .. } => {
1536 validate_parameter(
1537 parameter_data,
1538 header_params,
1539 "header",
1540 aggregate,
1541 &mut errors,
1542 &mut details,
1543 );
1544 }
1545 openapiv3::Parameter::Cookie { parameter_data, .. } => {
1546 validate_parameter(
1547 parameter_data,
1548 cookie_params,
1549 "cookie",
1550 aggregate,
1551 &mut errors,
1552 &mut details,
1553 );
1554 }
1555 }
1556 }
1557 }
1558 if errors.is_empty() {
1559 return Ok(());
1560 }
1561 match effective_mode {
1562 ValidationMode::Disabled => Ok(()),
1563 ValidationMode::Warn => {
1564 tracing::warn!("Request validation warnings: {:?}", errors);
1565 Ok(())
1566 }
1567 ValidationMode::Enforce => Err(Error::validation(
1568 serde_json::json!({"errors": errors, "details": details}).to_string(),
1569 )),
1570 }
1571 } else {
1572 Err(Error::internal(format!("Route {} {} not found in OpenAPI spec", method, path)))
1573 }
1574 }
1575
1576 pub fn paths(&self) -> Vec<String> {
1580 let mut paths: Vec<String> = self.routes.iter().map(|route| route.path.clone()).collect();
1581 paths.sort();
1582 paths.dedup();
1583 paths
1584 }
1585
1586 pub fn methods(&self) -> Vec<String> {
1588 let mut methods: Vec<String> =
1589 self.routes.iter().map(|route| route.method.clone()).collect();
1590 methods.sort();
1591 methods.dedup();
1592 methods
1593 }
1594
1595 pub fn get_operation(&self, path: &str, method: &str) -> Option<OpenApiOperation> {
1597 self.get_route(path, method).map(|route| {
1598 OpenApiOperation::from_operation(
1599 &route.method,
1600 route.path.clone(),
1601 &route.operation,
1602 &self.spec,
1603 )
1604 })
1605 }
1606
1607 pub fn extract_path_parameters(&self, path: &str, method: &str) -> HashMap<String, String> {
1609 let mut best: Option<(usize, HashMap<String, String>)> = None;
1615 for route in &self.routes {
1616 if route.method != method {
1617 continue;
1618 }
1619
1620 if let Some(params) = self.match_path_to_route(path, &route.path) {
1621 let static_segments = route
1622 .path
1623 .trim_start_matches('/')
1624 .split('/')
1625 .filter(|s| !(s.starts_with('{') && s.ends_with('}')))
1626 .count();
1627 let is_more_specific = match &best {
1628 None => true,
1629 Some((score, _)) => static_segments > *score,
1630 };
1631 if is_more_specific {
1632 best = Some((static_segments, params));
1633 }
1634 }
1635 }
1636 best.map(|(_, params)| params).unwrap_or_default()
1637 }
1638
1639 fn match_path_to_route(
1641 &self,
1642 request_path: &str,
1643 route_pattern: &str,
1644 ) -> Option<HashMap<String, String>> {
1645 let mut params = HashMap::new();
1646
1647 let request_segments: Vec<&str> = request_path.trim_start_matches('/').split('/').collect();
1649 let pattern_segments: Vec<&str> =
1650 route_pattern.trim_start_matches('/').split('/').collect();
1651
1652 if request_segments.len() != pattern_segments.len() {
1653 return None;
1654 }
1655
1656 for (req_seg, pat_seg) in request_segments.iter().zip(pattern_segments.iter()) {
1657 if pat_seg.starts_with('{') && pat_seg.ends_with('}') {
1658 if req_seg.is_empty() {
1663 return None;
1664 }
1665 let param_name = &pat_seg[1..pat_seg.len() - 1];
1666 params.insert(param_name.to_string(), req_seg.to_string());
1667 } else if req_seg != pat_seg {
1668 return None;
1670 }
1671 }
1672
1673 Some(params)
1674 }
1675
1676 pub fn convert_path_to_axum(openapi_path: &str) -> String {
1679 openapi_path.to_string()
1681 }
1682
1683 pub fn build_router_with_ai(
1685 &self,
1686 ai_generator: Option<Arc<dyn AiGenerator + Send + Sync>>,
1687 ) -> Router {
1688 let mut router = Router::new();
1689 let deduped = self.deduplicated_routes();
1690 tracing::debug!("Building router with AI support from {} routes", self.routes.len());
1691
1692 let validator = Arc::new(self.clone_for_validation());
1696 for (axum_path, route) in &deduped {
1697 tracing::debug!("Adding AI-enabled route: {} {}", route.method, route.path);
1698
1699 let route_clone = (*route).clone();
1700 let ai_generator_clone = ai_generator.clone();
1701 let validator_clone = validator.clone();
1705
1706 let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
1708 axum::extract::Query(query_params): axum::extract::Query<
1709 HashMap<String, String>,
1710 >,
1711 headers: HeaderMap,
1712 body: Option<Json<Value>>| {
1713 let route = route_clone.clone();
1714 let ai_generator = ai_generator_clone.clone();
1715 let validator = validator_clone.clone();
1716
1717 async move {
1718 let mut path_map = Map::new();
1723 for (k, v) in &path_params {
1724 path_map.insert(k.clone(), Value::String(v.clone()));
1725 }
1726 let mut query_map = Map::new();
1727 for (k, v) in &query_params {
1728 query_map.insert(k.clone(), Value::String(v.clone()));
1729 }
1730 let mut header_map = Map::new();
1731 for (k, v) in headers.iter() {
1732 if let Ok(s) = v.to_str() {
1733 header_map.insert(k.to_string(), Value::String(s.to_string()));
1734 }
1735 }
1736 let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
1737 if let Err((status_code, payload)) = validator.run_validation_with_recording(
1738 &route.path,
1739 &route.method,
1740 &path_map,
1741 &query_map,
1742 &header_map,
1743 &Map::new(),
1744 body_val,
1745 ) {
1746 let status = axum::http::StatusCode::from_u16(status_code)
1747 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
1748 return (status, Json(payload));
1749 }
1750
1751 tracing::debug!(
1752 "Handling AI request for route: {} {}",
1753 route.method,
1754 route.path
1755 );
1756
1757 let mut context = RequestContext::new(route.method.clone(), route.path.clone());
1759
1760 context.headers = headers
1762 .iter()
1763 .map(|(k, v)| {
1764 (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string()))
1765 })
1766 .collect();
1767
1768 context.body = body.map(|Json(b)| b);
1770
1771 let (status, response) = if let (Some(generator), Some(_ai_config)) =
1773 (ai_generator, &route.ai_config)
1774 {
1775 route
1776 .mock_response_with_status_async(&context, Some(generator.as_ref()))
1777 .await
1778 } else {
1779 route.mock_response_with_status()
1781 };
1782
1783 (
1784 axum::http::StatusCode::from_u16(status)
1785 .unwrap_or(axum::http::StatusCode::OK),
1786 Json(response),
1787 )
1788 }
1789 };
1790
1791 router = Self::route_for_method(router, axum_path, &route.method, handler);
1792 }
1793
1794 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
1798 }
1799
1800 pub fn build_router_with_mockai(
1811 &self,
1812 mockai: Option<
1813 Arc<
1814 tokio::sync::RwLock<
1815 dyn mockforge_foundation::intelligent_behavior::MockAiBehavior + Send + Sync,
1816 >,
1817 >,
1818 >,
1819 ) -> Router {
1820 use mockforge_foundation::intelligent_behavior::Request as MockAIRequest;
1821
1822 let mut router = Router::new();
1823 let deduped = self.deduplicated_routes();
1824 tracing::debug!("Building router with MockAI support from {} routes", self.routes.len());
1825
1826 let custom_loader = self.custom_fixture_loader.clone();
1827 let validator = Arc::new(self.clone_for_validation());
1831 for (axum_path, route) in &deduped {
1832 tracing::debug!("Adding MockAI-enabled route: {} {}", route.method, route.path);
1833
1834 let route_clone = (*route).clone();
1835 let mockai_clone = mockai.clone();
1836 let custom_loader_clone = custom_loader.clone();
1837 let validator_clone = validator.clone();
1843
1844 let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
1860 query: axum::extract::Query<HashMap<String, String>>,
1861 headers: HeaderMap,
1862 body_bytes: axum::body::Bytes| {
1863 let route = route_clone.clone();
1864 let mockai = mockai_clone.clone();
1865 let validator = validator_clone.clone();
1866
1867 async move {
1868 let mut path_map = Map::new();
1869 for (k, v) in &path_params {
1870 path_map.insert(k.clone(), Value::String(v.clone()));
1871 }
1872 let mut query_map = Map::new();
1873 for (k, v) in &query.0 {
1874 query_map.insert(k.clone(), Value::String(v.clone()));
1875 }
1876 let mut header_map = Map::new();
1877 for (k, v) in headers.iter() {
1878 if let Ok(s) = v.to_str() {
1879 header_map.insert(k.to_string(), Value::String(s.to_string()));
1880 }
1881 }
1882
1883 if !body_bytes.is_empty() {
1889 let actual_ct = headers
1890 .get(axum::http::header::CONTENT_TYPE)
1891 .and_then(|v| v.to_str().ok());
1892 if let Err(ct_err) = validator.check_request_content_type(
1893 &route.path,
1894 &route.method,
1895 actual_ct,
1896 ) {
1897 let status_code =
1898 validator.options.validation_status.unwrap_or_else(|| {
1899 std::env::var("MOCKFORGE_VALIDATION_STATUS")
1900 .ok()
1901 .and_then(|s| s.parse::<u16>().ok())
1902 .unwrap_or(415)
1903 });
1904 let (client_mockforge_version, client_sent_at) =
1905 mockforge_foundation::conformance_violations::read_client_stamps(
1906 |name| {
1907 headers
1908 .get(name)
1909 .and_then(|v| v.to_str().ok())
1910 .map(|s| s.to_string())
1911 },
1912 );
1913 mockforge_foundation::conformance_violations::record(
1914 mockforge_foundation::conformance_violations::ServerConformanceViolation {
1915 timestamp: Utc::now(),
1916 method: route.method.clone(),
1917 path: route.path.clone(),
1918 client_ip: "unknown".to_string(),
1919 status: status_code,
1920 reason: ct_err.clone(),
1921 category: "content-types".to_string(),
1922 occurrences: 1,
1923 client_mockforge_version,
1924 client_sent_at,
1925 summary: String::new(),
1926 },
1927 );
1928 let status = axum::http::StatusCode::from_u16(status_code)
1929 .unwrap_or(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
1930 return (
1931 status,
1932 Json(serde_json::json!({
1933 "error": "content_type_not_allowed",
1934 "message": ct_err,
1935 })),
1936 )
1937 .into_response();
1938 }
1939 }
1940
1941 let is_multipart_req = headers
1960 .get(axum::http::header::CONTENT_TYPE)
1961 .and_then(|v| v.to_str().ok())
1962 .map(|ct| ct.starts_with("multipart/form-data"))
1963 .unwrap_or(false);
1964 let body: Option<Json<Value>> = if body_bytes.is_empty() {
1965 None
1966 } else if is_multipart_req {
1967 match extract_multipart_from_bytes(&body_bytes, &headers).await {
1968 Ok((fields, _files)) => {
1969 let mut obj = Map::new();
1970 for (k, v) in fields {
1971 obj.insert(k, v);
1972 }
1973 if obj.is_empty() {
1974 Some(Json(Value::Object(Map::new())))
1982 } else {
1983 Some(Json(Value::Object(obj)))
1984 }
1985 }
1986 Err(e) => {
1987 tracing::warn!(
1988 "multipart parse failed for {} {}: {}",
1989 route.method,
1990 route.path,
1991 e
1992 );
1993 Some(Json(Value::Object(Map::new())))
1994 }
1995 }
1996 } else {
1997 serde_json::from_slice::<Value>(&body_bytes).ok().map(Json)
1998 };
1999
2000 let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
2005 if let Err((status_code, payload)) = validator.run_validation_with_recording(
2006 &route.path,
2007 &route.method,
2008 &path_map,
2009 &query_map,
2010 &header_map,
2011 &Map::new(),
2012 body_val,
2013 ) {
2014 let status = axum::http::StatusCode::from_u16(status_code)
2015 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
2016 return (status, Json(payload)).into_response();
2017 }
2018
2019 tracing::info!(
2020 "[FIXTURE DEBUG] Starting fixture check for {} {} (custom_loader available: {})",
2021 route.method,
2022 route.path,
2023 custom_loader_clone.is_some()
2024 );
2025
2026 if let Some(ref loader) = custom_loader_clone {
2028 use crate::request_fingerprint::RequestFingerprint;
2029 use axum::http::{Method, Uri};
2030
2031 let query_string = if query.0.is_empty() {
2033 String::new()
2034 } else {
2035 query
2036 .0
2037 .iter()
2038 .map(|(k, v)| format!("{}={}", k, v))
2039 .collect::<Vec<_>>()
2040 .join("&")
2041 };
2042
2043 let normalized_request_path =
2045 crate::custom_fixture::CustomFixtureLoader::normalize_path(&route.path);
2046
2047 tracing::info!(
2048 "[FIXTURE DEBUG] Path normalization: original='{}', normalized='{}'",
2049 route.path,
2050 normalized_request_path
2051 );
2052
2053 let uri_str = if query_string.is_empty() {
2055 normalized_request_path.clone()
2056 } else {
2057 format!("{}?{}", normalized_request_path, query_string)
2058 };
2059
2060 tracing::info!(
2061 "[FIXTURE DEBUG] URI construction: uri_str='{}', query_string='{}'",
2062 uri_str,
2063 query_string
2064 );
2065
2066 if let Ok(uri) = uri_str.parse::<Uri>() {
2067 let http_method =
2068 Method::from_bytes(route.method.as_bytes()).unwrap_or(Method::GET);
2069
2070 let body_bytes =
2072 body.as_ref().and_then(|Json(b)| serde_json::to_vec(b).ok());
2073 let body_slice = body_bytes.as_deref();
2074
2075 let fingerprint =
2076 RequestFingerprint::new(http_method, &uri, &headers, body_slice);
2077
2078 tracing::info!(
2079 "[FIXTURE DEBUG] RequestFingerprint created: method='{}', path='{}', query='{}', body_hash={:?}",
2080 fingerprint.method,
2081 fingerprint.path,
2082 fingerprint.query,
2083 fingerprint.body_hash
2084 );
2085
2086 let available_fixtures = loader.has_fixture(&fingerprint);
2088 tracing::info!(
2089 "[FIXTURE DEBUG] Fixture check result: has_fixture={}",
2090 available_fixtures
2091 );
2092
2093 if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
2094 tracing::info!(
2095 "[FIXTURE DEBUG] ✅ FIXTURE MATCHED! Using custom fixture for {} {} (status: {}, path: '{}')",
2096 route.method,
2097 route.path,
2098 custom_fixture.status,
2099 custom_fixture.path
2100 );
2101
2102 if custom_fixture.delay_ms > 0 {
2104 tokio::time::sleep(tokio::time::Duration::from_millis(
2105 custom_fixture.delay_ms,
2106 ))
2107 .await;
2108 }
2109
2110 let response_body = if custom_fixture.response.is_string() {
2112 custom_fixture.response.as_str().unwrap().to_string()
2113 } else {
2114 serde_json::to_string(&custom_fixture.response)
2115 .unwrap_or_else(|_| "{}".to_string())
2116 };
2117
2118 let json_value: Value = serde_json::from_str(&response_body)
2120 .unwrap_or_else(|_| serde_json::json!({}));
2121
2122 let status =
2124 axum::http::StatusCode::from_u16(custom_fixture.status)
2125 .unwrap_or(axum::http::StatusCode::OK);
2126
2127 return (status, Json(json_value)).into_response();
2129 } else {
2130 tracing::warn!(
2131 "[FIXTURE DEBUG] ❌ No fixture match found for {} {} (fingerprint.path='{}', normalized='{}')",
2132 route.method,
2133 route.path,
2134 fingerprint.path,
2135 normalized_request_path
2136 );
2137 }
2138 } else {
2139 tracing::warn!("[FIXTURE DEBUG] Failed to parse URI: '{}'", uri_str);
2140 }
2141 } else {
2142 tracing::warn!(
2143 "[FIXTURE DEBUG] Custom fixture loader not available for {} {}",
2144 route.method,
2145 route.path
2146 );
2147 }
2148
2149 tracing::debug!(
2150 "Handling MockAI request for route: {} {}",
2151 route.method,
2152 route.path
2153 );
2154
2155 let mockai_query = query.0;
2157
2158 let method_upper = route.method.to_uppercase();
2163 let should_use_mockai =
2164 matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH" | "DELETE");
2165
2166 if should_use_mockai {
2167 if let Some(mockai_arc) = mockai {
2168 let mockai_guard = mockai_arc.read().await;
2169
2170 let mut mockai_headers = HashMap::new();
2172 for (k, v) in headers.iter() {
2173 mockai_headers
2174 .insert(k.to_string(), v.to_str().unwrap_or("").to_string());
2175 }
2176
2177 let mockai_request = MockAIRequest {
2178 method: route.method.clone(),
2179 path: route.path.clone(),
2180 body: body.as_ref().map(|Json(b)| b.clone()),
2181 query_params: mockai_query,
2182 headers: mockai_headers,
2183 };
2184
2185 match mockai_guard.process_request(&mockai_request).await {
2187 Ok(mockai_response) => {
2188 let is_empty = mockai_response.body.is_object()
2190 && mockai_response
2191 .body
2192 .as_object()
2193 .map(|obj| obj.is_empty())
2194 .unwrap_or(false);
2195
2196 if is_empty {
2197 tracing::debug!(
2198 "MockAI returned empty object for {} {}, falling back to OpenAPI response generation",
2199 route.method,
2200 route.path
2201 );
2202 } else {
2204 let spec_status = route.find_first_available_status_code();
2208 tracing::debug!(
2209 "MockAI generated response for {} {}, using spec status: {} (MockAI suggested: {})",
2210 route.method,
2211 route.path,
2212 spec_status,
2213 mockai_response.status_code
2214 );
2215 let status = axum::http::StatusCode::from_u16(spec_status)
2216 .unwrap_or(axum::http::StatusCode::OK);
2217 let mut resp =
2218 (status, Json(mockai_response.body)).into_response();
2219 inject_spec_response_headers(
2220 &mut resp,
2221 &route,
2222 spec_status,
2223 );
2224 return resp;
2225 }
2226 }
2227 Err(e) => {
2228 tracing::warn!(
2229 "MockAI processing failed for {} {}: {}, falling back to standard response",
2230 route.method,
2231 route.path,
2232 e
2233 );
2234 }
2236 }
2237 }
2238 } else {
2239 tracing::debug!(
2240 "Skipping MockAI for {} request {} - using OpenAPI response generation",
2241 method_upper,
2242 route.path
2243 );
2244 }
2245
2246 let status_override = headers
2248 .get("X-Mockforge-Response-Status")
2249 .and_then(|v| v.to_str().ok())
2250 .and_then(|s| s.parse::<u16>().ok());
2251
2252 let scenario = headers
2254 .get("X-Mockforge-Scenario")
2255 .and_then(|v| v.to_str().ok())
2256 .map(|s| s.to_string())
2257 .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
2258
2259 let (status, response) = route
2261 .mock_response_with_status_and_scenario_and_override(
2262 scenario.as_deref(),
2263 status_override,
2264 );
2265 let status_code = axum::http::StatusCode::from_u16(status)
2266 .unwrap_or(axum::http::StatusCode::OK);
2267 let mut resp = (status_code, Json(response)).into_response();
2268 inject_spec_response_headers(&mut resp, &route, status);
2269 resp
2270 }
2271 };
2272
2273 router = Self::route_for_method(router, axum_path, &route.method, handler);
2274 }
2275
2276 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
2279 }
2280}
2281
2282fn inject_spec_response_headers(
2295 response: &mut axum::response::Response,
2296 route: &crate::route::OpenApiRoute,
2297 status_code: u16,
2298) {
2299 let synthesized = route.mock_response_headers_for_status(status_code);
2300 if synthesized.is_empty() {
2301 return;
2302 }
2303 let response_headers = response.headers_mut();
2304 for (name, value) in synthesized {
2305 let Ok(header_name) = axum::http::HeaderName::from_bytes(name.as_bytes()) else {
2306 continue;
2307 };
2308 if response_headers.contains_key(&header_name) {
2309 continue;
2310 }
2311 let Ok(header_value) = axum::http::HeaderValue::from_str(&value) else {
2312 continue;
2313 };
2314 response_headers.insert(header_name, header_value);
2315 }
2316}
2317
2318async fn extract_multipart_from_bytes(
2323 body: &axum::body::Bytes,
2324 headers: &HeaderMap,
2325) -> Result<(HashMap<String, Value>, HashMap<String, String>)> {
2326 let boundary = headers
2328 .get(axum::http::header::CONTENT_TYPE)
2329 .and_then(|v| v.to_str().ok())
2330 .and_then(|ct| {
2331 ct.split(';').find_map(|part| {
2332 let part = part.trim();
2333 if part.starts_with("boundary=") {
2334 Some(part.strip_prefix("boundary=").unwrap_or("").trim_matches('"'))
2335 } else {
2336 None
2337 }
2338 })
2339 })
2340 .ok_or_else(|| Error::internal("Missing boundary in Content-Type header"))?;
2341
2342 let mut fields = HashMap::new();
2343 let mut files = HashMap::new();
2344
2345 let boundary_prefix = format!("--{}", boundary).into_bytes();
2348 let boundary_line = format!("\r\n--{}\r\n", boundary).into_bytes();
2349 let end_boundary = format!("\r\n--{}--\r\n", boundary).into_bytes();
2350
2351 let mut pos = 0;
2353 let mut parts = Vec::new();
2354
2355 if body.starts_with(&boundary_prefix) {
2357 if let Some(first_crlf) = body.iter().position(|&b| b == b'\r') {
2358 pos = first_crlf + 2; }
2360 }
2361
2362 while let Some(boundary_pos) = body[pos..]
2364 .windows(boundary_line.len())
2365 .position(|window| window == boundary_line.as_slice())
2366 {
2367 let actual_pos = pos + boundary_pos;
2368 if actual_pos > pos {
2369 parts.push((pos, actual_pos));
2370 }
2371 pos = actual_pos + boundary_line.len();
2372 }
2373
2374 if let Some(end_pos) = body[pos..]
2376 .windows(end_boundary.len())
2377 .position(|window| window == end_boundary.as_slice())
2378 {
2379 let actual_end = pos + end_pos;
2380 if actual_end > pos {
2381 parts.push((pos, actual_end));
2382 }
2383 } else if pos < body.len() {
2384 parts.push((pos, body.len()));
2386 }
2387
2388 for (start, end) in parts {
2390 let part_data = &body[start..end];
2391
2392 let separator = b"\r\n\r\n";
2394 if let Some(sep_pos) =
2395 part_data.windows(separator.len()).position(|window| window == separator)
2396 {
2397 let header_bytes = &part_data[..sep_pos];
2398 let body_start = sep_pos + separator.len();
2399 let body_data = &part_data[body_start..];
2400
2401 let header_str = String::from_utf8_lossy(header_bytes);
2403 let mut field_name = None;
2404 let mut filename = None;
2405
2406 for header_line in header_str.lines() {
2407 if header_line.starts_with("Content-Disposition:") {
2408 if let Some(name_start) = header_line.find("name=\"") {
2410 let name_start = name_start + 6;
2411 if let Some(name_end) = header_line[name_start..].find('"') {
2412 field_name =
2413 Some(header_line[name_start..name_start + name_end].to_string());
2414 }
2415 }
2416
2417 if let Some(file_start) = header_line.find("filename=\"") {
2419 let file_start = file_start + 10;
2420 if let Some(file_end) = header_line[file_start..].find('"') {
2421 filename =
2422 Some(header_line[file_start..file_start + file_end].to_string());
2423 }
2424 }
2425 }
2426 }
2427
2428 if let Some(name) = field_name {
2429 if let Some(file) = filename {
2430 let temp_dir = std::env::temp_dir().join("mockforge-uploads");
2432 std::fs::create_dir_all(&temp_dir)
2433 .map_err(|e| Error::io_with_context("temp directory", e.to_string()))?;
2434
2435 let file_path = temp_dir.join(format!("{}_{}", uuid::Uuid::new_v4(), file));
2436 std::fs::write(&file_path, body_data)
2437 .map_err(|e| Error::io_with_context("file", e.to_string()))?;
2438
2439 let file_path_str = file_path.to_string_lossy().to_string();
2440 files.insert(name.clone(), file_path_str.clone());
2441 fields.insert(name, Value::String(file_path_str));
2442 } else {
2443 let body_str = body_data
2446 .strip_suffix(b"\r\n")
2447 .or_else(|| body_data.strip_suffix(b"\n"))
2448 .unwrap_or(body_data);
2449
2450 if let Ok(field_value) = String::from_utf8(body_str.to_vec()) {
2451 fields.insert(name, Value::String(field_value.trim().to_string()));
2452 } else {
2453 use base64::{engine::general_purpose, Engine as _};
2455 fields.insert(
2456 name,
2457 Value::String(general_purpose::STANDARD.encode(body_str)),
2458 );
2459 }
2460 }
2461 }
2462 }
2463 }
2464
2465 Ok((fields, files))
2466}
2467
2468static LAST_ERRORS: Lazy<Mutex<VecDeque<Value>>> =
2469 Lazy::new(|| Mutex::new(VecDeque::with_capacity(20)));
2470
2471pub fn classify_validation_reason(reason: &str) -> String {
2479 let r = reason.to_ascii_lowercase();
2488
2489 let path_starts_with = |prefix: &str| r.contains(&format!("\"path\":\"{}", prefix));
2491 if path_starts_with("query.") {
2492 return "query".into();
2493 }
2494 if path_starts_with("header.") {
2495 return "headers".into();
2496 }
2497 if path_starts_with("cookie.") {
2498 return "cookies".into();
2499 }
2500 if path_starts_with("path.") {
2501 return "parameters".into();
2502 }
2503 if path_starts_with("body") {
2504 return "request-body".into();
2505 }
2506
2507 if r.contains("content-type") || r.contains("content type") {
2510 return "content-types".into();
2511 }
2512
2513 if r.contains("required")
2517 && (r.contains("param") || r.contains("query") || r.contains("header"))
2518 {
2519 return "parameters".into();
2520 }
2521 if r.contains("auth") || r.contains("security") {
2522 return "security".into();
2523 }
2524 if r.contains("method") {
2525 return "http-methods".into();
2526 }
2527 if r.contains("schema") || r.contains("body") || r.contains("json") {
2528 return "request-body".into();
2529 }
2530 if r.contains("enum") || r.contains("min") || r.contains("max") || r.contains("pattern") {
2531 return "constraints".into();
2532 }
2533 String::new()
2534}
2535
2536pub fn record_validation_error(v: &Value) {
2538 if let Ok(mut q) = LAST_ERRORS.lock() {
2539 if q.len() >= 20 {
2540 q.pop_front();
2541 }
2542 q.push_back(v.clone());
2543 }
2544 }
2546
2547pub fn get_last_validation_error() -> Option<Value> {
2549 LAST_ERRORS.lock().ok()?.back().cloned()
2550}
2551
2552pub fn get_validation_errors() -> Vec<Value> {
2554 LAST_ERRORS.lock().map(|q| q.iter().cloned().collect()).unwrap_or_default()
2555}
2556
2557fn coerce_value_for_schema(value: &Value, schema: &openapiv3::Schema) -> Value {
2562 match value {
2564 Value::String(s) => {
2565 if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2567 &schema.schema_kind
2568 {
2569 if s.contains(',') {
2570 let parts: Vec<&str> = s.split(',').map(|s| s.trim()).collect();
2572 let mut array_values = Vec::new();
2573
2574 for part in parts {
2575 if let Some(items_schema) = &array_type.items {
2577 if let Some(items_schema_obj) = items_schema.as_item() {
2578 let part_value = Value::String(part.to_string());
2579 let coerced_part =
2580 coerce_value_for_schema(&part_value, items_schema_obj);
2581 array_values.push(coerced_part);
2582 } else {
2583 array_values.push(Value::String(part.to_string()));
2585 }
2586 } else {
2587 array_values.push(Value::String(part.to_string()));
2589 }
2590 }
2591 return Value::Array(array_values);
2592 }
2593 }
2594
2595 match &schema.schema_kind {
2597 openapiv3::SchemaKind::Type(openapiv3::Type::String(_)) => {
2598 value.clone()
2600 }
2601 openapiv3::SchemaKind::Type(openapiv3::Type::Number(_)) => {
2602 if let Ok(n) = s.parse::<f64>() {
2604 if let Some(num) = serde_json::Number::from_f64(n) {
2605 return Value::Number(num);
2606 }
2607 }
2608 value.clone()
2609 }
2610 openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_)) => {
2611 if let Ok(n) = s.parse::<i64>() {
2613 if let Some(num) = serde_json::Number::from_f64(n as f64) {
2614 return Value::Number(num);
2615 }
2616 }
2617 value.clone()
2618 }
2619 openapiv3::SchemaKind::Type(openapiv3::Type::Boolean(_)) => {
2620 match s.to_lowercase().as_str() {
2622 "true" | "1" | "yes" | "on" => Value::Bool(true),
2623 "false" | "0" | "no" | "off" => Value::Bool(false),
2624 _ => value.clone(),
2625 }
2626 }
2627 _ => {
2628 value.clone()
2630 }
2631 }
2632 }
2633 _ => value.clone(),
2634 }
2635}
2636
2637fn coerce_by_style(value: &Value, schema: &openapiv3::Schema, style: Option<&str>) -> Value {
2639 match value {
2641 Value::String(s) => {
2642 if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2644 &schema.schema_kind
2645 {
2646 let delimiter = match style {
2647 Some("spaceDelimited") => " ",
2648 Some("pipeDelimited") => "|",
2649 Some("form") | None => ",", _ => ",", };
2652
2653 if s.contains(delimiter) {
2654 let parts: Vec<&str> = s.split(delimiter).map(|s| s.trim()).collect();
2656 let mut array_values = Vec::new();
2657
2658 for part in parts {
2659 if let Some(items_schema) = &array_type.items {
2661 if let Some(items_schema_obj) = items_schema.as_item() {
2662 let part_value = Value::String(part.to_string());
2663 let coerced_part =
2664 coerce_by_style(&part_value, items_schema_obj, style);
2665 array_values.push(coerced_part);
2666 } else {
2667 array_values.push(Value::String(part.to_string()));
2669 }
2670 } else {
2671 array_values.push(Value::String(part.to_string()));
2673 }
2674 }
2675 return Value::Array(array_values);
2676 }
2677 }
2678
2679 if let Ok(n) = s.parse::<f64>() {
2681 if let Some(num) = serde_json::Number::from_f64(n) {
2682 return Value::Number(num);
2683 }
2684 }
2685 match s.to_lowercase().as_str() {
2687 "true" | "1" | "yes" | "on" => return Value::Bool(true),
2688 "false" | "0" | "no" | "off" => return Value::Bool(false),
2689 _ => {}
2690 }
2691 value.clone()
2693 }
2694 _ => value.clone(),
2695 }
2696}
2697
2698fn build_deep_object(name: &str, params: &Map<String, Value>) -> Option<Value> {
2700 let prefix = format!("{}[", name);
2701 let mut obj = Map::new();
2702 for (k, v) in params.iter() {
2703 if let Some(rest) = k.strip_prefix(&prefix) {
2704 if let Some(key) = rest.strip_suffix(']') {
2705 obj.insert(key.to_string(), v.clone());
2706 }
2707 }
2708 }
2709 if obj.is_empty() {
2710 None
2711 } else {
2712 Some(Value::Object(obj))
2713 }
2714}
2715
2716#[allow(clippy::too_many_arguments)]
2722fn generate_enhanced_422_response(
2723 validator: &OpenApiRouteRegistry,
2724 path_template: &str,
2725 method: &str,
2726 body: Option<&Value>,
2727 path_params: &Map<String, Value>,
2728 query_params: &Map<String, Value>,
2729 header_params: &Map<String, Value>,
2730 cookie_params: &Map<String, Value>,
2731) -> Value {
2732 let mut field_errors = Vec::new();
2733
2734 if let Some(route) = validator.get_route(path_template, method) {
2736 if let Some(schema) = &route.operation.request_body {
2738 if let Some(value) = body {
2739 if let Some(content) =
2740 schema.as_item().and_then(|rb| rb.content.get("application/json"))
2741 {
2742 if let Some(_schema_ref) = &content.schema {
2743 if serde_json::from_value::<Value>(value.clone()).is_err() {
2745 field_errors.push(json!({
2746 "path": "body",
2747 "message": "invalid JSON"
2748 }));
2749 }
2750 }
2751 }
2752 } else {
2753 field_errors.push(json!({
2754 "path": "body",
2755 "expected": "object",
2756 "found": "missing",
2757 "message": "Request body is required but not provided"
2758 }));
2759 }
2760 }
2761
2762 for param_ref in &route.operation.parameters {
2764 if let Some(param) = param_ref.as_item() {
2765 match param {
2766 openapiv3::Parameter::Path { parameter_data, .. } => {
2767 validate_parameter_detailed(
2768 parameter_data,
2769 path_params,
2770 "path",
2771 "path parameter",
2772 &mut field_errors,
2773 );
2774 }
2775 openapiv3::Parameter::Query { parameter_data, .. } => {
2776 let deep_value = if Some("form") == Some("deepObject") {
2777 build_deep_object(¶meter_data.name, query_params)
2778 } else {
2779 None
2780 };
2781 validate_parameter_detailed_with_deep(
2782 parameter_data,
2783 query_params,
2784 "query",
2785 "query parameter",
2786 deep_value,
2787 &mut field_errors,
2788 );
2789 }
2790 openapiv3::Parameter::Header { parameter_data, .. } => {
2791 validate_parameter_detailed(
2792 parameter_data,
2793 header_params,
2794 "header",
2795 "header parameter",
2796 &mut field_errors,
2797 );
2798 }
2799 openapiv3::Parameter::Cookie { parameter_data, .. } => {
2800 validate_parameter_detailed(
2801 parameter_data,
2802 cookie_params,
2803 "cookie",
2804 "cookie parameter",
2805 &mut field_errors,
2806 );
2807 }
2808 }
2809 }
2810 }
2811 }
2812
2813 json!({
2815 "error": "Schema validation failed",
2816 "details": field_errors,
2817 "method": method,
2818 "path": path_template,
2819 "timestamp": Utc::now().to_rfc3339(),
2820 "validation_type": "openapi_schema"
2821 })
2822}
2823
2824fn validate_parameter(
2826 parameter_data: &openapiv3::ParameterData,
2827 params_map: &Map<String, Value>,
2828 prefix: &str,
2829 aggregate: bool,
2830 errors: &mut Vec<String>,
2831 details: &mut Vec<Value>,
2832) {
2833 match params_map.get(¶meter_data.name) {
2834 Some(v) => {
2835 if let ParameterSchemaOrContent::Schema(s) = ¶meter_data.format {
2836 if let Some(schema) = s.as_item() {
2837 let coerced = coerce_value_for_schema(v, schema);
2838 if let Err(validation_error) =
2840 OpenApiSchema::new(schema.clone()).validate(&coerced)
2841 {
2842 let error_msg = validation_error.to_string();
2843 errors.push(format!(
2844 "{} parameter '{}' validation failed: {}",
2845 prefix, parameter_data.name, error_msg
2846 ));
2847 if aggregate {
2848 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2849 }
2850 }
2851 }
2852 }
2853 }
2854 None => {
2855 if parameter_data.required {
2856 errors.push(format!(
2857 "missing required {} parameter '{}'",
2858 prefix, parameter_data.name
2859 ));
2860 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2861 }
2862 }
2863 }
2864}
2865
2866#[allow(clippy::too_many_arguments)]
2868fn validate_parameter_with_deep_object(
2869 parameter_data: &openapiv3::ParameterData,
2870 params_map: &Map<String, Value>,
2871 prefix: &str,
2872 deep_value: Option<Value>,
2873 style: Option<&str>,
2874 aggregate: bool,
2875 errors: &mut Vec<String>,
2876 details: &mut Vec<Value>,
2877) {
2878 match deep_value.as_ref().or_else(|| params_map.get(¶meter_data.name)) {
2879 Some(v) => {
2880 if let ParameterSchemaOrContent::Schema(s) = ¶meter_data.format {
2881 if let Some(schema) = s.as_item() {
2882 let coerced = coerce_by_style(v, schema, style); if let Err(validation_error) =
2885 OpenApiSchema::new(schema.clone()).validate(&coerced)
2886 {
2887 let error_msg = validation_error.to_string();
2888 errors.push(format!(
2889 "{} parameter '{}' validation failed: {}",
2890 prefix, parameter_data.name, error_msg
2891 ));
2892 if aggregate {
2893 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2894 }
2895 }
2896 }
2897 }
2898 }
2899 None => {
2900 if parameter_data.required {
2901 errors.push(format!(
2902 "missing required {} parameter '{}'",
2903 prefix, parameter_data.name
2904 ));
2905 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2906 }
2907 }
2908 }
2909}
2910
2911fn validate_parameter_detailed(
2913 parameter_data: &openapiv3::ParameterData,
2914 params_map: &Map<String, Value>,
2915 location: &str,
2916 value_type: &str,
2917 field_errors: &mut Vec<Value>,
2918) {
2919 match params_map.get(¶meter_data.name) {
2920 Some(value) => {
2921 if let ParameterSchemaOrContent::Schema(schema) = ¶meter_data.format {
2922 let details: Vec<Value> = Vec::new();
2924 let param_path = format!("{}.{}", location, parameter_data.name);
2925
2926 if let Some(schema_ref) = schema.as_item() {
2928 let coerced_value = coerce_value_for_schema(value, schema_ref);
2929 if let Err(validation_error) =
2931 OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2932 {
2933 field_errors.push(json!({
2934 "path": param_path,
2935 "expected": "valid according to schema",
2936 "found": coerced_value,
2937 "message": validation_error.to_string()
2938 }));
2939 }
2940 }
2941
2942 for detail in details {
2943 field_errors.push(json!({
2944 "path": detail["path"],
2945 "expected": detail["expected_type"],
2946 "found": detail["value"],
2947 "message": detail["message"]
2948 }));
2949 }
2950 }
2951 }
2952 None => {
2953 if parameter_data.required {
2954 field_errors.push(json!({
2955 "path": format!("{}.{}", location, parameter_data.name),
2956 "expected": "value",
2957 "found": "missing",
2958 "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
2959 }));
2960 }
2961 }
2962 }
2963}
2964
2965fn validate_parameter_detailed_with_deep(
2967 parameter_data: &openapiv3::ParameterData,
2968 params_map: &Map<String, Value>,
2969 location: &str,
2970 value_type: &str,
2971 deep_value: Option<Value>,
2972 field_errors: &mut Vec<Value>,
2973) {
2974 match deep_value.as_ref().or_else(|| params_map.get(¶meter_data.name)) {
2975 Some(value) => {
2976 if let ParameterSchemaOrContent::Schema(schema) = ¶meter_data.format {
2977 let details: Vec<Value> = Vec::new();
2979 let param_path = format!("{}.{}", location, parameter_data.name);
2980
2981 if let Some(schema_ref) = schema.as_item() {
2983 let coerced_value = coerce_by_style(value, schema_ref, Some("form")); if let Err(validation_error) =
2986 OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2987 {
2988 field_errors.push(json!({
2989 "path": param_path,
2990 "expected": "valid according to schema",
2991 "found": coerced_value,
2992 "message": validation_error.to_string()
2993 }));
2994 }
2995 }
2996
2997 for detail in details {
2998 field_errors.push(json!({
2999 "path": detail["path"],
3000 "expected": detail["expected_type"],
3001 "found": detail["value"],
3002 "message": detail["message"]
3003 }));
3004 }
3005 }
3006 }
3007 None => {
3008 if parameter_data.required {
3009 field_errors.push(json!({
3010 "path": format!("{}.{}", location, parameter_data.name),
3011 "expected": "value",
3012 "found": "missing",
3013 "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
3014 }));
3015 }
3016 }
3017 }
3018}
3019
3020pub async fn create_registry_from_file<P: AsRef<std::path::Path>>(
3022 path: P,
3023) -> Result<OpenApiRouteRegistry> {
3024 let spec = OpenApiSpec::from_file(path).await?;
3025 spec.validate()?;
3026 Ok(OpenApiRouteRegistry::new(spec))
3027}
3028
3029pub fn create_registry_from_json(json: Value) -> Result<OpenApiRouteRegistry> {
3031 let spec = OpenApiSpec::from_json(json)?;
3032 spec.validate()?;
3033 Ok(OpenApiRouteRegistry::new(spec))
3034}
3035
3036#[cfg(test)]
3037mod tests {
3038 use super::*;
3039 use serde_json::json;
3040 use tempfile::TempDir;
3041
3042 #[test]
3051 fn classify_validation_reason_uses_structured_path_field_first() {
3052 let query_only = r#"{"details":[{"code":"schema_validation","message":"Validation error","path":"query.$.xgafv"}]}"#;
3055 assert_eq!(classify_validation_reason(query_only), "query");
3056
3057 let header_only = r#"{"details":[{"code":"schema_validation","message":"missing required X-Trace","path":"header.X-Trace"}]}"#;
3058 assert_eq!(classify_validation_reason(header_only), "headers");
3059
3060 let cookie_only = r#"{"details":[{"code":"schema_validation","message":"missing session","path":"cookie.session"}]}"#;
3061 assert_eq!(classify_validation_reason(cookie_only), "cookies");
3062
3063 let body_only = r#"{"details":[{"code":"schema_validation","message":"name required","path":"body.name"}]}"#;
3065 assert_eq!(classify_validation_reason(body_only), "request-body");
3066
3067 assert_eq!(
3069 classify_validation_reason("Content-Type application/xml not allowed"),
3070 "content-types"
3071 );
3072 }
3073
3074 #[tokio::test]
3075 async fn test_registry_creation() {
3076 let spec_json = json!({
3077 "openapi": "3.0.0",
3078 "info": {
3079 "title": "Test API",
3080 "version": "1.0.0"
3081 },
3082 "paths": {
3083 "/users": {
3084 "get": {
3085 "summary": "Get users",
3086 "responses": {
3087 "200": {
3088 "description": "Success",
3089 "content": {
3090 "application/json": {
3091 "schema": {
3092 "type": "array",
3093 "items": {
3094 "type": "object",
3095 "properties": {
3096 "id": {"type": "integer"},
3097 "name": {"type": "string"}
3098 }
3099 }
3100 }
3101 }
3102 }
3103 }
3104 }
3105 },
3106 "post": {
3107 "summary": "Create user",
3108 "requestBody": {
3109 "content": {
3110 "application/json": {
3111 "schema": {
3112 "type": "object",
3113 "properties": {
3114 "name": {"type": "string"}
3115 },
3116 "required": ["name"]
3117 }
3118 }
3119 }
3120 },
3121 "responses": {
3122 "201": {
3123 "description": "Created",
3124 "content": {
3125 "application/json": {
3126 "schema": {
3127 "type": "object",
3128 "properties": {
3129 "id": {"type": "integer"},
3130 "name": {"type": "string"}
3131 }
3132 }
3133 }
3134 }
3135 }
3136 }
3137 }
3138 },
3139 "/users/{id}": {
3140 "get": {
3141 "summary": "Get user by ID",
3142 "parameters": [
3143 {
3144 "name": "id",
3145 "in": "path",
3146 "required": true,
3147 "schema": {"type": "integer"}
3148 }
3149 ],
3150 "responses": {
3151 "200": {
3152 "description": "Success",
3153 "content": {
3154 "application/json": {
3155 "schema": {
3156 "type": "object",
3157 "properties": {
3158 "id": {"type": "integer"},
3159 "name": {"type": "string"}
3160 }
3161 }
3162 }
3163 }
3164 }
3165 }
3166 }
3167 }
3168 }
3169 });
3170
3171 let registry = create_registry_from_json(spec_json).unwrap();
3172
3173 assert_eq!(registry.paths().len(), 2);
3175 assert!(registry.paths().contains(&"/users".to_string()));
3176 assert!(registry.paths().contains(&"/users/{id}".to_string()));
3177
3178 assert_eq!(registry.methods().len(), 2);
3179 assert!(registry.methods().contains(&"GET".to_string()));
3180 assert!(registry.methods().contains(&"POST".to_string()));
3181
3182 let get_users_route = registry.get_route("/users", "GET").unwrap();
3184 assert_eq!(get_users_route.method, "GET");
3185 assert_eq!(get_users_route.path, "/users");
3186
3187 let post_users_route = registry.get_route("/users", "POST").unwrap();
3188 assert_eq!(post_users_route.method, "POST");
3189 assert!(post_users_route.operation.request_body.is_some());
3190
3191 let user_by_id_route = registry.get_route("/users/{id}", "GET").unwrap();
3193 assert_eq!(user_by_id_route.axum_path(), "/users/{id}");
3194 }
3195
3196 #[tokio::test]
3202 async fn check_request_content_type_flags_mismatch() {
3203 let spec_json = json!({
3204 "openapi": "3.0.0",
3205 "info": { "title": "T", "version": "1" },
3206 "paths": {
3207 "/api/appliance/access/consolecli": {
3208 "put": {
3209 "requestBody": {
3210 "required": true,
3211 "content": {
3212 "application/json": {
3213 "schema": {
3214 "type": "object",
3215 "required": ["enabled"],
3216 "properties": {"enabled": {"type": "boolean"}}
3217 }
3218 }
3219 }
3220 },
3221 "responses": { "204": { "description": "ok" } }
3222 }
3223 }
3224 }
3225 });
3226 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3227 let registry = OpenApiRouteRegistry::new(spec);
3228
3229 let r = registry.check_request_content_type(
3231 "/api/appliance/access/consolecli",
3232 "PUT",
3233 Some("application/xml"),
3234 );
3235 assert!(r.is_err(), "should flag application/xml: {:?}", r);
3236 let msg = r.unwrap_err();
3237 assert!(msg.contains("application/xml"), "{msg}");
3238 assert!(msg.contains("application/json"), "{msg}");
3239
3240 let r = registry.check_request_content_type(
3242 "/api/appliance/access/consolecli",
3243 "PUT",
3244 Some("application/json"),
3245 );
3246 assert!(r.is_ok(), "should accept application/json: {:?}", r);
3247
3248 let r = registry.check_request_content_type(
3250 "/api/appliance/access/consolecli",
3251 "PUT",
3252 Some("application/json; charset=utf-8"),
3253 );
3254 assert!(r.is_ok(), "should strip charset: {:?}", r);
3255
3256 let r = registry.check_request_content_type(
3258 "/api/appliance/access/consolecli",
3259 "GET",
3260 Some("application/xml"),
3261 );
3262 assert!(r.is_ok(), "GET has no requestBody on this op: {:?}", r);
3263
3264 let r =
3266 registry.check_request_content_type("/api/appliance/access/consolecli", "PUT", None);
3267 assert!(r.is_ok(), "no Content-Type → don't double-report: {:?}", r);
3268 }
3269
3270 #[tokio::test]
3271 async fn test_validate_request_with_params_and_formats() {
3272 let spec_json = json!({
3273 "openapi": "3.0.0",
3274 "info": { "title": "Test API", "version": "1.0.0" },
3275 "paths": {
3276 "/users/{id}": {
3277 "post": {
3278 "parameters": [
3279 { "name": "id", "in": "path", "required": true, "schema": {"type": "string"} },
3280 { "name": "q", "in": "query", "required": false, "schema": {"type": "integer"} }
3281 ],
3282 "requestBody": {
3283 "content": {
3284 "application/json": {
3285 "schema": {
3286 "type": "object",
3287 "required": ["email", "website"],
3288 "properties": {
3289 "email": {"type": "string", "format": "email"},
3290 "website": {"type": "string", "format": "uri"}
3291 }
3292 }
3293 }
3294 }
3295 },
3296 "responses": {"200": {"description": "ok"}}
3297 }
3298 }
3299 }
3300 });
3301
3302 let registry = create_registry_from_json(spec_json).unwrap();
3303 let mut path_params = Map::new();
3304 path_params.insert("id".to_string(), json!("abc"));
3305 let mut query_params = Map::new();
3306 query_params.insert("q".to_string(), json!(123));
3307
3308 let body = json!({"email":"a@b.co","website":"https://example.com"});
3310 assert!(registry
3311 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3312 .is_ok());
3313
3314 let bad_email = json!({"email":"not-an-email","website":"https://example.com"});
3316 assert!(registry
3317 .validate_request_with(
3318 "/users/{id}",
3319 "POST",
3320 &path_params,
3321 &query_params,
3322 Some(&bad_email)
3323 )
3324 .is_err());
3325
3326 let empty_path_params = Map::new();
3328 assert!(registry
3329 .validate_request_with(
3330 "/users/{id}",
3331 "POST",
3332 &empty_path_params,
3333 &query_params,
3334 Some(&body)
3335 )
3336 .is_err());
3337 }
3338
3339 #[tokio::test]
3340 async fn test_ref_resolution_for_params_and_body() {
3341 let spec_json = json!({
3342 "openapi": "3.0.0",
3343 "info": { "title": "Ref API", "version": "1.0.0" },
3344 "components": {
3345 "schemas": {
3346 "EmailWebsite": {
3347 "type": "object",
3348 "required": ["email", "website"],
3349 "properties": {
3350 "email": {"type": "string", "format": "email"},
3351 "website": {"type": "string", "format": "uri"}
3352 }
3353 }
3354 },
3355 "parameters": {
3356 "PathId": {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}},
3357 "QueryQ": {"name": "q", "in": "query", "required": false, "schema": {"type": "integer"}}
3358 },
3359 "requestBodies": {
3360 "CreateUser": {
3361 "content": {
3362 "application/json": {
3363 "schema": {"$ref": "#/components/schemas/EmailWebsite"}
3364 }
3365 }
3366 }
3367 }
3368 },
3369 "paths": {
3370 "/users/{id}": {
3371 "post": {
3372 "parameters": [
3373 {"$ref": "#/components/parameters/PathId"},
3374 {"$ref": "#/components/parameters/QueryQ"}
3375 ],
3376 "requestBody": {"$ref": "#/components/requestBodies/CreateUser"},
3377 "responses": {"200": {"description": "ok"}}
3378 }
3379 }
3380 }
3381 });
3382
3383 let registry = create_registry_from_json(spec_json).unwrap();
3384 let mut path_params = Map::new();
3385 path_params.insert("id".to_string(), json!("abc"));
3386 let mut query_params = Map::new();
3387 query_params.insert("q".to_string(), json!(7));
3388
3389 let body = json!({"email":"user@example.com","website":"https://example.com"});
3390 assert!(registry
3391 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3392 .is_ok());
3393
3394 let bad = json!({"email":"nope","website":"https://example.com"});
3395 assert!(registry
3396 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&bad))
3397 .is_err());
3398 }
3399
3400 #[tokio::test]
3401 async fn test_header_cookie_and_query_coercion() {
3402 let spec_json = json!({
3403 "openapi": "3.0.0",
3404 "info": { "title": "Params API", "version": "1.0.0" },
3405 "paths": {
3406 "/items": {
3407 "get": {
3408 "parameters": [
3409 {"name": "X-Flag", "in": "header", "required": true, "schema": {"type": "boolean"}},
3410 {"name": "session", "in": "cookie", "required": true, "schema": {"type": "string"}},
3411 {"name": "ids", "in": "query", "required": false, "schema": {"type": "array", "items": {"type": "integer"}}}
3412 ],
3413 "responses": {"200": {"description": "ok"}}
3414 }
3415 }
3416 }
3417 });
3418
3419 let registry = create_registry_from_json(spec_json).unwrap();
3420
3421 let path_params = Map::new();
3422 let mut query_params = Map::new();
3423 query_params.insert("ids".to_string(), json!("1,2,3"));
3425 let mut header_params = Map::new();
3426 header_params.insert("X-Flag".to_string(), json!("true"));
3427 let mut cookie_params = Map::new();
3428 cookie_params.insert("session".to_string(), json!("abc123"));
3429
3430 assert!(registry
3431 .validate_request_with_all(
3432 "/items",
3433 "GET",
3434 &path_params,
3435 &query_params,
3436 &header_params,
3437 &cookie_params,
3438 None
3439 )
3440 .is_ok());
3441
3442 let empty_cookie = Map::new();
3444 assert!(registry
3445 .validate_request_with_all(
3446 "/items",
3447 "GET",
3448 &path_params,
3449 &query_params,
3450 &header_params,
3451 &empty_cookie,
3452 None
3453 )
3454 .is_err());
3455
3456 let mut bad_header = Map::new();
3458 bad_header.insert("X-Flag".to_string(), json!("notabool"));
3459 assert!(registry
3460 .validate_request_with_all(
3461 "/items",
3462 "GET",
3463 &path_params,
3464 &query_params,
3465 &bad_header,
3466 &cookie_params,
3467 None
3468 )
3469 .is_err());
3470 }
3471
3472 #[tokio::test]
3473 async fn test_query_styles_space_pipe_deepobject() {
3474 let spec_json = json!({
3475 "openapi": "3.0.0",
3476 "info": { "title": "Query Styles API", "version": "1.0.0" },
3477 "paths": {"/search": {"get": {
3478 "parameters": [
3479 {"name":"tags","in":"query","style":"spaceDelimited","schema":{"type":"array","items":{"type":"string"}}},
3480 {"name":"ids","in":"query","style":"pipeDelimited","schema":{"type":"array","items":{"type":"integer"}}},
3481 {"name":"filter","in":"query","style":"deepObject","schema":{"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}}
3482 ],
3483 "responses": {"200": {"description":"ok"}}
3484 }} }
3485 });
3486
3487 let registry = create_registry_from_json(spec_json).unwrap();
3488
3489 let path_params = Map::new();
3490 let mut query = Map::new();
3491 query.insert("tags".into(), json!("alpha beta gamma"));
3492 query.insert("ids".into(), json!("1|2|3"));
3493 query.insert("filter[color]".into(), json!("red"));
3494
3495 assert!(registry
3496 .validate_request_with("/search", "GET", &path_params, &query, None)
3497 .is_ok());
3498 }
3499
3500 #[tokio::test]
3501 async fn test_oneof_anyof_allof_validation() {
3502 let spec_json = json!({
3503 "openapi": "3.0.0",
3504 "info": { "title": "Composite API", "version": "1.0.0" },
3505 "paths": {
3506 "/composite": {
3507 "post": {
3508 "requestBody": {
3509 "content": {
3510 "application/json": {
3511 "schema": {
3512 "allOf": [
3513 {"type": "object", "required": ["base"], "properties": {"base": {"type": "string"}}}
3514 ],
3515 "oneOf": [
3516 {"type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"], "not": {"required": ["b"]}},
3517 {"type": "object", "properties": {"b": {"type": "integer"}}, "required": ["b"], "not": {"required": ["a"]}}
3518 ],
3519 "anyOf": [
3520 {"type": "object", "properties": {"flag": {"type": "boolean"}}, "required": ["flag"]},
3521 {"type": "object", "properties": {"extra": {"type": "string"}}, "required": ["extra"]}
3522 ]
3523 }
3524 }
3525 }
3526 },
3527 "responses": {"200": {"description": "ok"}}
3528 }
3529 }
3530 }
3531 });
3532
3533 let registry = create_registry_from_json(spec_json).unwrap();
3534 let ok = json!({"base": "x", "a": 1, "flag": true});
3536 assert!(registry
3537 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&ok))
3538 .is_ok());
3539
3540 let bad_oneof = json!({"base": "x", "a": 1, "b": 2, "flag": false});
3542 assert!(registry
3543 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_oneof))
3544 .is_err());
3545
3546 let bad_anyof = json!({"base": "x", "a": 1});
3548 assert!(registry
3549 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_anyof))
3550 .is_err());
3551
3552 let bad_allof = json!({"a": 1, "flag": true});
3554 assert!(registry
3555 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_allof))
3556 .is_err());
3557 }
3558
3559 #[tokio::test]
3568 async fn dotted_schema_ref_resolves_in_route_validator() {
3569 let spec_json = json!({
3570 "openapi": "3.0.0",
3571 "info": { "title": "Dotted", "version": "1.0.0" },
3572 "paths": {
3573 "/x": {
3574 "post": {
3575 "requestBody": {
3576 "required": true,
3577 "content": {
3578 "application/json": {
3579 "schema": {
3580 "$ref": "#/components/schemas/Esx.Settings.Inventory.EntitySpec"
3581 }
3582 }
3583 }
3584 },
3585 "responses": {"200": {"description": "ok"}}
3586 }
3587 }
3588 },
3589 "components": {
3590 "schemas": {
3591 "Esx.Settings.Inventory.EntitySpec": {
3592 "type": "object",
3593 "required": ["type"],
3594 "properties": {"type": {"type": "string"}}
3595 }
3596 }
3597 }
3598 });
3599 let registry = create_registry_from_json(spec_json).unwrap();
3600 let good = json!({"type": "HOST"});
3603 let res =
3604 registry.validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&good));
3605 assert!(res.is_ok(), "valid body should pass; got {res:?}");
3606 let bad = json!({"unrelated": 1});
3608 let err = registry
3609 .validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&bad))
3610 .unwrap_err();
3611 let msg = format!("{err}");
3612 assert!(
3613 !msg.contains("Pointer") || !msg.contains("does not exist"),
3614 "should not be a pointer-resolution failure; got: {msg}"
3615 );
3616 }
3617
3618 #[tokio::test]
3619 async fn test_overrides_warn_mode_allows_invalid() {
3620 let spec_json = json!({
3622 "openapi": "3.0.0",
3623 "info": { "title": "Overrides API", "version": "1.0.0" },
3624 "paths": {"/things": {"post": {
3625 "parameters": [{"name":"q","in":"query","required":true,"schema":{"type":"integer"}}],
3626 "responses": {"200": {"description":"ok"}}
3627 }}}
3628 });
3629
3630 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3631 let mut overrides = HashMap::new();
3632 overrides.insert("POST /things".to_string(), ValidationMode::Warn);
3633 let registry = OpenApiRouteRegistry::new_with_options(
3634 spec,
3635 ValidationOptions {
3636 request_mode: ValidationMode::Enforce,
3637 aggregate_errors: true,
3638 validate_responses: false,
3639 overrides,
3640 admin_skip_prefixes: vec![],
3641 response_template_expand: false,
3642 validation_status: None,
3643 },
3644 );
3645
3646 let ok = registry.validate_request_with("/things", "POST", &Map::new(), &Map::new(), None);
3648 assert!(ok.is_ok());
3649 }
3650
3651 #[tokio::test]
3652 async fn test_admin_skip_prefix_short_circuit() {
3653 let spec_json = json!({
3654 "openapi": "3.0.0",
3655 "info": { "title": "Skip API", "version": "1.0.0" },
3656 "paths": {}
3657 });
3658 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3659 let registry = OpenApiRouteRegistry::new_with_options(
3660 spec,
3661 ValidationOptions {
3662 request_mode: ValidationMode::Enforce,
3663 aggregate_errors: true,
3664 validate_responses: false,
3665 overrides: HashMap::new(),
3666 admin_skip_prefixes: vec!["/admin".into()],
3667 response_template_expand: false,
3668 validation_status: None,
3669 },
3670 );
3671
3672 let res = registry.validate_request_with_all(
3674 "/admin/__mockforge/health",
3675 "GET",
3676 &Map::new(),
3677 &Map::new(),
3678 &Map::new(),
3679 &Map::new(),
3680 None,
3681 );
3682 assert!(res.is_ok());
3683 }
3684
3685 #[test]
3686 fn test_path_conversion() {
3687 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users"), "/users");
3688 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users/{id}"), "/users/{id}");
3689 assert_eq!(
3690 OpenApiRouteRegistry::convert_path_to_axum("/users/{id}/posts/{postId}"),
3691 "/users/{id}/posts/{postId}"
3692 );
3693 }
3694
3695 #[test]
3696 fn test_validation_options_default() {
3697 let options = ValidationOptions::default();
3698 assert!(matches!(options.request_mode, ValidationMode::Enforce));
3699 assert!(options.aggregate_errors);
3700 assert!(!options.validate_responses);
3701 assert!(options.overrides.is_empty());
3702 assert!(options.admin_skip_prefixes.is_empty());
3703 assert!(!options.response_template_expand);
3704 assert!(options.validation_status.is_none());
3705 }
3706
3707 #[test]
3708 fn test_validation_mode_variants() {
3709 let disabled = ValidationMode::Disabled;
3711 let warn = ValidationMode::Warn;
3712 let enforce = ValidationMode::Enforce;
3713 let default = ValidationMode::default();
3714
3715 assert!(matches!(default, ValidationMode::Warn));
3717
3718 assert!(!matches!(disabled, ValidationMode::Warn));
3720 assert!(!matches!(warn, ValidationMode::Enforce));
3721 assert!(!matches!(enforce, ValidationMode::Disabled));
3722 }
3723
3724 #[test]
3725 fn test_registry_spec_accessor() {
3726 let spec_json = json!({
3727 "openapi": "3.0.0",
3728 "info": {
3729 "title": "Test API",
3730 "version": "1.0.0"
3731 },
3732 "paths": {}
3733 });
3734 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3735 let registry = OpenApiRouteRegistry::new(spec.clone());
3736
3737 let accessed_spec = registry.spec();
3739 assert_eq!(accessed_spec.title(), "Test API");
3740 }
3741
3742 #[test]
3743 fn test_clone_for_validation() {
3744 let spec_json = json!({
3745 "openapi": "3.0.0",
3746 "info": {
3747 "title": "Test API",
3748 "version": "1.0.0"
3749 },
3750 "paths": {
3751 "/users": {
3752 "get": {
3753 "responses": {
3754 "200": {
3755 "description": "Success"
3756 }
3757 }
3758 }
3759 }
3760 }
3761 });
3762 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3763 let registry = OpenApiRouteRegistry::new(spec);
3764
3765 let cloned = registry.clone_for_validation();
3767 assert_eq!(cloned.routes().len(), registry.routes().len());
3768 assert_eq!(cloned.spec().title(), registry.spec().title());
3769 }
3770
3771 #[test]
3772 fn test_with_custom_fixture_loader() {
3773 let temp_dir = TempDir::new().unwrap();
3774 let spec_json = json!({
3775 "openapi": "3.0.0",
3776 "info": {
3777 "title": "Test API",
3778 "version": "1.0.0"
3779 },
3780 "paths": {}
3781 });
3782 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3783 let registry = OpenApiRouteRegistry::new(spec);
3784 let original_routes_len = registry.routes().len();
3785
3786 let custom_loader = Arc::new(crate::custom_fixture::CustomFixtureLoader::new(
3788 temp_dir.path().to_path_buf(),
3789 true,
3790 ));
3791 let registry_with_loader = registry.with_custom_fixture_loader(custom_loader);
3792
3793 assert_eq!(registry_with_loader.routes().len(), original_routes_len);
3795 }
3796
3797 #[test]
3798 fn test_get_route() {
3799 let spec_json = json!({
3800 "openapi": "3.0.0",
3801 "info": {
3802 "title": "Test API",
3803 "version": "1.0.0"
3804 },
3805 "paths": {
3806 "/users": {
3807 "get": {
3808 "operationId": "getUsers",
3809 "responses": {
3810 "200": {
3811 "description": "Success"
3812 }
3813 }
3814 },
3815 "post": {
3816 "operationId": "createUser",
3817 "responses": {
3818 "201": {
3819 "description": "Created"
3820 }
3821 }
3822 }
3823 }
3824 }
3825 });
3826 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3827 let registry = OpenApiRouteRegistry::new(spec);
3828
3829 let route = registry.get_route("/users", "GET");
3831 assert!(route.is_some());
3832 assert_eq!(route.unwrap().method, "GET");
3833 assert_eq!(route.unwrap().path, "/users");
3834
3835 let route = registry.get_route("/nonexistent", "GET");
3837 assert!(route.is_none());
3838
3839 let route = registry.get_route("/users", "POST");
3841 assert!(route.is_some());
3842 assert_eq!(route.unwrap().method, "POST");
3843 }
3844
3845 #[test]
3846 fn test_get_routes_for_path() {
3847 let spec_json = json!({
3848 "openapi": "3.0.0",
3849 "info": {
3850 "title": "Test API",
3851 "version": "1.0.0"
3852 },
3853 "paths": {
3854 "/users": {
3855 "get": {
3856 "responses": {
3857 "200": {
3858 "description": "Success"
3859 }
3860 }
3861 },
3862 "post": {
3863 "responses": {
3864 "201": {
3865 "description": "Created"
3866 }
3867 }
3868 },
3869 "put": {
3870 "responses": {
3871 "200": {
3872 "description": "Success"
3873 }
3874 }
3875 }
3876 },
3877 "/posts": {
3878 "get": {
3879 "responses": {
3880 "200": {
3881 "description": "Success"
3882 }
3883 }
3884 }
3885 }
3886 }
3887 });
3888 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3889 let registry = OpenApiRouteRegistry::new(spec);
3890
3891 let routes = registry.get_routes_for_path("/users");
3893 assert_eq!(routes.len(), 3);
3894 let methods: Vec<&str> = routes.iter().map(|r| r.method.as_str()).collect();
3895 assert!(methods.contains(&"GET"));
3896 assert!(methods.contains(&"POST"));
3897 assert!(methods.contains(&"PUT"));
3898
3899 let routes = registry.get_routes_for_path("/posts");
3901 assert_eq!(routes.len(), 1);
3902 assert_eq!(routes[0].method, "GET");
3903
3904 let routes = registry.get_routes_for_path("/nonexistent");
3906 assert!(routes.is_empty());
3907 }
3908
3909 #[test]
3910 fn test_new_vs_new_with_options() {
3911 let spec_json = json!({
3912 "openapi": "3.0.0",
3913 "info": {
3914 "title": "Test API",
3915 "version": "1.0.0"
3916 },
3917 "paths": {}
3918 });
3919 let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3920 let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3921
3922 let registry1 = OpenApiRouteRegistry::new(spec1);
3924 assert_eq!(registry1.spec().title(), "Test API");
3925
3926 let options = ValidationOptions {
3928 request_mode: ValidationMode::Disabled,
3929 aggregate_errors: false,
3930 validate_responses: true,
3931 overrides: HashMap::new(),
3932 admin_skip_prefixes: vec!["/admin".to_string()],
3933 response_template_expand: true,
3934 validation_status: Some(422),
3935 };
3936 let registry2 = OpenApiRouteRegistry::new_with_options(spec2, options);
3937 assert_eq!(registry2.spec().title(), "Test API");
3938 }
3939
3940 #[test]
3941 fn test_new_with_env_vs_new() {
3942 let spec_json = json!({
3943 "openapi": "3.0.0",
3944 "info": {
3945 "title": "Test API",
3946 "version": "1.0.0"
3947 },
3948 "paths": {}
3949 });
3950 let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3951 let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3952
3953 let registry1 = OpenApiRouteRegistry::new(spec1);
3955
3956 let registry2 = OpenApiRouteRegistry::new_with_env(spec2);
3958
3959 assert_eq!(registry1.spec().title(), "Test API");
3961 assert_eq!(registry2.spec().title(), "Test API");
3962 }
3963
3964 #[test]
3965 fn test_validation_options_custom() {
3966 let options = ValidationOptions {
3967 request_mode: ValidationMode::Warn,
3968 aggregate_errors: false,
3969 validate_responses: true,
3970 overrides: {
3971 let mut map = HashMap::new();
3972 map.insert("getUsers".to_string(), ValidationMode::Disabled);
3973 map
3974 },
3975 admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
3976 response_template_expand: true,
3977 validation_status: Some(422),
3978 };
3979
3980 assert!(matches!(options.request_mode, ValidationMode::Warn));
3981 assert!(!options.aggregate_errors);
3982 assert!(options.validate_responses);
3983 assert_eq!(options.overrides.len(), 1);
3984 assert_eq!(options.admin_skip_prefixes.len(), 2);
3985 assert!(options.response_template_expand);
3986 assert_eq!(options.validation_status, Some(422));
3987 }
3988
3989 #[test]
3990 fn test_validation_mode_default_standalone() {
3991 let mode = ValidationMode::default();
3992 assert!(matches!(mode, ValidationMode::Warn));
3993 }
3994
3995 #[test]
3996 fn test_validation_mode_clone() {
3997 let mode1 = ValidationMode::Enforce;
3998 let mode2 = mode1.clone();
3999 assert!(matches!(mode1, ValidationMode::Enforce));
4000 assert!(matches!(mode2, ValidationMode::Enforce));
4001 }
4002
4003 #[test]
4004 fn test_validation_mode_debug() {
4005 let mode = ValidationMode::Disabled;
4006 let debug_str = format!("{:?}", mode);
4007 assert!(debug_str.contains("Disabled") || debug_str.contains("ValidationMode"));
4008 }
4009
4010 #[test]
4011 fn test_validation_options_clone() {
4012 let options1 = ValidationOptions {
4013 request_mode: ValidationMode::Warn,
4014 aggregate_errors: true,
4015 validate_responses: false,
4016 overrides: HashMap::new(),
4017 admin_skip_prefixes: vec![],
4018 response_template_expand: false,
4019 validation_status: None,
4020 };
4021 let options2 = options1.clone();
4022 assert!(matches!(options2.request_mode, ValidationMode::Warn));
4023 assert_eq!(options1.aggregate_errors, options2.aggregate_errors);
4024 }
4025
4026 #[test]
4027 fn test_validation_options_debug() {
4028 let options = ValidationOptions::default();
4029 let debug_str = format!("{:?}", options);
4030 assert!(debug_str.contains("ValidationOptions"));
4031 }
4032
4033 #[test]
4034 fn test_validation_options_with_all_fields() {
4035 let mut overrides = HashMap::new();
4036 overrides.insert("op1".to_string(), ValidationMode::Disabled);
4037 overrides.insert("op2".to_string(), ValidationMode::Warn);
4038
4039 let options = ValidationOptions {
4040 request_mode: ValidationMode::Enforce,
4041 aggregate_errors: false,
4042 validate_responses: true,
4043 overrides: overrides.clone(),
4044 admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
4045 response_template_expand: true,
4046 validation_status: Some(422),
4047 };
4048
4049 assert!(matches!(options.request_mode, ValidationMode::Enforce));
4050 assert!(!options.aggregate_errors);
4051 assert!(options.validate_responses);
4052 assert_eq!(options.overrides.len(), 2);
4053 assert_eq!(options.admin_skip_prefixes.len(), 2);
4054 assert!(options.response_template_expand);
4055 assert_eq!(options.validation_status, Some(422));
4056 }
4057
4058 #[test]
4059 fn test_openapi_route_registry_clone() {
4060 let spec_json = json!({
4061 "openapi": "3.0.0",
4062 "info": { "title": "Test API", "version": "1.0.0" },
4063 "paths": {}
4064 });
4065 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4066 let registry1 = OpenApiRouteRegistry::new(spec);
4067 let registry2 = registry1.clone();
4068 assert_eq!(registry1.spec().title(), registry2.spec().title());
4069 }
4070
4071 #[test]
4072 fn test_validation_mode_serialization() {
4073 let mode = ValidationMode::Enforce;
4074 let json = serde_json::to_string(&mode).unwrap();
4075 assert!(json.contains("Enforce") || json.contains("enforce"));
4076 }
4077
4078 #[test]
4079 fn test_validation_mode_deserialization() {
4080 let json = r#""Disabled""#;
4081 let mode: ValidationMode = serde_json::from_str(json).unwrap();
4082 assert!(matches!(mode, ValidationMode::Disabled));
4083 }
4084
4085 #[test]
4086 fn test_validation_options_default_values() {
4087 let options = ValidationOptions::default();
4088 assert!(matches!(options.request_mode, ValidationMode::Enforce));
4089 assert!(options.aggregate_errors);
4090 assert!(!options.validate_responses);
4091 assert!(options.overrides.is_empty());
4092 assert!(options.admin_skip_prefixes.is_empty());
4093 assert!(!options.response_template_expand);
4094 assert_eq!(options.validation_status, None);
4095 }
4096
4097 #[test]
4098 fn test_validation_mode_all_variants() {
4099 let disabled = ValidationMode::Disabled;
4100 let warn = ValidationMode::Warn;
4101 let enforce = ValidationMode::Enforce;
4102
4103 assert!(matches!(disabled, ValidationMode::Disabled));
4104 assert!(matches!(warn, ValidationMode::Warn));
4105 assert!(matches!(enforce, ValidationMode::Enforce));
4106 }
4107
4108 #[test]
4109 fn test_validation_options_with_overrides() {
4110 let mut overrides = HashMap::new();
4111 overrides.insert("operation1".to_string(), ValidationMode::Disabled);
4112 overrides.insert("operation2".to_string(), ValidationMode::Warn);
4113
4114 let options = ValidationOptions {
4115 request_mode: ValidationMode::Enforce,
4116 aggregate_errors: true,
4117 validate_responses: false,
4118 overrides,
4119 admin_skip_prefixes: vec![],
4120 response_template_expand: false,
4121 validation_status: None,
4122 };
4123
4124 assert_eq!(options.overrides.len(), 2);
4125 assert!(matches!(options.overrides.get("operation1"), Some(ValidationMode::Disabled)));
4126 assert!(matches!(options.overrides.get("operation2"), Some(ValidationMode::Warn)));
4127 }
4128
4129 #[test]
4130 fn test_validation_options_with_admin_skip_prefixes() {
4131 let options = ValidationOptions {
4132 request_mode: ValidationMode::Enforce,
4133 aggregate_errors: true,
4134 validate_responses: false,
4135 overrides: HashMap::new(),
4136 admin_skip_prefixes: vec![
4137 "/admin".to_string(),
4138 "/internal".to_string(),
4139 "/debug".to_string(),
4140 ],
4141 response_template_expand: false,
4142 validation_status: None,
4143 };
4144
4145 assert_eq!(options.admin_skip_prefixes.len(), 3);
4146 assert!(options.admin_skip_prefixes.contains(&"/admin".to_string()));
4147 assert!(options.admin_skip_prefixes.contains(&"/internal".to_string()));
4148 assert!(options.admin_skip_prefixes.contains(&"/debug".to_string()));
4149 }
4150
4151 #[test]
4152 fn test_validation_options_with_validation_status() {
4153 let options1 = ValidationOptions {
4154 request_mode: ValidationMode::Enforce,
4155 aggregate_errors: true,
4156 validate_responses: false,
4157 overrides: HashMap::new(),
4158 admin_skip_prefixes: vec![],
4159 response_template_expand: false,
4160 validation_status: Some(400),
4161 };
4162
4163 let options2 = ValidationOptions {
4164 request_mode: ValidationMode::Enforce,
4165 aggregate_errors: true,
4166 validate_responses: false,
4167 overrides: HashMap::new(),
4168 admin_skip_prefixes: vec![],
4169 response_template_expand: false,
4170 validation_status: Some(422),
4171 };
4172
4173 assert_eq!(options1.validation_status, Some(400));
4174 assert_eq!(options2.validation_status, Some(422));
4175 }
4176
4177 #[test]
4178 fn test_validate_request_with_disabled_mode() {
4179 let spec_json = json!({
4181 "openapi": "3.0.0",
4182 "info": {"title": "Test API", "version": "1.0.0"},
4183 "paths": {
4184 "/users": {
4185 "get": {
4186 "responses": {"200": {"description": "OK"}}
4187 }
4188 }
4189 }
4190 });
4191 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4192 let options = ValidationOptions {
4193 request_mode: ValidationMode::Disabled,
4194 ..Default::default()
4195 };
4196 let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4197
4198 let result = registry.validate_request_with_all(
4200 "/users",
4201 "GET",
4202 &Map::new(),
4203 &Map::new(),
4204 &Map::new(),
4205 &Map::new(),
4206 None,
4207 );
4208 assert!(result.is_ok());
4209 }
4210
4211 #[test]
4212 fn test_validate_request_with_warn_mode() {
4213 let spec_json = json!({
4215 "openapi": "3.0.0",
4216 "info": {"title": "Test API", "version": "1.0.0"},
4217 "paths": {
4218 "/users": {
4219 "post": {
4220 "requestBody": {
4221 "required": true,
4222 "content": {
4223 "application/json": {
4224 "schema": {
4225 "type": "object",
4226 "required": ["name"],
4227 "properties": {
4228 "name": {"type": "string"}
4229 }
4230 }
4231 }
4232 }
4233 },
4234 "responses": {"200": {"description": "OK"}}
4235 }
4236 }
4237 }
4238 });
4239 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4240 let options = ValidationOptions {
4241 request_mode: ValidationMode::Warn,
4242 ..Default::default()
4243 };
4244 let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4245
4246 let result = registry.validate_request_with_all(
4248 "/users",
4249 "POST",
4250 &Map::new(),
4251 &Map::new(),
4252 &Map::new(),
4253 &Map::new(),
4254 None, );
4256 assert!(result.is_ok()); }
4258
4259 #[test]
4260 fn test_validate_request_body_validation_error() {
4261 let spec_json = json!({
4263 "openapi": "3.0.0",
4264 "info": {"title": "Test API", "version": "1.0.0"},
4265 "paths": {
4266 "/users": {
4267 "post": {
4268 "requestBody": {
4269 "required": true,
4270 "content": {
4271 "application/json": {
4272 "schema": {
4273 "type": "object",
4274 "required": ["name"],
4275 "properties": {
4276 "name": {"type": "string"}
4277 }
4278 }
4279 }
4280 }
4281 },
4282 "responses": {"200": {"description": "OK"}}
4283 }
4284 }
4285 }
4286 });
4287 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4288 let registry = OpenApiRouteRegistry::new(spec);
4289
4290 let result = registry.validate_request_with_all(
4292 "/users",
4293 "POST",
4294 &Map::new(),
4295 &Map::new(),
4296 &Map::new(),
4297 &Map::new(),
4298 None, );
4300 assert!(result.is_err());
4301 }
4302
4303 #[test]
4304 fn test_validate_request_body_schema_validation_error() {
4305 let spec_json = json!({
4307 "openapi": "3.0.0",
4308 "info": {"title": "Test API", "version": "1.0.0"},
4309 "paths": {
4310 "/users": {
4311 "post": {
4312 "requestBody": {
4313 "required": true,
4314 "content": {
4315 "application/json": {
4316 "schema": {
4317 "type": "object",
4318 "required": ["name"],
4319 "properties": {
4320 "name": {"type": "string"}
4321 }
4322 }
4323 }
4324 }
4325 },
4326 "responses": {"200": {"description": "OK"}}
4327 }
4328 }
4329 }
4330 });
4331 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4332 let registry = OpenApiRouteRegistry::new(spec);
4333
4334 let invalid_body = json!({}); let result = registry.validate_request_with_all(
4337 "/users",
4338 "POST",
4339 &Map::new(),
4340 &Map::new(),
4341 &Map::new(),
4342 &Map::new(),
4343 Some(&invalid_body),
4344 );
4345 assert!(result.is_err());
4346 }
4347
4348 #[test]
4349 fn test_validate_request_body_referenced_schema_error() {
4350 let spec_json = json!({
4352 "openapi": "3.0.0",
4353 "info": {"title": "Test API", "version": "1.0.0"},
4354 "paths": {
4355 "/users": {
4356 "post": {
4357 "requestBody": {
4358 "required": true,
4359 "content": {
4360 "application/json": {
4361 "schema": {
4362 "$ref": "#/components/schemas/NonExistentSchema"
4363 }
4364 }
4365 }
4366 },
4367 "responses": {"200": {"description": "OK"}}
4368 }
4369 }
4370 },
4371 "components": {}
4372 });
4373 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4374 let registry = OpenApiRouteRegistry::new(spec);
4375
4376 let body = json!({"name": "test"});
4378 let result = registry.validate_request_with_all(
4379 "/users",
4380 "POST",
4381 &Map::new(),
4382 &Map::new(),
4383 &Map::new(),
4384 &Map::new(),
4385 Some(&body),
4386 );
4387 assert!(result.is_err());
4388 }
4389
4390 #[test]
4391 fn test_validate_request_body_referenced_request_body_error() {
4392 let spec_json = json!({
4394 "openapi": "3.0.0",
4395 "info": {"title": "Test API", "version": "1.0.0"},
4396 "paths": {
4397 "/users": {
4398 "post": {
4399 "requestBody": {
4400 "$ref": "#/components/requestBodies/NonExistentRequestBody"
4401 },
4402 "responses": {"200": {"description": "OK"}}
4403 }
4404 }
4405 },
4406 "components": {}
4407 });
4408 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4409 let registry = OpenApiRouteRegistry::new(spec);
4410
4411 let body = json!({"name": "test"});
4413 let result = registry.validate_request_with_all(
4414 "/users",
4415 "POST",
4416 &Map::new(),
4417 &Map::new(),
4418 &Map::new(),
4419 &Map::new(),
4420 Some(&body),
4421 );
4422 assert!(result.is_err());
4423 }
4424
4425 #[test]
4426 fn test_validate_request_body_provided_when_not_expected() {
4427 let spec_json = json!({
4429 "openapi": "3.0.0",
4430 "info": {"title": "Test API", "version": "1.0.0"},
4431 "paths": {
4432 "/users": {
4433 "get": {
4434 "responses": {"200": {"description": "OK"}}
4435 }
4436 }
4437 }
4438 });
4439 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4440 let registry = OpenApiRouteRegistry::new(spec);
4441
4442 let body = json!({"extra": "data"});
4444 let result = registry.validate_request_with_all(
4445 "/users",
4446 "GET",
4447 &Map::new(),
4448 &Map::new(),
4449 &Map::new(),
4450 &Map::new(),
4451 Some(&body),
4452 );
4453 assert!(result.is_ok());
4455 }
4456
4457 #[test]
4458 fn test_get_operation() {
4459 let spec_json = json!({
4461 "openapi": "3.0.0",
4462 "info": {"title": "Test API", "version": "1.0.0"},
4463 "paths": {
4464 "/users": {
4465 "get": {
4466 "operationId": "getUsers",
4467 "summary": "Get users",
4468 "responses": {"200": {"description": "OK"}}
4469 }
4470 }
4471 }
4472 });
4473 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4474 let registry = OpenApiRouteRegistry::new(spec);
4475
4476 let operation = registry.get_operation("/users", "GET");
4478 assert!(operation.is_some());
4479 assert_eq!(operation.unwrap().method, "GET");
4480
4481 assert!(registry.get_operation("/nonexistent", "GET").is_none());
4483 }
4484
4485 #[test]
4486 fn test_extract_path_parameters() {
4487 let spec_json = json!({
4489 "openapi": "3.0.0",
4490 "info": {"title": "Test API", "version": "1.0.0"},
4491 "paths": {
4492 "/users/{id}": {
4493 "get": {
4494 "parameters": [
4495 {
4496 "name": "id",
4497 "in": "path",
4498 "required": true,
4499 "schema": {"type": "string"}
4500 }
4501 ],
4502 "responses": {"200": {"description": "OK"}}
4503 }
4504 }
4505 }
4506 });
4507 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4508 let registry = OpenApiRouteRegistry::new(spec);
4509
4510 let params = registry.extract_path_parameters("/users/123", "GET");
4512 assert_eq!(params.get("id"), Some(&"123".to_string()));
4513
4514 let empty_params = registry.extract_path_parameters("/users", "GET");
4516 assert!(empty_params.is_empty());
4517 }
4518
4519 #[test]
4520 fn extract_path_parameters_prefers_static_route_and_rejects_empty() {
4521 let spec_json = json!({
4524 "openapi": "3.0.0",
4525 "info": {"title": "Test API", "version": "1.0.0"},
4526 "paths": {
4527 "/users/{id}": { "get": { "responses": {"200": {"description": "OK"}} } },
4528 "/users/me": { "get": { "responses": {"200": {"description": "OK"}} } }
4529 }
4530 });
4531 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4532 let registry = OpenApiRouteRegistry::new(spec);
4533
4534 let me = registry.extract_path_parameters("/users/me", "GET");
4536 assert!(!me.contains_key("id"), "literal route should win, got {me:?}");
4537
4538 let by_id = registry.extract_path_parameters("/users/123", "GET");
4540 assert_eq!(by_id.get("id"), Some(&"123".to_string()));
4541
4542 let trailing = registry.extract_path_parameters("/users/", "GET");
4544 assert!(
4545 trailing.is_empty(),
4546 "empty trailing segment should not bind id, got {trailing:?}"
4547 );
4548 }
4549
4550 #[test]
4551 fn test_extract_path_parameters_multiple_params() {
4552 let spec_json = json!({
4554 "openapi": "3.0.0",
4555 "info": {"title": "Test API", "version": "1.0.0"},
4556 "paths": {
4557 "/users/{userId}/posts/{postId}": {
4558 "get": {
4559 "parameters": [
4560 {
4561 "name": "userId",
4562 "in": "path",
4563 "required": true,
4564 "schema": {"type": "string"}
4565 },
4566 {
4567 "name": "postId",
4568 "in": "path",
4569 "required": true,
4570 "schema": {"type": "string"}
4571 }
4572 ],
4573 "responses": {"200": {"description": "OK"}}
4574 }
4575 }
4576 }
4577 });
4578 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4579 let registry = OpenApiRouteRegistry::new(spec);
4580
4581 let params = registry.extract_path_parameters("/users/123/posts/456", "GET");
4583 assert_eq!(params.get("userId"), Some(&"123".to_string()));
4584 assert_eq!(params.get("postId"), Some(&"456".to_string()));
4585 }
4586
4587 #[test]
4588 fn test_validate_request_route_not_found() {
4589 let spec_json = json!({
4591 "openapi": "3.0.0",
4592 "info": {"title": "Test API", "version": "1.0.0"},
4593 "paths": {
4594 "/users": {
4595 "get": {
4596 "responses": {"200": {"description": "OK"}}
4597 }
4598 }
4599 }
4600 });
4601 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4602 let registry = OpenApiRouteRegistry::new(spec);
4603
4604 let result = registry.validate_request_with_all(
4606 "/nonexistent",
4607 "GET",
4608 &Map::new(),
4609 &Map::new(),
4610 &Map::new(),
4611 &Map::new(),
4612 None,
4613 );
4614 assert!(result.is_err());
4615 assert!(result.unwrap_err().to_string().contains("not found"));
4616 }
4617
4618 #[test]
4619 fn test_validate_request_with_path_parameters() {
4620 let spec_json = json!({
4622 "openapi": "3.0.0",
4623 "info": {"title": "Test API", "version": "1.0.0"},
4624 "paths": {
4625 "/users/{id}": {
4626 "get": {
4627 "parameters": [
4628 {
4629 "name": "id",
4630 "in": "path",
4631 "required": true,
4632 "schema": {"type": "string", "minLength": 1}
4633 }
4634 ],
4635 "responses": {"200": {"description": "OK"}}
4636 }
4637 }
4638 }
4639 });
4640 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4641 let registry = OpenApiRouteRegistry::new(spec);
4642
4643 let mut path_params = Map::new();
4645 path_params.insert("id".to_string(), json!("123"));
4646 let result = registry.validate_request_with_all(
4647 "/users/{id}",
4648 "GET",
4649 &path_params,
4650 &Map::new(),
4651 &Map::new(),
4652 &Map::new(),
4653 None,
4654 );
4655 assert!(result.is_ok());
4656 }
4657
4658 #[test]
4659 fn test_validate_request_with_query_parameters() {
4660 let spec_json = json!({
4662 "openapi": "3.0.0",
4663 "info": {"title": "Test API", "version": "1.0.0"},
4664 "paths": {
4665 "/users": {
4666 "get": {
4667 "parameters": [
4668 {
4669 "name": "page",
4670 "in": "query",
4671 "required": true,
4672 "schema": {"type": "integer", "minimum": 1}
4673 }
4674 ],
4675 "responses": {"200": {"description": "OK"}}
4676 }
4677 }
4678 }
4679 });
4680 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4681 let registry = OpenApiRouteRegistry::new(spec);
4682
4683 let mut query_params = Map::new();
4685 query_params.insert("page".to_string(), json!(1));
4686 let result = registry.validate_request_with_all(
4687 "/users",
4688 "GET",
4689 &Map::new(),
4690 &query_params,
4691 &Map::new(),
4692 &Map::new(),
4693 None,
4694 );
4695 assert!(result.is_ok());
4696 }
4697
4698 #[test]
4699 fn test_validate_request_with_header_parameters() {
4700 let spec_json = json!({
4702 "openapi": "3.0.0",
4703 "info": {"title": "Test API", "version": "1.0.0"},
4704 "paths": {
4705 "/users": {
4706 "get": {
4707 "parameters": [
4708 {
4709 "name": "X-API-Key",
4710 "in": "header",
4711 "required": true,
4712 "schema": {"type": "string"}
4713 }
4714 ],
4715 "responses": {"200": {"description": "OK"}}
4716 }
4717 }
4718 }
4719 });
4720 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4721 let registry = OpenApiRouteRegistry::new(spec);
4722
4723 let mut header_params = Map::new();
4725 header_params.insert("X-API-Key".to_string(), json!("secret-key"));
4726 let result = registry.validate_request_with_all(
4727 "/users",
4728 "GET",
4729 &Map::new(),
4730 &Map::new(),
4731 &header_params,
4732 &Map::new(),
4733 None,
4734 );
4735 assert!(result.is_ok());
4736 }
4737
4738 #[test]
4739 fn test_validate_request_with_cookie_parameters() {
4740 let spec_json = json!({
4742 "openapi": "3.0.0",
4743 "info": {"title": "Test API", "version": "1.0.0"},
4744 "paths": {
4745 "/users": {
4746 "get": {
4747 "parameters": [
4748 {
4749 "name": "sessionId",
4750 "in": "cookie",
4751 "required": true,
4752 "schema": {"type": "string"}
4753 }
4754 ],
4755 "responses": {"200": {"description": "OK"}}
4756 }
4757 }
4758 }
4759 });
4760 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4761 let registry = OpenApiRouteRegistry::new(spec);
4762
4763 let mut cookie_params = Map::new();
4765 cookie_params.insert("sessionId".to_string(), json!("abc123"));
4766 let result = registry.validate_request_with_all(
4767 "/users",
4768 "GET",
4769 &Map::new(),
4770 &Map::new(),
4771 &Map::new(),
4772 &cookie_params,
4773 None,
4774 );
4775 assert!(result.is_ok());
4776 }
4777
4778 #[test]
4779 fn test_validate_request_no_errors_early_return() {
4780 let spec_json = json!({
4782 "openapi": "3.0.0",
4783 "info": {"title": "Test API", "version": "1.0.0"},
4784 "paths": {
4785 "/users": {
4786 "get": {
4787 "responses": {"200": {"description": "OK"}}
4788 }
4789 }
4790 }
4791 });
4792 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4793 let registry = OpenApiRouteRegistry::new(spec);
4794
4795 let result = registry.validate_request_with_all(
4797 "/users",
4798 "GET",
4799 &Map::new(),
4800 &Map::new(),
4801 &Map::new(),
4802 &Map::new(),
4803 None,
4804 );
4805 assert!(result.is_ok());
4806 }
4807
4808 #[test]
4809 fn test_validate_request_query_parameter_different_styles() {
4810 let spec_json = json!({
4812 "openapi": "3.0.0",
4813 "info": {"title": "Test API", "version": "1.0.0"},
4814 "paths": {
4815 "/users": {
4816 "get": {
4817 "parameters": [
4818 {
4819 "name": "tags",
4820 "in": "query",
4821 "style": "pipeDelimited",
4822 "schema": {
4823 "type": "array",
4824 "items": {"type": "string"}
4825 }
4826 }
4827 ],
4828 "responses": {"200": {"description": "OK"}}
4829 }
4830 }
4831 }
4832 });
4833 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4834 let registry = OpenApiRouteRegistry::new(spec);
4835
4836 let mut query_params = Map::new();
4838 query_params.insert("tags".to_string(), json!(["tag1", "tag2"]));
4839 let result = registry.validate_request_with_all(
4840 "/users",
4841 "GET",
4842 &Map::new(),
4843 &query_params,
4844 &Map::new(),
4845 &Map::new(),
4846 None,
4847 );
4848 assert!(result.is_ok() || result.is_err()); }
4851}