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 mockforge_foundation::conformance_violations::record(
719 mockforge_foundation::conformance_violations::ServerConformanceViolation {
720 timestamp: Utc::now(),
721 method: method.to_string(),
722 path: path_template.clone(),
723 client_ip: "unknown".to_string(),
724 status: status_code,
725 reason: ct_err.clone(),
726 category: "content-types".to_string(),
727 },
728 );
729 let status = axum::http::StatusCode::from_u16(status_code)
730 .unwrap_or(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
731 let payload = serde_json::json!({
732 "error": "content_type_not_allowed",
733 "message": ct_err,
734 });
735 let body_bytes = serde_json::to_vec(&payload)
736 .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
737 return axum::response::Response::builder()
738 .status(status)
739 .header("content-type", "application/json")
740 .body(axum::body::Body::from(body_bytes))
741 .unwrap_or_else(|_| {
742 axum::response::Response::builder()
743 .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
744 .body(axum::body::Body::empty())
745 .unwrap()
746 });
747 }
748
749 if let Err(e) = validator.validate_request_with_all(
750 &path_template,
751 &method,
752 &path_map,
753 &query_map,
754 &header_map,
755 &cookie_map,
756 body_json.as_ref(),
757 ) {
758 let status_code =
760 validator.options.validation_status.unwrap_or_else(|| {
761 std::env::var("MOCKFORGE_VALIDATION_STATUS")
762 .ok()
763 .and_then(|s| s.parse::<u16>().ok())
764 .unwrap_or(400)
765 });
766
767 let payload = if status_code == 422 {
768 generate_enhanced_422_response(
770 &validator,
771 &path_template,
772 &method,
773 body_json.as_ref(),
774 &path_map,
775 &query_map,
776 &header_map,
777 &cookie_map,
778 )
779 } else {
780 let msg = format!("{}", e);
782 let detail_val = serde_json::from_str::<Value>(&msg)
783 .unwrap_or(serde_json::json!(msg));
784 json!({
785 "error": "request validation failed",
786 "detail": detail_val,
787 "method": method,
788 "path": path_template,
789 "timestamp": Utc::now().to_rfc3339(),
790 })
791 };
792
793 record_validation_error(&payload);
794
795 let reason = payload
802 .get("detail")
803 .and_then(|d| {
804 if d.is_string() {
805 d.as_str().map(|s| s.to_string())
806 } else {
807 serde_json::to_string(d).ok()
808 }
809 })
810 .unwrap_or_else(|| {
811 payload
812 .get("error")
813 .and_then(|v| v.as_str())
814 .unwrap_or("request validation failed")
815 .to_string()
816 });
817 let category = classify_validation_reason(&reason);
818 mockforge_foundation::conformance_violations::record(
819 mockforge_foundation::conformance_violations::ServerConformanceViolation {
820 timestamp: Utc::now(),
821 method: method.to_string(),
822 path: path_template.clone(),
823 client_ip: "unknown".to_string(),
824 status: status_code,
825 reason,
826 category,
827 },
828 );
829
830 let status = axum::http::StatusCode::from_u16(status_code)
831 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
832
833 let body_bytes = serde_json::to_vec(&payload)
835 .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
836
837 return axum::http::Response::builder()
838 .status(status)
839 .header(axum::http::header::CONTENT_TYPE, "application/json")
840 .body(axum::body::Body::from(body_bytes))
841 .expect("Response builder should create valid response with valid headers and body");
842 }
843 }
844
845 let mut final_response = mock_response.clone();
854 let env_expand: Option<bool> = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
855 .ok()
856 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"));
857 let expand = match env_expand {
858 Some(v) => v,
859 None => {
860 ctx.enable_template_expand || validator.options.response_template_expand
861 }
862 };
863 if expand {
864 if let Some(ref rewriter) = ctx.response_rewriter {
865 rewriter.expand_tokens(&mut final_response);
866 }
867 }
868
869 if ctx.overrides_enabled {
871 if let Some(ref rewriter) = ctx.response_rewriter {
872 let op_tags =
873 operation.operation_id.clone().map(|id| vec![id]).unwrap_or_default();
874 rewriter.apply_overrides(
875 &operation.operation_id.clone().unwrap_or_default(),
876 &op_tags,
877 &path_template,
878 &mut final_response,
879 );
880 }
881 }
882
883 if ctx.enable_full_validation {
885 if validator.options.validate_responses {
887 if let Some((status_code, _response)) = operation
889 .responses
890 .responses
891 .iter()
892 .filter_map(|(status, resp)| match status {
893 openapiv3::StatusCode::Code(code)
894 if *code >= 200 && *code < 300 =>
895 {
896 resp.as_item().map(|r| ((*code), r))
897 }
898 openapiv3::StatusCode::Range(range)
899 if *range >= 200 && *range < 300 =>
900 {
901 resp.as_item().map(|r| (200, r))
902 }
903 _ => None,
904 })
905 .next()
906 {
907 if serde_json::from_value::<Value>(final_response.clone()).is_err() {
909 tracing::warn!(
910 "Response validation failed: invalid JSON for status {}",
911 status_code
912 );
913 }
914 }
915 }
916
917 let mut trace = ResponseGenerationTrace::new();
919 trace.set_final_payload(final_response.clone());
920
921 if let Some((_status_code, response_ref)) = operation
923 .responses
924 .responses
925 .iter()
926 .filter_map(|(status, resp)| match status {
927 openapiv3::StatusCode::Code(code) if *code == selected_status => {
928 resp.as_item().map(|r| ((*code), r))
929 }
930 openapiv3::StatusCode::Range(range)
931 if *range >= 200 && *range < 300 =>
932 {
933 resp.as_item().map(|r| (200, r))
934 }
935 _ => None,
936 })
937 .next()
938 .or_else(|| {
939 operation
941 .responses
942 .responses
943 .iter()
944 .filter_map(|(status, resp)| match status {
945 openapiv3::StatusCode::Code(code)
946 if *code >= 200 && *code < 300 =>
947 {
948 resp.as_item().map(|r| ((*code), r))
949 }
950 _ => None,
951 })
952 .next()
953 })
954 {
955 let response_item = response_ref;
957 if let Some(content) = response_item.content.get("application/json") {
959 if let Some(schema_ref) = &content.schema {
960 if let Some(schema) = schema_ref.as_item() {
962 if let Ok(schema_json) = serde_json::to_value(schema) {
963 let validation_errors =
965 validation_diff(&schema_json, &final_response);
966 trace.set_schema_validation_diff(validation_errors);
967 }
968 }
969 }
970 }
971 }
972
973 let mut response = Json(final_response).into_response();
975 response.extensions_mut().insert(trace);
976 *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
977 .unwrap_or(axum::http::StatusCode::OK);
978 return response;
979 }
980
981 let mut response = Json(final_response).into_response();
983 *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
984 .unwrap_or(axum::http::StatusCode::OK);
985 response
986 };
987
988 router = Self::route_for_method(router, axum_path, &route.method, handler);
989 }
990
991 if ctx.add_spec_endpoint {
993 let spec_json = serde_json::to_value(&self.spec.spec).unwrap_or(Value::Null);
994 router = router.route("/openapi.json", get(move || async move { Json(spec_json) }));
995 }
996
997 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
1001 }
1002
1003 pub fn build_router_with_latency(self, latency_injector: LatencyInjector) -> Router {
1005 self.build_router_with_injectors(latency_injector, None)
1006 }
1007
1008 pub fn build_router_with_injectors(
1010 self,
1011 latency_injector: LatencyInjector,
1012 failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1013 ) -> Router {
1014 self.build_router_with_injectors_and_overrides(
1015 latency_injector,
1016 failure_injector,
1017 None,
1018 false,
1019 )
1020 }
1021
1022 pub fn build_router_with_injectors_and_overrides(
1026 self,
1027 latency_injector: LatencyInjector,
1028 failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1029 response_rewriter: Option<Arc<dyn ResponseRewriter>>,
1030 overrides_enabled: bool,
1031 ) -> Router {
1032 let ctx = RouterContext {
1033 custom_fixture_loader: self.custom_fixture_loader.clone(),
1034 latency_injector: Some(latency_injector),
1035 failure_injector,
1036 response_rewriter,
1037 overrides_enabled,
1038 enable_full_validation: true,
1039 enable_template_expand: true,
1040 add_spec_endpoint: true,
1041 ..Default::default()
1042 };
1043 self.build_router_with_context(ctx)
1044 }
1045
1046 pub fn get_route(&self, path: &str, method: &str) -> Option<&OpenApiRoute> {
1048 self.routes.iter().find(|route| route.path == path && route.method == method)
1049 }
1050
1051 pub fn get_routes_for_path(&self, path: &str) -> Vec<&OpenApiRoute> {
1053 self.routes.iter().filter(|route| route.path == path).collect()
1054 }
1055
1056 pub fn validate_request(&self, path: &str, method: &str, body: Option<&Value>) -> Result<()> {
1058 self.validate_request_with(path, method, &Map::new(), &Map::new(), body)
1059 }
1060
1061 pub fn check_request_content_type(
1077 &self,
1078 path: &str,
1079 method: &str,
1080 actual_content_type: Option<&str>,
1081 ) -> std::result::Result<(), String> {
1082 let Some(route) = self.get_route(path, method) else {
1083 return Ok(());
1084 };
1085 let Some(rb_ref) = &route.operation.request_body else {
1086 return Ok(());
1087 };
1088 let request_body = match rb_ref {
1089 openapiv3::ReferenceOr::Item(rb) => rb,
1090 openapiv3::ReferenceOr::Reference { reference } => {
1091 let resolved = self
1092 .spec
1093 .spec
1094 .components
1095 .as_ref()
1096 .and_then(|components| {
1097 components
1098 .request_bodies
1099 .get(reference.trim_start_matches("#/components/requestBodies/"))
1100 })
1101 .and_then(|rb_ref| rb_ref.as_item());
1102 let Some(rb) = resolved else { return Ok(()) };
1103 rb
1104 }
1105 };
1106 if request_body.content.is_empty() {
1107 return Ok(());
1108 }
1109 let actual = actual_content_type
1110 .and_then(|s| s.split(';').next())
1111 .map(|s| s.trim().to_ascii_lowercase());
1112 let Some(actual) = actual else {
1113 return Ok(());
1118 };
1119 let allowed: Vec<String> = request_body
1120 .content
1121 .keys()
1122 .map(|k| k.split(';').next().unwrap_or(k).trim().to_ascii_lowercase())
1123 .collect();
1124 if allowed.iter().any(|a| a == &actual) {
1125 return Ok(());
1126 }
1127 Err(format!(
1128 "Content-Type '{actual}' not allowed; spec declares: [{}]",
1129 allowed.join(", ")
1130 ))
1131 }
1132
1133 pub fn validate_request_with(
1135 &self,
1136 path: &str,
1137 method: &str,
1138 path_params: &Map<String, Value>,
1139 query_params: &Map<String, Value>,
1140 body: Option<&Value>,
1141 ) -> Result<()> {
1142 self.validate_request_with_all(
1143 path,
1144 method,
1145 path_params,
1146 query_params,
1147 &Map::new(),
1148 &Map::new(),
1149 body,
1150 )
1151 }
1152
1153 #[allow(clippy::too_many_arguments)]
1166 pub fn run_validation_with_recording(
1167 &self,
1168 path_template: &str,
1169 method: &str,
1170 path_params: &Map<String, Value>,
1171 query_params: &Map<String, Value>,
1172 header_map: &Map<String, Value>,
1173 cookie_map: &Map<String, Value>,
1174 body: Option<&Value>,
1175 ) -> std::result::Result<(), (u16, Value)> {
1176 let e = match self.validate_request_with_all(
1177 path_template,
1178 method,
1179 path_params,
1180 query_params,
1181 header_map,
1182 cookie_map,
1183 body,
1184 ) {
1185 Ok(()) => {
1186 mockforge_foundation::conformance_violations::record_ok();
1190 return Ok(());
1191 }
1192 Err(e) => e,
1193 };
1194
1195 let status_code = self.options.validation_status.unwrap_or_else(|| {
1196 std::env::var("MOCKFORGE_VALIDATION_STATUS")
1197 .ok()
1198 .and_then(|s| s.parse::<u16>().ok())
1199 .unwrap_or(400)
1200 });
1201
1202 let payload = if status_code == 422 {
1203 generate_enhanced_422_response(
1204 self,
1205 path_template,
1206 method,
1207 body,
1208 path_params,
1209 query_params,
1210 header_map,
1211 cookie_map,
1212 )
1213 } else {
1214 let msg = format!("{}", e);
1215 let detail_val = serde_json::from_str::<Value>(&msg).unwrap_or(serde_json::json!(msg));
1216 json!({
1217 "error": "request validation failed",
1218 "detail": detail_val,
1219 "method": method,
1220 "path": path_template,
1221 "timestamp": Utc::now().to_rfc3339(),
1222 })
1223 };
1224
1225 record_validation_error(&payload);
1226
1227 let reason = payload
1228 .get("detail")
1229 .and_then(|d| {
1230 if d.is_string() {
1231 d.as_str().map(|s| s.to_string())
1232 } else {
1233 serde_json::to_string(d).ok()
1234 }
1235 })
1236 .unwrap_or_else(|| {
1237 payload
1238 .get("error")
1239 .and_then(|v| v.as_str())
1240 .unwrap_or("request validation failed")
1241 .to_string()
1242 });
1243 let category = classify_validation_reason(&reason);
1244 tracing::debug!(
1252 target: "mockforge::conformance",
1253 method = %method,
1254 path = %path_template,
1255 status = status_code,
1256 category = %category,
1257 reason = %reason,
1258 "request conformance violation"
1259 );
1260 mockforge_foundation::conformance_violations::record(
1261 mockforge_foundation::conformance_violations::ServerConformanceViolation {
1262 timestamp: Utc::now(),
1263 method: method.to_string(),
1264 path: path_template.to_string(),
1265 client_ip: "unknown".to_string(),
1266 status: status_code,
1267 reason,
1268 category,
1269 },
1270 );
1271
1272 if mockforge_foundation::unknown_paths::shadow_mode_enabled() {
1279 return Ok(());
1280 }
1281
1282 Err((status_code, payload))
1283 }
1284
1285 #[allow(clippy::too_many_arguments)]
1287 pub fn validate_request_with_all(
1288 &self,
1289 path: &str,
1290 method: &str,
1291 path_params: &Map<String, Value>,
1292 query_params: &Map<String, Value>,
1293 header_params: &Map<String, Value>,
1294 cookie_params: &Map<String, Value>,
1295 body: Option<&Value>,
1296 ) -> Result<()> {
1297 for pref in &self.options.admin_skip_prefixes {
1299 if !pref.is_empty() && path.starts_with(pref) {
1300 return Ok(());
1301 }
1302 }
1303 let env_mode = std::env::var("MOCKFORGE_REQUEST_VALIDATION").ok().map(|v| {
1305 match v.to_ascii_lowercase().as_str() {
1306 "off" | "disable" | "disabled" => ValidationMode::Disabled,
1307 "warn" | "warning" => ValidationMode::Warn,
1308 _ => ValidationMode::Enforce,
1309 }
1310 });
1311 let aggregate = std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
1312 .ok()
1313 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1314 .unwrap_or(self.options.aggregate_errors);
1315 let env_overrides: Option<Map<String, Value>> =
1317 std::env::var("MOCKFORGE_VALIDATION_OVERRIDES_JSON")
1318 .ok()
1319 .and_then(|s| serde_json::from_str::<Value>(&s).ok())
1320 .and_then(|v| v.as_object().cloned());
1321 let mut effective_mode = env_mode.unwrap_or(self.options.request_mode.clone());
1323 if let Some(map) = &env_overrides {
1325 if let Some(v) = map.get(&format!("{} {}", method, path)) {
1326 if let Some(m) = v.as_str() {
1327 effective_mode = match m {
1328 "off" => ValidationMode::Disabled,
1329 "warn" => ValidationMode::Warn,
1330 _ => ValidationMode::Enforce,
1331 };
1332 }
1333 }
1334 }
1335 if let Some(override_mode) = self.options.overrides.get(&format!("{} {}", method, path)) {
1337 effective_mode = override_mode.clone();
1338 }
1339 if matches!(effective_mode, ValidationMode::Disabled) {
1340 return Ok(());
1341 }
1342 if let Some(route) = self.get_route(path, method) {
1343 if matches!(effective_mode, ValidationMode::Disabled) {
1344 return Ok(());
1345 }
1346 let mut errors: Vec<String> = Vec::new();
1347 let mut details: Vec<Value> = Vec::new();
1348 if let Some(schema) = &route.operation.request_body {
1350 if let Some(value) = body {
1351 let request_body = match schema {
1353 openapiv3::ReferenceOr::Item(rb) => Some(rb),
1354 openapiv3::ReferenceOr::Reference { reference } => {
1355 self.spec
1357 .spec
1358 .components
1359 .as_ref()
1360 .and_then(|components| {
1361 components.request_bodies.get(
1362 reference.trim_start_matches("#/components/requestBodies/"),
1363 )
1364 })
1365 .and_then(|rb_ref| rb_ref.as_item())
1366 }
1367 };
1368
1369 if let Some(rb) = request_body {
1370 if let Some(content) = rb.content.get("application/json") {
1371 if let Some(schema_ref) = &content.schema {
1372 let root_schema = match schema_ref {
1385 openapiv3::ReferenceOr::Item(s) => Some((*s).clone()),
1386 openapiv3::ReferenceOr::Reference { reference } => {
1387 self.spec.get_schema(reference).map(|s| s.schema.clone())
1388 }
1389 };
1390 if let Some(root_schema) = root_schema {
1391 let result = crate::schema_ref_resolver::build_validator(
1392 &root_schema,
1393 &self.spec.spec,
1394 )
1395 .and_then(|validator| {
1396 let errs: Vec<String> = validator
1397 .iter_errors(value)
1398 .map(|e| e.to_string())
1399 .collect();
1400 if errs.is_empty() {
1401 Ok(())
1402 } else {
1403 Err(errs.join("; "))
1404 }
1405 });
1406 if let Err(error_msg) = result {
1407 errors
1408 .push(format!("body validation failed: {}", error_msg));
1409 if aggregate {
1410 details.push(serde_json::json!({"path":"body","code":"schema_validation","message":error_msg}));
1411 }
1412 }
1413 } else if let openapiv3::ReferenceOr::Reference { reference } =
1414 schema_ref
1415 {
1416 errors.push(format!("body validation failed: could not resolve schema reference {}", reference));
1418 if aggregate {
1419 details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve schema reference"}));
1420 }
1421 }
1422 }
1423 }
1424 } else {
1425 errors.push("body validation failed: could not resolve request body or no application/json content".to_string());
1427 if aggregate {
1428 details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve request body reference"}));
1429 }
1430 }
1431 } else {
1432 errors.push("body: Request body is required but not provided".to_string());
1433 details.push(serde_json::json!({"path":"body","code":"required","message":"Request body is required"}));
1434 }
1435 } else if body.is_some() {
1436 tracing::debug!("Body provided for operation without requestBody; accepting");
1438 }
1439
1440 for p_ref in &route.operation.parameters {
1442 if let Some(p) = p_ref.as_item() {
1443 match p {
1444 openapiv3::Parameter::Path { parameter_data, .. } => {
1445 validate_parameter(
1446 parameter_data,
1447 path_params,
1448 "path",
1449 aggregate,
1450 &mut errors,
1451 &mut details,
1452 );
1453 }
1454 openapiv3::Parameter::Query {
1455 parameter_data,
1456 style,
1457 ..
1458 } => {
1459 let deep_value = if matches!(style, openapiv3::QueryStyle::DeepObject) {
1462 let prefix_bracket = format!("{}[", parameter_data.name);
1463 let mut obj = Map::new();
1464 for (key, val) in query_params.iter() {
1465 if let Some(rest) = key.strip_prefix(&prefix_bracket) {
1466 if let Some(prop) = rest.strip_suffix(']') {
1467 obj.insert(prop.to_string(), val.clone());
1468 }
1469 }
1470 }
1471 if obj.is_empty() {
1472 None
1473 } else {
1474 Some(Value::Object(obj))
1475 }
1476 } else {
1477 None
1478 };
1479 let style_str = match style {
1480 openapiv3::QueryStyle::Form => Some("form"),
1481 openapiv3::QueryStyle::SpaceDelimited => Some("spaceDelimited"),
1482 openapiv3::QueryStyle::PipeDelimited => Some("pipeDelimited"),
1483 openapiv3::QueryStyle::DeepObject => Some("deepObject"),
1484 };
1485 validate_parameter_with_deep_object(
1486 parameter_data,
1487 query_params,
1488 "query",
1489 deep_value,
1490 style_str,
1491 aggregate,
1492 &mut errors,
1493 &mut details,
1494 );
1495 }
1496 openapiv3::Parameter::Header { parameter_data, .. } => {
1497 validate_parameter(
1498 parameter_data,
1499 header_params,
1500 "header",
1501 aggregate,
1502 &mut errors,
1503 &mut details,
1504 );
1505 }
1506 openapiv3::Parameter::Cookie { parameter_data, .. } => {
1507 validate_parameter(
1508 parameter_data,
1509 cookie_params,
1510 "cookie",
1511 aggregate,
1512 &mut errors,
1513 &mut details,
1514 );
1515 }
1516 }
1517 }
1518 }
1519 if errors.is_empty() {
1520 return Ok(());
1521 }
1522 match effective_mode {
1523 ValidationMode::Disabled => Ok(()),
1524 ValidationMode::Warn => {
1525 tracing::warn!("Request validation warnings: {:?}", errors);
1526 Ok(())
1527 }
1528 ValidationMode::Enforce => Err(Error::validation(
1529 serde_json::json!({"errors": errors, "details": details}).to_string(),
1530 )),
1531 }
1532 } else {
1533 Err(Error::internal(format!("Route {} {} not found in OpenAPI spec", method, path)))
1534 }
1535 }
1536
1537 pub fn paths(&self) -> Vec<String> {
1541 let mut paths: Vec<String> = self.routes.iter().map(|route| route.path.clone()).collect();
1542 paths.sort();
1543 paths.dedup();
1544 paths
1545 }
1546
1547 pub fn methods(&self) -> Vec<String> {
1549 let mut methods: Vec<String> =
1550 self.routes.iter().map(|route| route.method.clone()).collect();
1551 methods.sort();
1552 methods.dedup();
1553 methods
1554 }
1555
1556 pub fn get_operation(&self, path: &str, method: &str) -> Option<OpenApiOperation> {
1558 self.get_route(path, method).map(|route| {
1559 OpenApiOperation::from_operation(
1560 &route.method,
1561 route.path.clone(),
1562 &route.operation,
1563 &self.spec,
1564 )
1565 })
1566 }
1567
1568 pub fn extract_path_parameters(&self, path: &str, method: &str) -> HashMap<String, String> {
1570 let mut best: Option<(usize, HashMap<String, String>)> = None;
1576 for route in &self.routes {
1577 if route.method != method {
1578 continue;
1579 }
1580
1581 if let Some(params) = self.match_path_to_route(path, &route.path) {
1582 let static_segments = route
1583 .path
1584 .trim_start_matches('/')
1585 .split('/')
1586 .filter(|s| !(s.starts_with('{') && s.ends_with('}')))
1587 .count();
1588 let is_more_specific = match &best {
1589 None => true,
1590 Some((score, _)) => static_segments > *score,
1591 };
1592 if is_more_specific {
1593 best = Some((static_segments, params));
1594 }
1595 }
1596 }
1597 best.map(|(_, params)| params).unwrap_or_default()
1598 }
1599
1600 fn match_path_to_route(
1602 &self,
1603 request_path: &str,
1604 route_pattern: &str,
1605 ) -> Option<HashMap<String, String>> {
1606 let mut params = HashMap::new();
1607
1608 let request_segments: Vec<&str> = request_path.trim_start_matches('/').split('/').collect();
1610 let pattern_segments: Vec<&str> =
1611 route_pattern.trim_start_matches('/').split('/').collect();
1612
1613 if request_segments.len() != pattern_segments.len() {
1614 return None;
1615 }
1616
1617 for (req_seg, pat_seg) in request_segments.iter().zip(pattern_segments.iter()) {
1618 if pat_seg.starts_with('{') && pat_seg.ends_with('}') {
1619 if req_seg.is_empty() {
1624 return None;
1625 }
1626 let param_name = &pat_seg[1..pat_seg.len() - 1];
1627 params.insert(param_name.to_string(), req_seg.to_string());
1628 } else if req_seg != pat_seg {
1629 return None;
1631 }
1632 }
1633
1634 Some(params)
1635 }
1636
1637 pub fn convert_path_to_axum(openapi_path: &str) -> String {
1640 openapi_path.to_string()
1642 }
1643
1644 pub fn build_router_with_ai(
1646 &self,
1647 ai_generator: Option<Arc<dyn AiGenerator + Send + Sync>>,
1648 ) -> Router {
1649 let mut router = Router::new();
1650 let deduped = self.deduplicated_routes();
1651 tracing::debug!("Building router with AI support from {} routes", self.routes.len());
1652
1653 let validator = Arc::new(self.clone_for_validation());
1657 for (axum_path, route) in &deduped {
1658 tracing::debug!("Adding AI-enabled route: {} {}", route.method, route.path);
1659
1660 let route_clone = (*route).clone();
1661 let ai_generator_clone = ai_generator.clone();
1662 let validator_clone = validator.clone();
1666
1667 let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
1669 axum::extract::Query(query_params): axum::extract::Query<
1670 HashMap<String, String>,
1671 >,
1672 headers: HeaderMap,
1673 body: Option<Json<Value>>| {
1674 let route = route_clone.clone();
1675 let ai_generator = ai_generator_clone.clone();
1676 let validator = validator_clone.clone();
1677
1678 async move {
1679 let mut path_map = Map::new();
1684 for (k, v) in &path_params {
1685 path_map.insert(k.clone(), Value::String(v.clone()));
1686 }
1687 let mut query_map = Map::new();
1688 for (k, v) in &query_params {
1689 query_map.insert(k.clone(), Value::String(v.clone()));
1690 }
1691 let mut header_map = Map::new();
1692 for (k, v) in headers.iter() {
1693 if let Ok(s) = v.to_str() {
1694 header_map.insert(k.to_string(), Value::String(s.to_string()));
1695 }
1696 }
1697 let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
1698 if let Err((status_code, payload)) = validator.run_validation_with_recording(
1699 &route.path,
1700 &route.method,
1701 &path_map,
1702 &query_map,
1703 &header_map,
1704 &Map::new(),
1705 body_val,
1706 ) {
1707 let status = axum::http::StatusCode::from_u16(status_code)
1708 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
1709 return (status, Json(payload));
1710 }
1711
1712 tracing::debug!(
1713 "Handling AI request for route: {} {}",
1714 route.method,
1715 route.path
1716 );
1717
1718 let mut context = RequestContext::new(route.method.clone(), route.path.clone());
1720
1721 context.headers = headers
1723 .iter()
1724 .map(|(k, v)| {
1725 (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string()))
1726 })
1727 .collect();
1728
1729 context.body = body.map(|Json(b)| b);
1731
1732 let (status, response) = if let (Some(generator), Some(_ai_config)) =
1734 (ai_generator, &route.ai_config)
1735 {
1736 route
1737 .mock_response_with_status_async(&context, Some(generator.as_ref()))
1738 .await
1739 } else {
1740 route.mock_response_with_status()
1742 };
1743
1744 (
1745 axum::http::StatusCode::from_u16(status)
1746 .unwrap_or(axum::http::StatusCode::OK),
1747 Json(response),
1748 )
1749 }
1750 };
1751
1752 router = Self::route_for_method(router, axum_path, &route.method, handler);
1753 }
1754
1755 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
1759 }
1760
1761 pub fn build_router_with_mockai(
1772 &self,
1773 mockai: Option<
1774 Arc<
1775 tokio::sync::RwLock<
1776 dyn mockforge_foundation::intelligent_behavior::MockAiBehavior + Send + Sync,
1777 >,
1778 >,
1779 >,
1780 ) -> Router {
1781 use mockforge_foundation::intelligent_behavior::Request as MockAIRequest;
1782
1783 let mut router = Router::new();
1784 let deduped = self.deduplicated_routes();
1785 tracing::debug!("Building router with MockAI support from {} routes", self.routes.len());
1786
1787 let custom_loader = self.custom_fixture_loader.clone();
1788 let validator = Arc::new(self.clone_for_validation());
1792 for (axum_path, route) in &deduped {
1793 tracing::debug!("Adding MockAI-enabled route: {} {}", route.method, route.path);
1794
1795 let route_clone = (*route).clone();
1796 let mockai_clone = mockai.clone();
1797 let custom_loader_clone = custom_loader.clone();
1798 let validator_clone = validator.clone();
1804
1805 let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
1809 query: axum::extract::Query<HashMap<String, String>>,
1810 headers: HeaderMap,
1811 body: Option<Json<Value>>| {
1812 let route = route_clone.clone();
1813 let mockai = mockai_clone.clone();
1814 let validator = validator_clone.clone();
1815
1816 async move {
1817 let mut path_map = Map::new();
1822 for (k, v) in &path_params {
1823 path_map.insert(k.clone(), Value::String(v.clone()));
1824 }
1825 let mut query_map = Map::new();
1826 for (k, v) in &query.0 {
1827 query_map.insert(k.clone(), Value::String(v.clone()));
1828 }
1829 let mut header_map = Map::new();
1830 for (k, v) in headers.iter() {
1831 if let Ok(s) = v.to_str() {
1832 header_map.insert(k.to_string(), Value::String(s.to_string()));
1833 }
1834 }
1835 let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
1836 if let Err((status_code, payload)) = validator.run_validation_with_recording(
1837 &route.path,
1838 &route.method,
1839 &path_map,
1840 &query_map,
1841 &header_map,
1842 &Map::new(),
1843 body_val,
1844 ) {
1845 let status = axum::http::StatusCode::from_u16(status_code)
1846 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
1847 return (status, Json(payload));
1848 }
1849
1850 tracing::info!(
1851 "[FIXTURE DEBUG] Starting fixture check for {} {} (custom_loader available: {})",
1852 route.method,
1853 route.path,
1854 custom_loader_clone.is_some()
1855 );
1856
1857 if let Some(ref loader) = custom_loader_clone {
1859 use crate::request_fingerprint::RequestFingerprint;
1860 use axum::http::{Method, Uri};
1861
1862 let query_string = if query.0.is_empty() {
1864 String::new()
1865 } else {
1866 query
1867 .0
1868 .iter()
1869 .map(|(k, v)| format!("{}={}", k, v))
1870 .collect::<Vec<_>>()
1871 .join("&")
1872 };
1873
1874 let normalized_request_path =
1876 crate::custom_fixture::CustomFixtureLoader::normalize_path(&route.path);
1877
1878 tracing::info!(
1879 "[FIXTURE DEBUG] Path normalization: original='{}', normalized='{}'",
1880 route.path,
1881 normalized_request_path
1882 );
1883
1884 let uri_str = if query_string.is_empty() {
1886 normalized_request_path.clone()
1887 } else {
1888 format!("{}?{}", normalized_request_path, query_string)
1889 };
1890
1891 tracing::info!(
1892 "[FIXTURE DEBUG] URI construction: uri_str='{}', query_string='{}'",
1893 uri_str,
1894 query_string
1895 );
1896
1897 if let Ok(uri) = uri_str.parse::<Uri>() {
1898 let http_method =
1899 Method::from_bytes(route.method.as_bytes()).unwrap_or(Method::GET);
1900
1901 let body_bytes =
1903 body.as_ref().and_then(|Json(b)| serde_json::to_vec(b).ok());
1904 let body_slice = body_bytes.as_deref();
1905
1906 let fingerprint =
1907 RequestFingerprint::new(http_method, &uri, &headers, body_slice);
1908
1909 tracing::info!(
1910 "[FIXTURE DEBUG] RequestFingerprint created: method='{}', path='{}', query='{}', body_hash={:?}",
1911 fingerprint.method,
1912 fingerprint.path,
1913 fingerprint.query,
1914 fingerprint.body_hash
1915 );
1916
1917 let available_fixtures = loader.has_fixture(&fingerprint);
1919 tracing::info!(
1920 "[FIXTURE DEBUG] Fixture check result: has_fixture={}",
1921 available_fixtures
1922 );
1923
1924 if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
1925 tracing::info!(
1926 "[FIXTURE DEBUG] ✅ FIXTURE MATCHED! Using custom fixture for {} {} (status: {}, path: '{}')",
1927 route.method,
1928 route.path,
1929 custom_fixture.status,
1930 custom_fixture.path
1931 );
1932
1933 if custom_fixture.delay_ms > 0 {
1935 tokio::time::sleep(tokio::time::Duration::from_millis(
1936 custom_fixture.delay_ms,
1937 ))
1938 .await;
1939 }
1940
1941 let response_body = if custom_fixture.response.is_string() {
1943 custom_fixture.response.as_str().unwrap().to_string()
1944 } else {
1945 serde_json::to_string(&custom_fixture.response)
1946 .unwrap_or_else(|_| "{}".to_string())
1947 };
1948
1949 let json_value: Value = serde_json::from_str(&response_body)
1951 .unwrap_or_else(|_| serde_json::json!({}));
1952
1953 let status =
1955 axum::http::StatusCode::from_u16(custom_fixture.status)
1956 .unwrap_or(axum::http::StatusCode::OK);
1957
1958 return (status, Json(json_value));
1960 } else {
1961 tracing::warn!(
1962 "[FIXTURE DEBUG] ❌ No fixture match found for {} {} (fingerprint.path='{}', normalized='{}')",
1963 route.method,
1964 route.path,
1965 fingerprint.path,
1966 normalized_request_path
1967 );
1968 }
1969 } else {
1970 tracing::warn!("[FIXTURE DEBUG] Failed to parse URI: '{}'", uri_str);
1971 }
1972 } else {
1973 tracing::warn!(
1974 "[FIXTURE DEBUG] Custom fixture loader not available for {} {}",
1975 route.method,
1976 route.path
1977 );
1978 }
1979
1980 tracing::debug!(
1981 "Handling MockAI request for route: {} {}",
1982 route.method,
1983 route.path
1984 );
1985
1986 let mockai_query = query.0;
1988
1989 let method_upper = route.method.to_uppercase();
1994 let should_use_mockai =
1995 matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH" | "DELETE");
1996
1997 if should_use_mockai {
1998 if let Some(mockai_arc) = mockai {
1999 let mockai_guard = mockai_arc.read().await;
2000
2001 let mut mockai_headers = HashMap::new();
2003 for (k, v) in headers.iter() {
2004 mockai_headers
2005 .insert(k.to_string(), v.to_str().unwrap_or("").to_string());
2006 }
2007
2008 let mockai_request = MockAIRequest {
2009 method: route.method.clone(),
2010 path: route.path.clone(),
2011 body: body.as_ref().map(|Json(b)| b.clone()),
2012 query_params: mockai_query,
2013 headers: mockai_headers,
2014 };
2015
2016 match mockai_guard.process_request(&mockai_request).await {
2018 Ok(mockai_response) => {
2019 let is_empty = mockai_response.body.is_object()
2021 && mockai_response
2022 .body
2023 .as_object()
2024 .map(|obj| obj.is_empty())
2025 .unwrap_or(false);
2026
2027 if is_empty {
2028 tracing::debug!(
2029 "MockAI returned empty object for {} {}, falling back to OpenAPI response generation",
2030 route.method,
2031 route.path
2032 );
2033 } else {
2035 let spec_status = route.find_first_available_status_code();
2039 tracing::debug!(
2040 "MockAI generated response for {} {}, using spec status: {} (MockAI suggested: {})",
2041 route.method,
2042 route.path,
2043 spec_status,
2044 mockai_response.status_code
2045 );
2046 return (
2047 axum::http::StatusCode::from_u16(spec_status)
2048 .unwrap_or(axum::http::StatusCode::OK),
2049 Json(mockai_response.body),
2050 );
2051 }
2052 }
2053 Err(e) => {
2054 tracing::warn!(
2055 "MockAI processing failed for {} {}: {}, falling back to standard response",
2056 route.method,
2057 route.path,
2058 e
2059 );
2060 }
2062 }
2063 }
2064 } else {
2065 tracing::debug!(
2066 "Skipping MockAI for {} request {} - using OpenAPI response generation",
2067 method_upper,
2068 route.path
2069 );
2070 }
2071
2072 let status_override = headers
2074 .get("X-Mockforge-Response-Status")
2075 .and_then(|v| v.to_str().ok())
2076 .and_then(|s| s.parse::<u16>().ok());
2077
2078 let scenario = headers
2080 .get("X-Mockforge-Scenario")
2081 .and_then(|v| v.to_str().ok())
2082 .map(|s| s.to_string())
2083 .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
2084
2085 let (status, response) = route
2087 .mock_response_with_status_and_scenario_and_override(
2088 scenario.as_deref(),
2089 status_override,
2090 );
2091 (
2092 axum::http::StatusCode::from_u16(status)
2093 .unwrap_or(axum::http::StatusCode::OK),
2094 Json(response),
2095 )
2096 }
2097 };
2098
2099 router = Self::route_for_method(router, axum_path, &route.method, handler);
2100 }
2101
2102 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
2105 }
2106}
2107
2108async fn extract_multipart_from_bytes(
2113 body: &axum::body::Bytes,
2114 headers: &HeaderMap,
2115) -> Result<(HashMap<String, Value>, HashMap<String, String>)> {
2116 let boundary = headers
2118 .get(axum::http::header::CONTENT_TYPE)
2119 .and_then(|v| v.to_str().ok())
2120 .and_then(|ct| {
2121 ct.split(';').find_map(|part| {
2122 let part = part.trim();
2123 if part.starts_with("boundary=") {
2124 Some(part.strip_prefix("boundary=").unwrap_or("").trim_matches('"'))
2125 } else {
2126 None
2127 }
2128 })
2129 })
2130 .ok_or_else(|| Error::internal("Missing boundary in Content-Type header"))?;
2131
2132 let mut fields = HashMap::new();
2133 let mut files = HashMap::new();
2134
2135 let boundary_prefix = format!("--{}", boundary).into_bytes();
2138 let boundary_line = format!("\r\n--{}\r\n", boundary).into_bytes();
2139 let end_boundary = format!("\r\n--{}--\r\n", boundary).into_bytes();
2140
2141 let mut pos = 0;
2143 let mut parts = Vec::new();
2144
2145 if body.starts_with(&boundary_prefix) {
2147 if let Some(first_crlf) = body.iter().position(|&b| b == b'\r') {
2148 pos = first_crlf + 2; }
2150 }
2151
2152 while let Some(boundary_pos) = body[pos..]
2154 .windows(boundary_line.len())
2155 .position(|window| window == boundary_line.as_slice())
2156 {
2157 let actual_pos = pos + boundary_pos;
2158 if actual_pos > pos {
2159 parts.push((pos, actual_pos));
2160 }
2161 pos = actual_pos + boundary_line.len();
2162 }
2163
2164 if let Some(end_pos) = body[pos..]
2166 .windows(end_boundary.len())
2167 .position(|window| window == end_boundary.as_slice())
2168 {
2169 let actual_end = pos + end_pos;
2170 if actual_end > pos {
2171 parts.push((pos, actual_end));
2172 }
2173 } else if pos < body.len() {
2174 parts.push((pos, body.len()));
2176 }
2177
2178 for (start, end) in parts {
2180 let part_data = &body[start..end];
2181
2182 let separator = b"\r\n\r\n";
2184 if let Some(sep_pos) =
2185 part_data.windows(separator.len()).position(|window| window == separator)
2186 {
2187 let header_bytes = &part_data[..sep_pos];
2188 let body_start = sep_pos + separator.len();
2189 let body_data = &part_data[body_start..];
2190
2191 let header_str = String::from_utf8_lossy(header_bytes);
2193 let mut field_name = None;
2194 let mut filename = None;
2195
2196 for header_line in header_str.lines() {
2197 if header_line.starts_with("Content-Disposition:") {
2198 if let Some(name_start) = header_line.find("name=\"") {
2200 let name_start = name_start + 6;
2201 if let Some(name_end) = header_line[name_start..].find('"') {
2202 field_name =
2203 Some(header_line[name_start..name_start + name_end].to_string());
2204 }
2205 }
2206
2207 if let Some(file_start) = header_line.find("filename=\"") {
2209 let file_start = file_start + 10;
2210 if let Some(file_end) = header_line[file_start..].find('"') {
2211 filename =
2212 Some(header_line[file_start..file_start + file_end].to_string());
2213 }
2214 }
2215 }
2216 }
2217
2218 if let Some(name) = field_name {
2219 if let Some(file) = filename {
2220 let temp_dir = std::env::temp_dir().join("mockforge-uploads");
2222 std::fs::create_dir_all(&temp_dir)
2223 .map_err(|e| Error::io_with_context("temp directory", e.to_string()))?;
2224
2225 let file_path = temp_dir.join(format!("{}_{}", uuid::Uuid::new_v4(), file));
2226 std::fs::write(&file_path, body_data)
2227 .map_err(|e| Error::io_with_context("file", e.to_string()))?;
2228
2229 let file_path_str = file_path.to_string_lossy().to_string();
2230 files.insert(name.clone(), file_path_str.clone());
2231 fields.insert(name, Value::String(file_path_str));
2232 } else {
2233 let body_str = body_data
2236 .strip_suffix(b"\r\n")
2237 .or_else(|| body_data.strip_suffix(b"\n"))
2238 .unwrap_or(body_data);
2239
2240 if let Ok(field_value) = String::from_utf8(body_str.to_vec()) {
2241 fields.insert(name, Value::String(field_value.trim().to_string()));
2242 } else {
2243 use base64::{engine::general_purpose, Engine as _};
2245 fields.insert(
2246 name,
2247 Value::String(general_purpose::STANDARD.encode(body_str)),
2248 );
2249 }
2250 }
2251 }
2252 }
2253 }
2254
2255 Ok((fields, files))
2256}
2257
2258static LAST_ERRORS: Lazy<Mutex<VecDeque<Value>>> =
2259 Lazy::new(|| Mutex::new(VecDeque::with_capacity(20)));
2260
2261pub fn classify_validation_reason(reason: &str) -> String {
2269 let r = reason.to_ascii_lowercase();
2270 if r.contains("required")
2271 && (r.contains("param") || r.contains("query") || r.contains("header"))
2272 {
2273 return "parameters".into();
2274 }
2275 if r.contains("schema") || r.contains("body") || r.contains("json") {
2276 return "request-body".into();
2277 }
2278 if r.contains("content-type") || r.contains("content type") {
2279 return "content-types".into();
2280 }
2281 if r.contains("header") {
2282 return "headers".into();
2283 }
2284 if r.contains("cookie") {
2285 return "cookies".into();
2286 }
2287 if r.contains("method") {
2288 return "http-methods".into();
2289 }
2290 if r.contains("auth") || r.contains("security") {
2291 return "security".into();
2292 }
2293 if r.contains("enum") || r.contains("min") || r.contains("max") || r.contains("pattern") {
2294 return "constraints".into();
2295 }
2296 String::new()
2297}
2298
2299pub fn record_validation_error(v: &Value) {
2301 if let Ok(mut q) = LAST_ERRORS.lock() {
2302 if q.len() >= 20 {
2303 q.pop_front();
2304 }
2305 q.push_back(v.clone());
2306 }
2307 }
2309
2310pub fn get_last_validation_error() -> Option<Value> {
2312 LAST_ERRORS.lock().ok()?.back().cloned()
2313}
2314
2315pub fn get_validation_errors() -> Vec<Value> {
2317 LAST_ERRORS.lock().map(|q| q.iter().cloned().collect()).unwrap_or_default()
2318}
2319
2320fn coerce_value_for_schema(value: &Value, schema: &openapiv3::Schema) -> Value {
2325 match value {
2327 Value::String(s) => {
2328 if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2330 &schema.schema_kind
2331 {
2332 if s.contains(',') {
2333 let parts: Vec<&str> = s.split(',').map(|s| s.trim()).collect();
2335 let mut array_values = Vec::new();
2336
2337 for part in parts {
2338 if let Some(items_schema) = &array_type.items {
2340 if let Some(items_schema_obj) = items_schema.as_item() {
2341 let part_value = Value::String(part.to_string());
2342 let coerced_part =
2343 coerce_value_for_schema(&part_value, items_schema_obj);
2344 array_values.push(coerced_part);
2345 } else {
2346 array_values.push(Value::String(part.to_string()));
2348 }
2349 } else {
2350 array_values.push(Value::String(part.to_string()));
2352 }
2353 }
2354 return Value::Array(array_values);
2355 }
2356 }
2357
2358 match &schema.schema_kind {
2360 openapiv3::SchemaKind::Type(openapiv3::Type::String(_)) => {
2361 value.clone()
2363 }
2364 openapiv3::SchemaKind::Type(openapiv3::Type::Number(_)) => {
2365 if let Ok(n) = s.parse::<f64>() {
2367 if let Some(num) = serde_json::Number::from_f64(n) {
2368 return Value::Number(num);
2369 }
2370 }
2371 value.clone()
2372 }
2373 openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_)) => {
2374 if let Ok(n) = s.parse::<i64>() {
2376 if let Some(num) = serde_json::Number::from_f64(n as f64) {
2377 return Value::Number(num);
2378 }
2379 }
2380 value.clone()
2381 }
2382 openapiv3::SchemaKind::Type(openapiv3::Type::Boolean(_)) => {
2383 match s.to_lowercase().as_str() {
2385 "true" | "1" | "yes" | "on" => Value::Bool(true),
2386 "false" | "0" | "no" | "off" => Value::Bool(false),
2387 _ => value.clone(),
2388 }
2389 }
2390 _ => {
2391 value.clone()
2393 }
2394 }
2395 }
2396 _ => value.clone(),
2397 }
2398}
2399
2400fn coerce_by_style(value: &Value, schema: &openapiv3::Schema, style: Option<&str>) -> Value {
2402 match value {
2404 Value::String(s) => {
2405 if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2407 &schema.schema_kind
2408 {
2409 let delimiter = match style {
2410 Some("spaceDelimited") => " ",
2411 Some("pipeDelimited") => "|",
2412 Some("form") | None => ",", _ => ",", };
2415
2416 if s.contains(delimiter) {
2417 let parts: Vec<&str> = s.split(delimiter).map(|s| s.trim()).collect();
2419 let mut array_values = Vec::new();
2420
2421 for part in parts {
2422 if let Some(items_schema) = &array_type.items {
2424 if let Some(items_schema_obj) = items_schema.as_item() {
2425 let part_value = Value::String(part.to_string());
2426 let coerced_part =
2427 coerce_by_style(&part_value, items_schema_obj, style);
2428 array_values.push(coerced_part);
2429 } else {
2430 array_values.push(Value::String(part.to_string()));
2432 }
2433 } else {
2434 array_values.push(Value::String(part.to_string()));
2436 }
2437 }
2438 return Value::Array(array_values);
2439 }
2440 }
2441
2442 if let Ok(n) = s.parse::<f64>() {
2444 if let Some(num) = serde_json::Number::from_f64(n) {
2445 return Value::Number(num);
2446 }
2447 }
2448 match s.to_lowercase().as_str() {
2450 "true" | "1" | "yes" | "on" => return Value::Bool(true),
2451 "false" | "0" | "no" | "off" => return Value::Bool(false),
2452 _ => {}
2453 }
2454 value.clone()
2456 }
2457 _ => value.clone(),
2458 }
2459}
2460
2461fn build_deep_object(name: &str, params: &Map<String, Value>) -> Option<Value> {
2463 let prefix = format!("{}[", name);
2464 let mut obj = Map::new();
2465 for (k, v) in params.iter() {
2466 if let Some(rest) = k.strip_prefix(&prefix) {
2467 if let Some(key) = rest.strip_suffix(']') {
2468 obj.insert(key.to_string(), v.clone());
2469 }
2470 }
2471 }
2472 if obj.is_empty() {
2473 None
2474 } else {
2475 Some(Value::Object(obj))
2476 }
2477}
2478
2479#[allow(clippy::too_many_arguments)]
2485fn generate_enhanced_422_response(
2486 validator: &OpenApiRouteRegistry,
2487 path_template: &str,
2488 method: &str,
2489 body: Option<&Value>,
2490 path_params: &Map<String, Value>,
2491 query_params: &Map<String, Value>,
2492 header_params: &Map<String, Value>,
2493 cookie_params: &Map<String, Value>,
2494) -> Value {
2495 let mut field_errors = Vec::new();
2496
2497 if let Some(route) = validator.get_route(path_template, method) {
2499 if let Some(schema) = &route.operation.request_body {
2501 if let Some(value) = body {
2502 if let Some(content) =
2503 schema.as_item().and_then(|rb| rb.content.get("application/json"))
2504 {
2505 if let Some(_schema_ref) = &content.schema {
2506 if serde_json::from_value::<Value>(value.clone()).is_err() {
2508 field_errors.push(json!({
2509 "path": "body",
2510 "message": "invalid JSON"
2511 }));
2512 }
2513 }
2514 }
2515 } else {
2516 field_errors.push(json!({
2517 "path": "body",
2518 "expected": "object",
2519 "found": "missing",
2520 "message": "Request body is required but not provided"
2521 }));
2522 }
2523 }
2524
2525 for param_ref in &route.operation.parameters {
2527 if let Some(param) = param_ref.as_item() {
2528 match param {
2529 openapiv3::Parameter::Path { parameter_data, .. } => {
2530 validate_parameter_detailed(
2531 parameter_data,
2532 path_params,
2533 "path",
2534 "path parameter",
2535 &mut field_errors,
2536 );
2537 }
2538 openapiv3::Parameter::Query { parameter_data, .. } => {
2539 let deep_value = if Some("form") == Some("deepObject") {
2540 build_deep_object(¶meter_data.name, query_params)
2541 } else {
2542 None
2543 };
2544 validate_parameter_detailed_with_deep(
2545 parameter_data,
2546 query_params,
2547 "query",
2548 "query parameter",
2549 deep_value,
2550 &mut field_errors,
2551 );
2552 }
2553 openapiv3::Parameter::Header { parameter_data, .. } => {
2554 validate_parameter_detailed(
2555 parameter_data,
2556 header_params,
2557 "header",
2558 "header parameter",
2559 &mut field_errors,
2560 );
2561 }
2562 openapiv3::Parameter::Cookie { parameter_data, .. } => {
2563 validate_parameter_detailed(
2564 parameter_data,
2565 cookie_params,
2566 "cookie",
2567 "cookie parameter",
2568 &mut field_errors,
2569 );
2570 }
2571 }
2572 }
2573 }
2574 }
2575
2576 json!({
2578 "error": "Schema validation failed",
2579 "details": field_errors,
2580 "method": method,
2581 "path": path_template,
2582 "timestamp": Utc::now().to_rfc3339(),
2583 "validation_type": "openapi_schema"
2584 })
2585}
2586
2587fn validate_parameter(
2589 parameter_data: &openapiv3::ParameterData,
2590 params_map: &Map<String, Value>,
2591 prefix: &str,
2592 aggregate: bool,
2593 errors: &mut Vec<String>,
2594 details: &mut Vec<Value>,
2595) {
2596 match params_map.get(¶meter_data.name) {
2597 Some(v) => {
2598 if let ParameterSchemaOrContent::Schema(s) = ¶meter_data.format {
2599 if let Some(schema) = s.as_item() {
2600 let coerced = coerce_value_for_schema(v, schema);
2601 if let Err(validation_error) =
2603 OpenApiSchema::new(schema.clone()).validate(&coerced)
2604 {
2605 let error_msg = validation_error.to_string();
2606 errors.push(format!(
2607 "{} parameter '{}' validation failed: {}",
2608 prefix, parameter_data.name, error_msg
2609 ));
2610 if aggregate {
2611 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2612 }
2613 }
2614 }
2615 }
2616 }
2617 None => {
2618 if parameter_data.required {
2619 errors.push(format!(
2620 "missing required {} parameter '{}'",
2621 prefix, parameter_data.name
2622 ));
2623 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2624 }
2625 }
2626 }
2627}
2628
2629#[allow(clippy::too_many_arguments)]
2631fn validate_parameter_with_deep_object(
2632 parameter_data: &openapiv3::ParameterData,
2633 params_map: &Map<String, Value>,
2634 prefix: &str,
2635 deep_value: Option<Value>,
2636 style: Option<&str>,
2637 aggregate: bool,
2638 errors: &mut Vec<String>,
2639 details: &mut Vec<Value>,
2640) {
2641 match deep_value.as_ref().or_else(|| params_map.get(¶meter_data.name)) {
2642 Some(v) => {
2643 if let ParameterSchemaOrContent::Schema(s) = ¶meter_data.format {
2644 if let Some(schema) = s.as_item() {
2645 let coerced = coerce_by_style(v, schema, style); if let Err(validation_error) =
2648 OpenApiSchema::new(schema.clone()).validate(&coerced)
2649 {
2650 let error_msg = validation_error.to_string();
2651 errors.push(format!(
2652 "{} parameter '{}' validation failed: {}",
2653 prefix, parameter_data.name, error_msg
2654 ));
2655 if aggregate {
2656 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2657 }
2658 }
2659 }
2660 }
2661 }
2662 None => {
2663 if parameter_data.required {
2664 errors.push(format!(
2665 "missing required {} parameter '{}'",
2666 prefix, parameter_data.name
2667 ));
2668 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2669 }
2670 }
2671 }
2672}
2673
2674fn validate_parameter_detailed(
2676 parameter_data: &openapiv3::ParameterData,
2677 params_map: &Map<String, Value>,
2678 location: &str,
2679 value_type: &str,
2680 field_errors: &mut Vec<Value>,
2681) {
2682 match params_map.get(¶meter_data.name) {
2683 Some(value) => {
2684 if let ParameterSchemaOrContent::Schema(schema) = ¶meter_data.format {
2685 let details: Vec<Value> = Vec::new();
2687 let param_path = format!("{}.{}", location, parameter_data.name);
2688
2689 if let Some(schema_ref) = schema.as_item() {
2691 let coerced_value = coerce_value_for_schema(value, schema_ref);
2692 if let Err(validation_error) =
2694 OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2695 {
2696 field_errors.push(json!({
2697 "path": param_path,
2698 "expected": "valid according to schema",
2699 "found": coerced_value,
2700 "message": validation_error.to_string()
2701 }));
2702 }
2703 }
2704
2705 for detail in details {
2706 field_errors.push(json!({
2707 "path": detail["path"],
2708 "expected": detail["expected_type"],
2709 "found": detail["value"],
2710 "message": detail["message"]
2711 }));
2712 }
2713 }
2714 }
2715 None => {
2716 if parameter_data.required {
2717 field_errors.push(json!({
2718 "path": format!("{}.{}", location, parameter_data.name),
2719 "expected": "value",
2720 "found": "missing",
2721 "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
2722 }));
2723 }
2724 }
2725 }
2726}
2727
2728fn validate_parameter_detailed_with_deep(
2730 parameter_data: &openapiv3::ParameterData,
2731 params_map: &Map<String, Value>,
2732 location: &str,
2733 value_type: &str,
2734 deep_value: Option<Value>,
2735 field_errors: &mut Vec<Value>,
2736) {
2737 match deep_value.as_ref().or_else(|| params_map.get(¶meter_data.name)) {
2738 Some(value) => {
2739 if let ParameterSchemaOrContent::Schema(schema) = ¶meter_data.format {
2740 let details: Vec<Value> = Vec::new();
2742 let param_path = format!("{}.{}", location, parameter_data.name);
2743
2744 if let Some(schema_ref) = schema.as_item() {
2746 let coerced_value = coerce_by_style(value, schema_ref, Some("form")); if let Err(validation_error) =
2749 OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2750 {
2751 field_errors.push(json!({
2752 "path": param_path,
2753 "expected": "valid according to schema",
2754 "found": coerced_value,
2755 "message": validation_error.to_string()
2756 }));
2757 }
2758 }
2759
2760 for detail in details {
2761 field_errors.push(json!({
2762 "path": detail["path"],
2763 "expected": detail["expected_type"],
2764 "found": detail["value"],
2765 "message": detail["message"]
2766 }));
2767 }
2768 }
2769 }
2770 None => {
2771 if parameter_data.required {
2772 field_errors.push(json!({
2773 "path": format!("{}.{}", location, parameter_data.name),
2774 "expected": "value",
2775 "found": "missing",
2776 "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
2777 }));
2778 }
2779 }
2780 }
2781}
2782
2783pub async fn create_registry_from_file<P: AsRef<std::path::Path>>(
2785 path: P,
2786) -> Result<OpenApiRouteRegistry> {
2787 let spec = OpenApiSpec::from_file(path).await?;
2788 spec.validate()?;
2789 Ok(OpenApiRouteRegistry::new(spec))
2790}
2791
2792pub fn create_registry_from_json(json: Value) -> Result<OpenApiRouteRegistry> {
2794 let spec = OpenApiSpec::from_json(json)?;
2795 spec.validate()?;
2796 Ok(OpenApiRouteRegistry::new(spec))
2797}
2798
2799#[cfg(test)]
2800mod tests {
2801 use super::*;
2802 use serde_json::json;
2803 use tempfile::TempDir;
2804
2805 #[tokio::test]
2806 async fn test_registry_creation() {
2807 let spec_json = json!({
2808 "openapi": "3.0.0",
2809 "info": {
2810 "title": "Test API",
2811 "version": "1.0.0"
2812 },
2813 "paths": {
2814 "/users": {
2815 "get": {
2816 "summary": "Get users",
2817 "responses": {
2818 "200": {
2819 "description": "Success",
2820 "content": {
2821 "application/json": {
2822 "schema": {
2823 "type": "array",
2824 "items": {
2825 "type": "object",
2826 "properties": {
2827 "id": {"type": "integer"},
2828 "name": {"type": "string"}
2829 }
2830 }
2831 }
2832 }
2833 }
2834 }
2835 }
2836 },
2837 "post": {
2838 "summary": "Create user",
2839 "requestBody": {
2840 "content": {
2841 "application/json": {
2842 "schema": {
2843 "type": "object",
2844 "properties": {
2845 "name": {"type": "string"}
2846 },
2847 "required": ["name"]
2848 }
2849 }
2850 }
2851 },
2852 "responses": {
2853 "201": {
2854 "description": "Created",
2855 "content": {
2856 "application/json": {
2857 "schema": {
2858 "type": "object",
2859 "properties": {
2860 "id": {"type": "integer"},
2861 "name": {"type": "string"}
2862 }
2863 }
2864 }
2865 }
2866 }
2867 }
2868 }
2869 },
2870 "/users/{id}": {
2871 "get": {
2872 "summary": "Get user by ID",
2873 "parameters": [
2874 {
2875 "name": "id",
2876 "in": "path",
2877 "required": true,
2878 "schema": {"type": "integer"}
2879 }
2880 ],
2881 "responses": {
2882 "200": {
2883 "description": "Success",
2884 "content": {
2885 "application/json": {
2886 "schema": {
2887 "type": "object",
2888 "properties": {
2889 "id": {"type": "integer"},
2890 "name": {"type": "string"}
2891 }
2892 }
2893 }
2894 }
2895 }
2896 }
2897 }
2898 }
2899 }
2900 });
2901
2902 let registry = create_registry_from_json(spec_json).unwrap();
2903
2904 assert_eq!(registry.paths().len(), 2);
2906 assert!(registry.paths().contains(&"/users".to_string()));
2907 assert!(registry.paths().contains(&"/users/{id}".to_string()));
2908
2909 assert_eq!(registry.methods().len(), 2);
2910 assert!(registry.methods().contains(&"GET".to_string()));
2911 assert!(registry.methods().contains(&"POST".to_string()));
2912
2913 let get_users_route = registry.get_route("/users", "GET").unwrap();
2915 assert_eq!(get_users_route.method, "GET");
2916 assert_eq!(get_users_route.path, "/users");
2917
2918 let post_users_route = registry.get_route("/users", "POST").unwrap();
2919 assert_eq!(post_users_route.method, "POST");
2920 assert!(post_users_route.operation.request_body.is_some());
2921
2922 let user_by_id_route = registry.get_route("/users/{id}", "GET").unwrap();
2924 assert_eq!(user_by_id_route.axum_path(), "/users/{id}");
2925 }
2926
2927 #[tokio::test]
2933 async fn check_request_content_type_flags_mismatch() {
2934 let spec_json = json!({
2935 "openapi": "3.0.0",
2936 "info": { "title": "T", "version": "1" },
2937 "paths": {
2938 "/api/appliance/access/consolecli": {
2939 "put": {
2940 "requestBody": {
2941 "required": true,
2942 "content": {
2943 "application/json": {
2944 "schema": {
2945 "type": "object",
2946 "required": ["enabled"],
2947 "properties": {"enabled": {"type": "boolean"}}
2948 }
2949 }
2950 }
2951 },
2952 "responses": { "204": { "description": "ok" } }
2953 }
2954 }
2955 }
2956 });
2957 let spec = OpenApiSpec::from_json(spec_json).unwrap();
2958 let registry = OpenApiRouteRegistry::new(spec);
2959
2960 let r = registry.check_request_content_type(
2962 "/api/appliance/access/consolecli",
2963 "PUT",
2964 Some("application/xml"),
2965 );
2966 assert!(r.is_err(), "should flag application/xml: {:?}", r);
2967 let msg = r.unwrap_err();
2968 assert!(msg.contains("application/xml"), "{msg}");
2969 assert!(msg.contains("application/json"), "{msg}");
2970
2971 let r = registry.check_request_content_type(
2973 "/api/appliance/access/consolecli",
2974 "PUT",
2975 Some("application/json"),
2976 );
2977 assert!(r.is_ok(), "should accept application/json: {:?}", r);
2978
2979 let r = registry.check_request_content_type(
2981 "/api/appliance/access/consolecli",
2982 "PUT",
2983 Some("application/json; charset=utf-8"),
2984 );
2985 assert!(r.is_ok(), "should strip charset: {:?}", r);
2986
2987 let r = registry.check_request_content_type(
2989 "/api/appliance/access/consolecli",
2990 "GET",
2991 Some("application/xml"),
2992 );
2993 assert!(r.is_ok(), "GET has no requestBody on this op: {:?}", r);
2994
2995 let r =
2997 registry.check_request_content_type("/api/appliance/access/consolecli", "PUT", None);
2998 assert!(r.is_ok(), "no Content-Type → don't double-report: {:?}", r);
2999 }
3000
3001 #[tokio::test]
3002 async fn test_validate_request_with_params_and_formats() {
3003 let spec_json = json!({
3004 "openapi": "3.0.0",
3005 "info": { "title": "Test API", "version": "1.0.0" },
3006 "paths": {
3007 "/users/{id}": {
3008 "post": {
3009 "parameters": [
3010 { "name": "id", "in": "path", "required": true, "schema": {"type": "string"} },
3011 { "name": "q", "in": "query", "required": false, "schema": {"type": "integer"} }
3012 ],
3013 "requestBody": {
3014 "content": {
3015 "application/json": {
3016 "schema": {
3017 "type": "object",
3018 "required": ["email", "website"],
3019 "properties": {
3020 "email": {"type": "string", "format": "email"},
3021 "website": {"type": "string", "format": "uri"}
3022 }
3023 }
3024 }
3025 }
3026 },
3027 "responses": {"200": {"description": "ok"}}
3028 }
3029 }
3030 }
3031 });
3032
3033 let registry = create_registry_from_json(spec_json).unwrap();
3034 let mut path_params = Map::new();
3035 path_params.insert("id".to_string(), json!("abc"));
3036 let mut query_params = Map::new();
3037 query_params.insert("q".to_string(), json!(123));
3038
3039 let body = json!({"email":"a@b.co","website":"https://example.com"});
3041 assert!(registry
3042 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3043 .is_ok());
3044
3045 let bad_email = json!({"email":"not-an-email","website":"https://example.com"});
3047 assert!(registry
3048 .validate_request_with(
3049 "/users/{id}",
3050 "POST",
3051 &path_params,
3052 &query_params,
3053 Some(&bad_email)
3054 )
3055 .is_err());
3056
3057 let empty_path_params = Map::new();
3059 assert!(registry
3060 .validate_request_with(
3061 "/users/{id}",
3062 "POST",
3063 &empty_path_params,
3064 &query_params,
3065 Some(&body)
3066 )
3067 .is_err());
3068 }
3069
3070 #[tokio::test]
3071 async fn test_ref_resolution_for_params_and_body() {
3072 let spec_json = json!({
3073 "openapi": "3.0.0",
3074 "info": { "title": "Ref API", "version": "1.0.0" },
3075 "components": {
3076 "schemas": {
3077 "EmailWebsite": {
3078 "type": "object",
3079 "required": ["email", "website"],
3080 "properties": {
3081 "email": {"type": "string", "format": "email"},
3082 "website": {"type": "string", "format": "uri"}
3083 }
3084 }
3085 },
3086 "parameters": {
3087 "PathId": {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}},
3088 "QueryQ": {"name": "q", "in": "query", "required": false, "schema": {"type": "integer"}}
3089 },
3090 "requestBodies": {
3091 "CreateUser": {
3092 "content": {
3093 "application/json": {
3094 "schema": {"$ref": "#/components/schemas/EmailWebsite"}
3095 }
3096 }
3097 }
3098 }
3099 },
3100 "paths": {
3101 "/users/{id}": {
3102 "post": {
3103 "parameters": [
3104 {"$ref": "#/components/parameters/PathId"},
3105 {"$ref": "#/components/parameters/QueryQ"}
3106 ],
3107 "requestBody": {"$ref": "#/components/requestBodies/CreateUser"},
3108 "responses": {"200": {"description": "ok"}}
3109 }
3110 }
3111 }
3112 });
3113
3114 let registry = create_registry_from_json(spec_json).unwrap();
3115 let mut path_params = Map::new();
3116 path_params.insert("id".to_string(), json!("abc"));
3117 let mut query_params = Map::new();
3118 query_params.insert("q".to_string(), json!(7));
3119
3120 let body = json!({"email":"user@example.com","website":"https://example.com"});
3121 assert!(registry
3122 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3123 .is_ok());
3124
3125 let bad = json!({"email":"nope","website":"https://example.com"});
3126 assert!(registry
3127 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&bad))
3128 .is_err());
3129 }
3130
3131 #[tokio::test]
3132 async fn test_header_cookie_and_query_coercion() {
3133 let spec_json = json!({
3134 "openapi": "3.0.0",
3135 "info": { "title": "Params API", "version": "1.0.0" },
3136 "paths": {
3137 "/items": {
3138 "get": {
3139 "parameters": [
3140 {"name": "X-Flag", "in": "header", "required": true, "schema": {"type": "boolean"}},
3141 {"name": "session", "in": "cookie", "required": true, "schema": {"type": "string"}},
3142 {"name": "ids", "in": "query", "required": false, "schema": {"type": "array", "items": {"type": "integer"}}}
3143 ],
3144 "responses": {"200": {"description": "ok"}}
3145 }
3146 }
3147 }
3148 });
3149
3150 let registry = create_registry_from_json(spec_json).unwrap();
3151
3152 let path_params = Map::new();
3153 let mut query_params = Map::new();
3154 query_params.insert("ids".to_string(), json!("1,2,3"));
3156 let mut header_params = Map::new();
3157 header_params.insert("X-Flag".to_string(), json!("true"));
3158 let mut cookie_params = Map::new();
3159 cookie_params.insert("session".to_string(), json!("abc123"));
3160
3161 assert!(registry
3162 .validate_request_with_all(
3163 "/items",
3164 "GET",
3165 &path_params,
3166 &query_params,
3167 &header_params,
3168 &cookie_params,
3169 None
3170 )
3171 .is_ok());
3172
3173 let empty_cookie = Map::new();
3175 assert!(registry
3176 .validate_request_with_all(
3177 "/items",
3178 "GET",
3179 &path_params,
3180 &query_params,
3181 &header_params,
3182 &empty_cookie,
3183 None
3184 )
3185 .is_err());
3186
3187 let mut bad_header = Map::new();
3189 bad_header.insert("X-Flag".to_string(), json!("notabool"));
3190 assert!(registry
3191 .validate_request_with_all(
3192 "/items",
3193 "GET",
3194 &path_params,
3195 &query_params,
3196 &bad_header,
3197 &cookie_params,
3198 None
3199 )
3200 .is_err());
3201 }
3202
3203 #[tokio::test]
3204 async fn test_query_styles_space_pipe_deepobject() {
3205 let spec_json = json!({
3206 "openapi": "3.0.0",
3207 "info": { "title": "Query Styles API", "version": "1.0.0" },
3208 "paths": {"/search": {"get": {
3209 "parameters": [
3210 {"name":"tags","in":"query","style":"spaceDelimited","schema":{"type":"array","items":{"type":"string"}}},
3211 {"name":"ids","in":"query","style":"pipeDelimited","schema":{"type":"array","items":{"type":"integer"}}},
3212 {"name":"filter","in":"query","style":"deepObject","schema":{"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}}
3213 ],
3214 "responses": {"200": {"description":"ok"}}
3215 }} }
3216 });
3217
3218 let registry = create_registry_from_json(spec_json).unwrap();
3219
3220 let path_params = Map::new();
3221 let mut query = Map::new();
3222 query.insert("tags".into(), json!("alpha beta gamma"));
3223 query.insert("ids".into(), json!("1|2|3"));
3224 query.insert("filter[color]".into(), json!("red"));
3225
3226 assert!(registry
3227 .validate_request_with("/search", "GET", &path_params, &query, None)
3228 .is_ok());
3229 }
3230
3231 #[tokio::test]
3232 async fn test_oneof_anyof_allof_validation() {
3233 let spec_json = json!({
3234 "openapi": "3.0.0",
3235 "info": { "title": "Composite API", "version": "1.0.0" },
3236 "paths": {
3237 "/composite": {
3238 "post": {
3239 "requestBody": {
3240 "content": {
3241 "application/json": {
3242 "schema": {
3243 "allOf": [
3244 {"type": "object", "required": ["base"], "properties": {"base": {"type": "string"}}}
3245 ],
3246 "oneOf": [
3247 {"type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"], "not": {"required": ["b"]}},
3248 {"type": "object", "properties": {"b": {"type": "integer"}}, "required": ["b"], "not": {"required": ["a"]}}
3249 ],
3250 "anyOf": [
3251 {"type": "object", "properties": {"flag": {"type": "boolean"}}, "required": ["flag"]},
3252 {"type": "object", "properties": {"extra": {"type": "string"}}, "required": ["extra"]}
3253 ]
3254 }
3255 }
3256 }
3257 },
3258 "responses": {"200": {"description": "ok"}}
3259 }
3260 }
3261 }
3262 });
3263
3264 let registry = create_registry_from_json(spec_json).unwrap();
3265 let ok = json!({"base": "x", "a": 1, "flag": true});
3267 assert!(registry
3268 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&ok))
3269 .is_ok());
3270
3271 let bad_oneof = json!({"base": "x", "a": 1, "b": 2, "flag": false});
3273 assert!(registry
3274 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_oneof))
3275 .is_err());
3276
3277 let bad_anyof = json!({"base": "x", "a": 1});
3279 assert!(registry
3280 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_anyof))
3281 .is_err());
3282
3283 let bad_allof = json!({"a": 1, "flag": true});
3285 assert!(registry
3286 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_allof))
3287 .is_err());
3288 }
3289
3290 #[tokio::test]
3299 async fn dotted_schema_ref_resolves_in_route_validator() {
3300 let spec_json = json!({
3301 "openapi": "3.0.0",
3302 "info": { "title": "Dotted", "version": "1.0.0" },
3303 "paths": {
3304 "/x": {
3305 "post": {
3306 "requestBody": {
3307 "required": true,
3308 "content": {
3309 "application/json": {
3310 "schema": {
3311 "$ref": "#/components/schemas/Esx.Settings.Inventory.EntitySpec"
3312 }
3313 }
3314 }
3315 },
3316 "responses": {"200": {"description": "ok"}}
3317 }
3318 }
3319 },
3320 "components": {
3321 "schemas": {
3322 "Esx.Settings.Inventory.EntitySpec": {
3323 "type": "object",
3324 "required": ["type"],
3325 "properties": {"type": {"type": "string"}}
3326 }
3327 }
3328 }
3329 });
3330 let registry = create_registry_from_json(spec_json).unwrap();
3331 let good = json!({"type": "HOST"});
3334 let res =
3335 registry.validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&good));
3336 assert!(res.is_ok(), "valid body should pass; got {res:?}");
3337 let bad = json!({"unrelated": 1});
3339 let err = registry
3340 .validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&bad))
3341 .unwrap_err();
3342 let msg = format!("{err}");
3343 assert!(
3344 !msg.contains("Pointer") || !msg.contains("does not exist"),
3345 "should not be a pointer-resolution failure; got: {msg}"
3346 );
3347 }
3348
3349 #[tokio::test]
3350 async fn test_overrides_warn_mode_allows_invalid() {
3351 let spec_json = json!({
3353 "openapi": "3.0.0",
3354 "info": { "title": "Overrides API", "version": "1.0.0" },
3355 "paths": {"/things": {"post": {
3356 "parameters": [{"name":"q","in":"query","required":true,"schema":{"type":"integer"}}],
3357 "responses": {"200": {"description":"ok"}}
3358 }}}
3359 });
3360
3361 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3362 let mut overrides = HashMap::new();
3363 overrides.insert("POST /things".to_string(), ValidationMode::Warn);
3364 let registry = OpenApiRouteRegistry::new_with_options(
3365 spec,
3366 ValidationOptions {
3367 request_mode: ValidationMode::Enforce,
3368 aggregate_errors: true,
3369 validate_responses: false,
3370 overrides,
3371 admin_skip_prefixes: vec![],
3372 response_template_expand: false,
3373 validation_status: None,
3374 },
3375 );
3376
3377 let ok = registry.validate_request_with("/things", "POST", &Map::new(), &Map::new(), None);
3379 assert!(ok.is_ok());
3380 }
3381
3382 #[tokio::test]
3383 async fn test_admin_skip_prefix_short_circuit() {
3384 let spec_json = json!({
3385 "openapi": "3.0.0",
3386 "info": { "title": "Skip API", "version": "1.0.0" },
3387 "paths": {}
3388 });
3389 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3390 let registry = OpenApiRouteRegistry::new_with_options(
3391 spec,
3392 ValidationOptions {
3393 request_mode: ValidationMode::Enforce,
3394 aggregate_errors: true,
3395 validate_responses: false,
3396 overrides: HashMap::new(),
3397 admin_skip_prefixes: vec!["/admin".into()],
3398 response_template_expand: false,
3399 validation_status: None,
3400 },
3401 );
3402
3403 let res = registry.validate_request_with_all(
3405 "/admin/__mockforge/health",
3406 "GET",
3407 &Map::new(),
3408 &Map::new(),
3409 &Map::new(),
3410 &Map::new(),
3411 None,
3412 );
3413 assert!(res.is_ok());
3414 }
3415
3416 #[test]
3417 fn test_path_conversion() {
3418 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users"), "/users");
3419 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users/{id}"), "/users/{id}");
3420 assert_eq!(
3421 OpenApiRouteRegistry::convert_path_to_axum("/users/{id}/posts/{postId}"),
3422 "/users/{id}/posts/{postId}"
3423 );
3424 }
3425
3426 #[test]
3427 fn test_validation_options_default() {
3428 let options = ValidationOptions::default();
3429 assert!(matches!(options.request_mode, ValidationMode::Enforce));
3430 assert!(options.aggregate_errors);
3431 assert!(!options.validate_responses);
3432 assert!(options.overrides.is_empty());
3433 assert!(options.admin_skip_prefixes.is_empty());
3434 assert!(!options.response_template_expand);
3435 assert!(options.validation_status.is_none());
3436 }
3437
3438 #[test]
3439 fn test_validation_mode_variants() {
3440 let disabled = ValidationMode::Disabled;
3442 let warn = ValidationMode::Warn;
3443 let enforce = ValidationMode::Enforce;
3444 let default = ValidationMode::default();
3445
3446 assert!(matches!(default, ValidationMode::Warn));
3448
3449 assert!(!matches!(disabled, ValidationMode::Warn));
3451 assert!(!matches!(warn, ValidationMode::Enforce));
3452 assert!(!matches!(enforce, ValidationMode::Disabled));
3453 }
3454
3455 #[test]
3456 fn test_registry_spec_accessor() {
3457 let spec_json = json!({
3458 "openapi": "3.0.0",
3459 "info": {
3460 "title": "Test API",
3461 "version": "1.0.0"
3462 },
3463 "paths": {}
3464 });
3465 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3466 let registry = OpenApiRouteRegistry::new(spec.clone());
3467
3468 let accessed_spec = registry.spec();
3470 assert_eq!(accessed_spec.title(), "Test API");
3471 }
3472
3473 #[test]
3474 fn test_clone_for_validation() {
3475 let spec_json = json!({
3476 "openapi": "3.0.0",
3477 "info": {
3478 "title": "Test API",
3479 "version": "1.0.0"
3480 },
3481 "paths": {
3482 "/users": {
3483 "get": {
3484 "responses": {
3485 "200": {
3486 "description": "Success"
3487 }
3488 }
3489 }
3490 }
3491 }
3492 });
3493 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3494 let registry = OpenApiRouteRegistry::new(spec);
3495
3496 let cloned = registry.clone_for_validation();
3498 assert_eq!(cloned.routes().len(), registry.routes().len());
3499 assert_eq!(cloned.spec().title(), registry.spec().title());
3500 }
3501
3502 #[test]
3503 fn test_with_custom_fixture_loader() {
3504 let temp_dir = TempDir::new().unwrap();
3505 let spec_json = json!({
3506 "openapi": "3.0.0",
3507 "info": {
3508 "title": "Test API",
3509 "version": "1.0.0"
3510 },
3511 "paths": {}
3512 });
3513 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3514 let registry = OpenApiRouteRegistry::new(spec);
3515 let original_routes_len = registry.routes().len();
3516
3517 let custom_loader = Arc::new(crate::custom_fixture::CustomFixtureLoader::new(
3519 temp_dir.path().to_path_buf(),
3520 true,
3521 ));
3522 let registry_with_loader = registry.with_custom_fixture_loader(custom_loader);
3523
3524 assert_eq!(registry_with_loader.routes().len(), original_routes_len);
3526 }
3527
3528 #[test]
3529 fn test_get_route() {
3530 let spec_json = json!({
3531 "openapi": "3.0.0",
3532 "info": {
3533 "title": "Test API",
3534 "version": "1.0.0"
3535 },
3536 "paths": {
3537 "/users": {
3538 "get": {
3539 "operationId": "getUsers",
3540 "responses": {
3541 "200": {
3542 "description": "Success"
3543 }
3544 }
3545 },
3546 "post": {
3547 "operationId": "createUser",
3548 "responses": {
3549 "201": {
3550 "description": "Created"
3551 }
3552 }
3553 }
3554 }
3555 }
3556 });
3557 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3558 let registry = OpenApiRouteRegistry::new(spec);
3559
3560 let route = registry.get_route("/users", "GET");
3562 assert!(route.is_some());
3563 assert_eq!(route.unwrap().method, "GET");
3564 assert_eq!(route.unwrap().path, "/users");
3565
3566 let route = registry.get_route("/nonexistent", "GET");
3568 assert!(route.is_none());
3569
3570 let route = registry.get_route("/users", "POST");
3572 assert!(route.is_some());
3573 assert_eq!(route.unwrap().method, "POST");
3574 }
3575
3576 #[test]
3577 fn test_get_routes_for_path() {
3578 let spec_json = json!({
3579 "openapi": "3.0.0",
3580 "info": {
3581 "title": "Test API",
3582 "version": "1.0.0"
3583 },
3584 "paths": {
3585 "/users": {
3586 "get": {
3587 "responses": {
3588 "200": {
3589 "description": "Success"
3590 }
3591 }
3592 },
3593 "post": {
3594 "responses": {
3595 "201": {
3596 "description": "Created"
3597 }
3598 }
3599 },
3600 "put": {
3601 "responses": {
3602 "200": {
3603 "description": "Success"
3604 }
3605 }
3606 }
3607 },
3608 "/posts": {
3609 "get": {
3610 "responses": {
3611 "200": {
3612 "description": "Success"
3613 }
3614 }
3615 }
3616 }
3617 }
3618 });
3619 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3620 let registry = OpenApiRouteRegistry::new(spec);
3621
3622 let routes = registry.get_routes_for_path("/users");
3624 assert_eq!(routes.len(), 3);
3625 let methods: Vec<&str> = routes.iter().map(|r| r.method.as_str()).collect();
3626 assert!(methods.contains(&"GET"));
3627 assert!(methods.contains(&"POST"));
3628 assert!(methods.contains(&"PUT"));
3629
3630 let routes = registry.get_routes_for_path("/posts");
3632 assert_eq!(routes.len(), 1);
3633 assert_eq!(routes[0].method, "GET");
3634
3635 let routes = registry.get_routes_for_path("/nonexistent");
3637 assert!(routes.is_empty());
3638 }
3639
3640 #[test]
3641 fn test_new_vs_new_with_options() {
3642 let spec_json = json!({
3643 "openapi": "3.0.0",
3644 "info": {
3645 "title": "Test API",
3646 "version": "1.0.0"
3647 },
3648 "paths": {}
3649 });
3650 let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3651 let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3652
3653 let registry1 = OpenApiRouteRegistry::new(spec1);
3655 assert_eq!(registry1.spec().title(), "Test API");
3656
3657 let options = ValidationOptions {
3659 request_mode: ValidationMode::Disabled,
3660 aggregate_errors: false,
3661 validate_responses: true,
3662 overrides: HashMap::new(),
3663 admin_skip_prefixes: vec!["/admin".to_string()],
3664 response_template_expand: true,
3665 validation_status: Some(422),
3666 };
3667 let registry2 = OpenApiRouteRegistry::new_with_options(spec2, options);
3668 assert_eq!(registry2.spec().title(), "Test API");
3669 }
3670
3671 #[test]
3672 fn test_new_with_env_vs_new() {
3673 let spec_json = json!({
3674 "openapi": "3.0.0",
3675 "info": {
3676 "title": "Test API",
3677 "version": "1.0.0"
3678 },
3679 "paths": {}
3680 });
3681 let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3682 let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3683
3684 let registry1 = OpenApiRouteRegistry::new(spec1);
3686
3687 let registry2 = OpenApiRouteRegistry::new_with_env(spec2);
3689
3690 assert_eq!(registry1.spec().title(), "Test API");
3692 assert_eq!(registry2.spec().title(), "Test API");
3693 }
3694
3695 #[test]
3696 fn test_validation_options_custom() {
3697 let options = ValidationOptions {
3698 request_mode: ValidationMode::Warn,
3699 aggregate_errors: false,
3700 validate_responses: true,
3701 overrides: {
3702 let mut map = HashMap::new();
3703 map.insert("getUsers".to_string(), ValidationMode::Disabled);
3704 map
3705 },
3706 admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
3707 response_template_expand: true,
3708 validation_status: Some(422),
3709 };
3710
3711 assert!(matches!(options.request_mode, ValidationMode::Warn));
3712 assert!(!options.aggregate_errors);
3713 assert!(options.validate_responses);
3714 assert_eq!(options.overrides.len(), 1);
3715 assert_eq!(options.admin_skip_prefixes.len(), 2);
3716 assert!(options.response_template_expand);
3717 assert_eq!(options.validation_status, Some(422));
3718 }
3719
3720 #[test]
3721 fn test_validation_mode_default_standalone() {
3722 let mode = ValidationMode::default();
3723 assert!(matches!(mode, ValidationMode::Warn));
3724 }
3725
3726 #[test]
3727 fn test_validation_mode_clone() {
3728 let mode1 = ValidationMode::Enforce;
3729 let mode2 = mode1.clone();
3730 assert!(matches!(mode1, ValidationMode::Enforce));
3731 assert!(matches!(mode2, ValidationMode::Enforce));
3732 }
3733
3734 #[test]
3735 fn test_validation_mode_debug() {
3736 let mode = ValidationMode::Disabled;
3737 let debug_str = format!("{:?}", mode);
3738 assert!(debug_str.contains("Disabled") || debug_str.contains("ValidationMode"));
3739 }
3740
3741 #[test]
3742 fn test_validation_options_clone() {
3743 let options1 = ValidationOptions {
3744 request_mode: ValidationMode::Warn,
3745 aggregate_errors: true,
3746 validate_responses: false,
3747 overrides: HashMap::new(),
3748 admin_skip_prefixes: vec![],
3749 response_template_expand: false,
3750 validation_status: None,
3751 };
3752 let options2 = options1.clone();
3753 assert!(matches!(options2.request_mode, ValidationMode::Warn));
3754 assert_eq!(options1.aggregate_errors, options2.aggregate_errors);
3755 }
3756
3757 #[test]
3758 fn test_validation_options_debug() {
3759 let options = ValidationOptions::default();
3760 let debug_str = format!("{:?}", options);
3761 assert!(debug_str.contains("ValidationOptions"));
3762 }
3763
3764 #[test]
3765 fn test_validation_options_with_all_fields() {
3766 let mut overrides = HashMap::new();
3767 overrides.insert("op1".to_string(), ValidationMode::Disabled);
3768 overrides.insert("op2".to_string(), ValidationMode::Warn);
3769
3770 let options = ValidationOptions {
3771 request_mode: ValidationMode::Enforce,
3772 aggregate_errors: false,
3773 validate_responses: true,
3774 overrides: overrides.clone(),
3775 admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
3776 response_template_expand: true,
3777 validation_status: Some(422),
3778 };
3779
3780 assert!(matches!(options.request_mode, ValidationMode::Enforce));
3781 assert!(!options.aggregate_errors);
3782 assert!(options.validate_responses);
3783 assert_eq!(options.overrides.len(), 2);
3784 assert_eq!(options.admin_skip_prefixes.len(), 2);
3785 assert!(options.response_template_expand);
3786 assert_eq!(options.validation_status, Some(422));
3787 }
3788
3789 #[test]
3790 fn test_openapi_route_registry_clone() {
3791 let spec_json = json!({
3792 "openapi": "3.0.0",
3793 "info": { "title": "Test API", "version": "1.0.0" },
3794 "paths": {}
3795 });
3796 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3797 let registry1 = OpenApiRouteRegistry::new(spec);
3798 let registry2 = registry1.clone();
3799 assert_eq!(registry1.spec().title(), registry2.spec().title());
3800 }
3801
3802 #[test]
3803 fn test_validation_mode_serialization() {
3804 let mode = ValidationMode::Enforce;
3805 let json = serde_json::to_string(&mode).unwrap();
3806 assert!(json.contains("Enforce") || json.contains("enforce"));
3807 }
3808
3809 #[test]
3810 fn test_validation_mode_deserialization() {
3811 let json = r#""Disabled""#;
3812 let mode: ValidationMode = serde_json::from_str(json).unwrap();
3813 assert!(matches!(mode, ValidationMode::Disabled));
3814 }
3815
3816 #[test]
3817 fn test_validation_options_default_values() {
3818 let options = ValidationOptions::default();
3819 assert!(matches!(options.request_mode, ValidationMode::Enforce));
3820 assert!(options.aggregate_errors);
3821 assert!(!options.validate_responses);
3822 assert!(options.overrides.is_empty());
3823 assert!(options.admin_skip_prefixes.is_empty());
3824 assert!(!options.response_template_expand);
3825 assert_eq!(options.validation_status, None);
3826 }
3827
3828 #[test]
3829 fn test_validation_mode_all_variants() {
3830 let disabled = ValidationMode::Disabled;
3831 let warn = ValidationMode::Warn;
3832 let enforce = ValidationMode::Enforce;
3833
3834 assert!(matches!(disabled, ValidationMode::Disabled));
3835 assert!(matches!(warn, ValidationMode::Warn));
3836 assert!(matches!(enforce, ValidationMode::Enforce));
3837 }
3838
3839 #[test]
3840 fn test_validation_options_with_overrides() {
3841 let mut overrides = HashMap::new();
3842 overrides.insert("operation1".to_string(), ValidationMode::Disabled);
3843 overrides.insert("operation2".to_string(), ValidationMode::Warn);
3844
3845 let options = ValidationOptions {
3846 request_mode: ValidationMode::Enforce,
3847 aggregate_errors: true,
3848 validate_responses: false,
3849 overrides,
3850 admin_skip_prefixes: vec![],
3851 response_template_expand: false,
3852 validation_status: None,
3853 };
3854
3855 assert_eq!(options.overrides.len(), 2);
3856 assert!(matches!(options.overrides.get("operation1"), Some(ValidationMode::Disabled)));
3857 assert!(matches!(options.overrides.get("operation2"), Some(ValidationMode::Warn)));
3858 }
3859
3860 #[test]
3861 fn test_validation_options_with_admin_skip_prefixes() {
3862 let options = ValidationOptions {
3863 request_mode: ValidationMode::Enforce,
3864 aggregate_errors: true,
3865 validate_responses: false,
3866 overrides: HashMap::new(),
3867 admin_skip_prefixes: vec![
3868 "/admin".to_string(),
3869 "/internal".to_string(),
3870 "/debug".to_string(),
3871 ],
3872 response_template_expand: false,
3873 validation_status: None,
3874 };
3875
3876 assert_eq!(options.admin_skip_prefixes.len(), 3);
3877 assert!(options.admin_skip_prefixes.contains(&"/admin".to_string()));
3878 assert!(options.admin_skip_prefixes.contains(&"/internal".to_string()));
3879 assert!(options.admin_skip_prefixes.contains(&"/debug".to_string()));
3880 }
3881
3882 #[test]
3883 fn test_validation_options_with_validation_status() {
3884 let options1 = ValidationOptions {
3885 request_mode: ValidationMode::Enforce,
3886 aggregate_errors: true,
3887 validate_responses: false,
3888 overrides: HashMap::new(),
3889 admin_skip_prefixes: vec![],
3890 response_template_expand: false,
3891 validation_status: Some(400),
3892 };
3893
3894 let options2 = ValidationOptions {
3895 request_mode: ValidationMode::Enforce,
3896 aggregate_errors: true,
3897 validate_responses: false,
3898 overrides: HashMap::new(),
3899 admin_skip_prefixes: vec![],
3900 response_template_expand: false,
3901 validation_status: Some(422),
3902 };
3903
3904 assert_eq!(options1.validation_status, Some(400));
3905 assert_eq!(options2.validation_status, Some(422));
3906 }
3907
3908 #[test]
3909 fn test_validate_request_with_disabled_mode() {
3910 let spec_json = json!({
3912 "openapi": "3.0.0",
3913 "info": {"title": "Test API", "version": "1.0.0"},
3914 "paths": {
3915 "/users": {
3916 "get": {
3917 "responses": {"200": {"description": "OK"}}
3918 }
3919 }
3920 }
3921 });
3922 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3923 let options = ValidationOptions {
3924 request_mode: ValidationMode::Disabled,
3925 ..Default::default()
3926 };
3927 let registry = OpenApiRouteRegistry::new_with_options(spec, options);
3928
3929 let result = registry.validate_request_with_all(
3931 "/users",
3932 "GET",
3933 &Map::new(),
3934 &Map::new(),
3935 &Map::new(),
3936 &Map::new(),
3937 None,
3938 );
3939 assert!(result.is_ok());
3940 }
3941
3942 #[test]
3943 fn test_validate_request_with_warn_mode() {
3944 let spec_json = json!({
3946 "openapi": "3.0.0",
3947 "info": {"title": "Test API", "version": "1.0.0"},
3948 "paths": {
3949 "/users": {
3950 "post": {
3951 "requestBody": {
3952 "required": true,
3953 "content": {
3954 "application/json": {
3955 "schema": {
3956 "type": "object",
3957 "required": ["name"],
3958 "properties": {
3959 "name": {"type": "string"}
3960 }
3961 }
3962 }
3963 }
3964 },
3965 "responses": {"200": {"description": "OK"}}
3966 }
3967 }
3968 }
3969 });
3970 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3971 let options = ValidationOptions {
3972 request_mode: ValidationMode::Warn,
3973 ..Default::default()
3974 };
3975 let registry = OpenApiRouteRegistry::new_with_options(spec, options);
3976
3977 let result = registry.validate_request_with_all(
3979 "/users",
3980 "POST",
3981 &Map::new(),
3982 &Map::new(),
3983 &Map::new(),
3984 &Map::new(),
3985 None, );
3987 assert!(result.is_ok()); }
3989
3990 #[test]
3991 fn test_validate_request_body_validation_error() {
3992 let spec_json = json!({
3994 "openapi": "3.0.0",
3995 "info": {"title": "Test API", "version": "1.0.0"},
3996 "paths": {
3997 "/users": {
3998 "post": {
3999 "requestBody": {
4000 "required": true,
4001 "content": {
4002 "application/json": {
4003 "schema": {
4004 "type": "object",
4005 "required": ["name"],
4006 "properties": {
4007 "name": {"type": "string"}
4008 }
4009 }
4010 }
4011 }
4012 },
4013 "responses": {"200": {"description": "OK"}}
4014 }
4015 }
4016 }
4017 });
4018 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4019 let registry = OpenApiRouteRegistry::new(spec);
4020
4021 let result = registry.validate_request_with_all(
4023 "/users",
4024 "POST",
4025 &Map::new(),
4026 &Map::new(),
4027 &Map::new(),
4028 &Map::new(),
4029 None, );
4031 assert!(result.is_err());
4032 }
4033
4034 #[test]
4035 fn test_validate_request_body_schema_validation_error() {
4036 let spec_json = json!({
4038 "openapi": "3.0.0",
4039 "info": {"title": "Test API", "version": "1.0.0"},
4040 "paths": {
4041 "/users": {
4042 "post": {
4043 "requestBody": {
4044 "required": true,
4045 "content": {
4046 "application/json": {
4047 "schema": {
4048 "type": "object",
4049 "required": ["name"],
4050 "properties": {
4051 "name": {"type": "string"}
4052 }
4053 }
4054 }
4055 }
4056 },
4057 "responses": {"200": {"description": "OK"}}
4058 }
4059 }
4060 }
4061 });
4062 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4063 let registry = OpenApiRouteRegistry::new(spec);
4064
4065 let invalid_body = json!({}); let result = registry.validate_request_with_all(
4068 "/users",
4069 "POST",
4070 &Map::new(),
4071 &Map::new(),
4072 &Map::new(),
4073 &Map::new(),
4074 Some(&invalid_body),
4075 );
4076 assert!(result.is_err());
4077 }
4078
4079 #[test]
4080 fn test_validate_request_body_referenced_schema_error() {
4081 let spec_json = json!({
4083 "openapi": "3.0.0",
4084 "info": {"title": "Test API", "version": "1.0.0"},
4085 "paths": {
4086 "/users": {
4087 "post": {
4088 "requestBody": {
4089 "required": true,
4090 "content": {
4091 "application/json": {
4092 "schema": {
4093 "$ref": "#/components/schemas/NonExistentSchema"
4094 }
4095 }
4096 }
4097 },
4098 "responses": {"200": {"description": "OK"}}
4099 }
4100 }
4101 },
4102 "components": {}
4103 });
4104 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4105 let registry = OpenApiRouteRegistry::new(spec);
4106
4107 let body = json!({"name": "test"});
4109 let result = registry.validate_request_with_all(
4110 "/users",
4111 "POST",
4112 &Map::new(),
4113 &Map::new(),
4114 &Map::new(),
4115 &Map::new(),
4116 Some(&body),
4117 );
4118 assert!(result.is_err());
4119 }
4120
4121 #[test]
4122 fn test_validate_request_body_referenced_request_body_error() {
4123 let spec_json = json!({
4125 "openapi": "3.0.0",
4126 "info": {"title": "Test API", "version": "1.0.0"},
4127 "paths": {
4128 "/users": {
4129 "post": {
4130 "requestBody": {
4131 "$ref": "#/components/requestBodies/NonExistentRequestBody"
4132 },
4133 "responses": {"200": {"description": "OK"}}
4134 }
4135 }
4136 },
4137 "components": {}
4138 });
4139 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4140 let registry = OpenApiRouteRegistry::new(spec);
4141
4142 let body = json!({"name": "test"});
4144 let result = registry.validate_request_with_all(
4145 "/users",
4146 "POST",
4147 &Map::new(),
4148 &Map::new(),
4149 &Map::new(),
4150 &Map::new(),
4151 Some(&body),
4152 );
4153 assert!(result.is_err());
4154 }
4155
4156 #[test]
4157 fn test_validate_request_body_provided_when_not_expected() {
4158 let spec_json = json!({
4160 "openapi": "3.0.0",
4161 "info": {"title": "Test API", "version": "1.0.0"},
4162 "paths": {
4163 "/users": {
4164 "get": {
4165 "responses": {"200": {"description": "OK"}}
4166 }
4167 }
4168 }
4169 });
4170 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4171 let registry = OpenApiRouteRegistry::new(spec);
4172
4173 let body = json!({"extra": "data"});
4175 let result = registry.validate_request_with_all(
4176 "/users",
4177 "GET",
4178 &Map::new(),
4179 &Map::new(),
4180 &Map::new(),
4181 &Map::new(),
4182 Some(&body),
4183 );
4184 assert!(result.is_ok());
4186 }
4187
4188 #[test]
4189 fn test_get_operation() {
4190 let spec_json = json!({
4192 "openapi": "3.0.0",
4193 "info": {"title": "Test API", "version": "1.0.0"},
4194 "paths": {
4195 "/users": {
4196 "get": {
4197 "operationId": "getUsers",
4198 "summary": "Get users",
4199 "responses": {"200": {"description": "OK"}}
4200 }
4201 }
4202 }
4203 });
4204 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4205 let registry = OpenApiRouteRegistry::new(spec);
4206
4207 let operation = registry.get_operation("/users", "GET");
4209 assert!(operation.is_some());
4210 assert_eq!(operation.unwrap().method, "GET");
4211
4212 assert!(registry.get_operation("/nonexistent", "GET").is_none());
4214 }
4215
4216 #[test]
4217 fn test_extract_path_parameters() {
4218 let spec_json = json!({
4220 "openapi": "3.0.0",
4221 "info": {"title": "Test API", "version": "1.0.0"},
4222 "paths": {
4223 "/users/{id}": {
4224 "get": {
4225 "parameters": [
4226 {
4227 "name": "id",
4228 "in": "path",
4229 "required": true,
4230 "schema": {"type": "string"}
4231 }
4232 ],
4233 "responses": {"200": {"description": "OK"}}
4234 }
4235 }
4236 }
4237 });
4238 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4239 let registry = OpenApiRouteRegistry::new(spec);
4240
4241 let params = registry.extract_path_parameters("/users/123", "GET");
4243 assert_eq!(params.get("id"), Some(&"123".to_string()));
4244
4245 let empty_params = registry.extract_path_parameters("/users", "GET");
4247 assert!(empty_params.is_empty());
4248 }
4249
4250 #[test]
4251 fn extract_path_parameters_prefers_static_route_and_rejects_empty() {
4252 let spec_json = json!({
4255 "openapi": "3.0.0",
4256 "info": {"title": "Test API", "version": "1.0.0"},
4257 "paths": {
4258 "/users/{id}": { "get": { "responses": {"200": {"description": "OK"}} } },
4259 "/users/me": { "get": { "responses": {"200": {"description": "OK"}} } }
4260 }
4261 });
4262 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4263 let registry = OpenApiRouteRegistry::new(spec);
4264
4265 let me = registry.extract_path_parameters("/users/me", "GET");
4267 assert!(!me.contains_key("id"), "literal route should win, got {me:?}");
4268
4269 let by_id = registry.extract_path_parameters("/users/123", "GET");
4271 assert_eq!(by_id.get("id"), Some(&"123".to_string()));
4272
4273 let trailing = registry.extract_path_parameters("/users/", "GET");
4275 assert!(
4276 trailing.is_empty(),
4277 "empty trailing segment should not bind id, got {trailing:?}"
4278 );
4279 }
4280
4281 #[test]
4282 fn test_extract_path_parameters_multiple_params() {
4283 let spec_json = json!({
4285 "openapi": "3.0.0",
4286 "info": {"title": "Test API", "version": "1.0.0"},
4287 "paths": {
4288 "/users/{userId}/posts/{postId}": {
4289 "get": {
4290 "parameters": [
4291 {
4292 "name": "userId",
4293 "in": "path",
4294 "required": true,
4295 "schema": {"type": "string"}
4296 },
4297 {
4298 "name": "postId",
4299 "in": "path",
4300 "required": true,
4301 "schema": {"type": "string"}
4302 }
4303 ],
4304 "responses": {"200": {"description": "OK"}}
4305 }
4306 }
4307 }
4308 });
4309 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4310 let registry = OpenApiRouteRegistry::new(spec);
4311
4312 let params = registry.extract_path_parameters("/users/123/posts/456", "GET");
4314 assert_eq!(params.get("userId"), Some(&"123".to_string()));
4315 assert_eq!(params.get("postId"), Some(&"456".to_string()));
4316 }
4317
4318 #[test]
4319 fn test_validate_request_route_not_found() {
4320 let spec_json = json!({
4322 "openapi": "3.0.0",
4323 "info": {"title": "Test API", "version": "1.0.0"},
4324 "paths": {
4325 "/users": {
4326 "get": {
4327 "responses": {"200": {"description": "OK"}}
4328 }
4329 }
4330 }
4331 });
4332 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4333 let registry = OpenApiRouteRegistry::new(spec);
4334
4335 let result = registry.validate_request_with_all(
4337 "/nonexistent",
4338 "GET",
4339 &Map::new(),
4340 &Map::new(),
4341 &Map::new(),
4342 &Map::new(),
4343 None,
4344 );
4345 assert!(result.is_err());
4346 assert!(result.unwrap_err().to_string().contains("not found"));
4347 }
4348
4349 #[test]
4350 fn test_validate_request_with_path_parameters() {
4351 let spec_json = json!({
4353 "openapi": "3.0.0",
4354 "info": {"title": "Test API", "version": "1.0.0"},
4355 "paths": {
4356 "/users/{id}": {
4357 "get": {
4358 "parameters": [
4359 {
4360 "name": "id",
4361 "in": "path",
4362 "required": true,
4363 "schema": {"type": "string", "minLength": 1}
4364 }
4365 ],
4366 "responses": {"200": {"description": "OK"}}
4367 }
4368 }
4369 }
4370 });
4371 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4372 let registry = OpenApiRouteRegistry::new(spec);
4373
4374 let mut path_params = Map::new();
4376 path_params.insert("id".to_string(), json!("123"));
4377 let result = registry.validate_request_with_all(
4378 "/users/{id}",
4379 "GET",
4380 &path_params,
4381 &Map::new(),
4382 &Map::new(),
4383 &Map::new(),
4384 None,
4385 );
4386 assert!(result.is_ok());
4387 }
4388
4389 #[test]
4390 fn test_validate_request_with_query_parameters() {
4391 let spec_json = json!({
4393 "openapi": "3.0.0",
4394 "info": {"title": "Test API", "version": "1.0.0"},
4395 "paths": {
4396 "/users": {
4397 "get": {
4398 "parameters": [
4399 {
4400 "name": "page",
4401 "in": "query",
4402 "required": true,
4403 "schema": {"type": "integer", "minimum": 1}
4404 }
4405 ],
4406 "responses": {"200": {"description": "OK"}}
4407 }
4408 }
4409 }
4410 });
4411 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4412 let registry = OpenApiRouteRegistry::new(spec);
4413
4414 let mut query_params = Map::new();
4416 query_params.insert("page".to_string(), json!(1));
4417 let result = registry.validate_request_with_all(
4418 "/users",
4419 "GET",
4420 &Map::new(),
4421 &query_params,
4422 &Map::new(),
4423 &Map::new(),
4424 None,
4425 );
4426 assert!(result.is_ok());
4427 }
4428
4429 #[test]
4430 fn test_validate_request_with_header_parameters() {
4431 let spec_json = json!({
4433 "openapi": "3.0.0",
4434 "info": {"title": "Test API", "version": "1.0.0"},
4435 "paths": {
4436 "/users": {
4437 "get": {
4438 "parameters": [
4439 {
4440 "name": "X-API-Key",
4441 "in": "header",
4442 "required": true,
4443 "schema": {"type": "string"}
4444 }
4445 ],
4446 "responses": {"200": {"description": "OK"}}
4447 }
4448 }
4449 }
4450 });
4451 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4452 let registry = OpenApiRouteRegistry::new(spec);
4453
4454 let mut header_params = Map::new();
4456 header_params.insert("X-API-Key".to_string(), json!("secret-key"));
4457 let result = registry.validate_request_with_all(
4458 "/users",
4459 "GET",
4460 &Map::new(),
4461 &Map::new(),
4462 &header_params,
4463 &Map::new(),
4464 None,
4465 );
4466 assert!(result.is_ok());
4467 }
4468
4469 #[test]
4470 fn test_validate_request_with_cookie_parameters() {
4471 let spec_json = json!({
4473 "openapi": "3.0.0",
4474 "info": {"title": "Test API", "version": "1.0.0"},
4475 "paths": {
4476 "/users": {
4477 "get": {
4478 "parameters": [
4479 {
4480 "name": "sessionId",
4481 "in": "cookie",
4482 "required": true,
4483 "schema": {"type": "string"}
4484 }
4485 ],
4486 "responses": {"200": {"description": "OK"}}
4487 }
4488 }
4489 }
4490 });
4491 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4492 let registry = OpenApiRouteRegistry::new(spec);
4493
4494 let mut cookie_params = Map::new();
4496 cookie_params.insert("sessionId".to_string(), json!("abc123"));
4497 let result = registry.validate_request_with_all(
4498 "/users",
4499 "GET",
4500 &Map::new(),
4501 &Map::new(),
4502 &Map::new(),
4503 &cookie_params,
4504 None,
4505 );
4506 assert!(result.is_ok());
4507 }
4508
4509 #[test]
4510 fn test_validate_request_no_errors_early_return() {
4511 let spec_json = json!({
4513 "openapi": "3.0.0",
4514 "info": {"title": "Test API", "version": "1.0.0"},
4515 "paths": {
4516 "/users": {
4517 "get": {
4518 "responses": {"200": {"description": "OK"}}
4519 }
4520 }
4521 }
4522 });
4523 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4524 let registry = OpenApiRouteRegistry::new(spec);
4525
4526 let result = registry.validate_request_with_all(
4528 "/users",
4529 "GET",
4530 &Map::new(),
4531 &Map::new(),
4532 &Map::new(),
4533 &Map::new(),
4534 None,
4535 );
4536 assert!(result.is_ok());
4537 }
4538
4539 #[test]
4540 fn test_validate_request_query_parameter_different_styles() {
4541 let spec_json = json!({
4543 "openapi": "3.0.0",
4544 "info": {"title": "Test API", "version": "1.0.0"},
4545 "paths": {
4546 "/users": {
4547 "get": {
4548 "parameters": [
4549 {
4550 "name": "tags",
4551 "in": "query",
4552 "style": "pipeDelimited",
4553 "schema": {
4554 "type": "array",
4555 "items": {"type": "string"}
4556 }
4557 }
4558 ],
4559 "responses": {"200": {"description": "OK"}}
4560 }
4561 }
4562 }
4563 });
4564 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4565 let registry = OpenApiRouteRegistry::new(spec);
4566
4567 let mut query_params = Map::new();
4569 query_params.insert("tags".to_string(), json!(["tag1", "tag2"]));
4570 let result = registry.validate_request_with_all(
4571 "/users",
4572 "GET",
4573 &Map::new(),
4574 &query_params,
4575 &Map::new(),
4576 &Map::new(),
4577 None,
4578 );
4579 assert!(result.is_ok() || result.is_err()); }
4582}