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