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