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