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