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>>,
1824 query: axum::extract::Query<HashMap<String, String>>,
1825 headers: HeaderMap,
1826 body_bytes: axum::body::Bytes| {
1827 let route = route_clone.clone();
1828 let mockai = mockai_clone.clone();
1829 let validator = validator_clone.clone();
1830
1831 async move {
1832 let mut path_map = Map::new();
1833 for (k, v) in &path_params {
1834 path_map.insert(k.clone(), Value::String(v.clone()));
1835 }
1836 let mut query_map = Map::new();
1837 for (k, v) in &query.0 {
1838 query_map.insert(k.clone(), Value::String(v.clone()));
1839 }
1840 let mut header_map = Map::new();
1841 for (k, v) in headers.iter() {
1842 if let Ok(s) = v.to_str() {
1843 header_map.insert(k.to_string(), Value::String(s.to_string()));
1844 }
1845 }
1846
1847 if !body_bytes.is_empty() {
1853 let actual_ct = headers
1854 .get(axum::http::header::CONTENT_TYPE)
1855 .and_then(|v| v.to_str().ok());
1856 if let Err(ct_err) = validator.check_request_content_type(
1857 &route.path,
1858 &route.method,
1859 actual_ct,
1860 ) {
1861 let status_code =
1862 validator.options.validation_status.unwrap_or_else(|| {
1863 std::env::var("MOCKFORGE_VALIDATION_STATUS")
1864 .ok()
1865 .and_then(|s| s.parse::<u16>().ok())
1866 .unwrap_or(415)
1867 });
1868 mockforge_foundation::conformance_violations::record(
1869 mockforge_foundation::conformance_violations::ServerConformanceViolation {
1870 timestamp: Utc::now(),
1871 method: route.method.clone(),
1872 path: route.path.clone(),
1873 client_ip: "unknown".to_string(),
1874 status: status_code,
1875 reason: ct_err.clone(),
1876 category: "content-types".to_string(),
1877 occurrences: 1,
1878 },
1879 );
1880 let status = axum::http::StatusCode::from_u16(status_code)
1881 .unwrap_or(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
1882 return (
1883 status,
1884 Json(serde_json::json!({
1885 "error": "content_type_not_allowed",
1886 "message": ct_err,
1887 })),
1888 );
1889 }
1890 }
1891
1892 let body: Option<Json<Value>> = if body_bytes.is_empty() {
1898 None
1899 } else {
1900 serde_json::from_slice::<Value>(&body_bytes).ok().map(Json)
1901 };
1902
1903 let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
1908 if let Err((status_code, payload)) = validator.run_validation_with_recording(
1909 &route.path,
1910 &route.method,
1911 &path_map,
1912 &query_map,
1913 &header_map,
1914 &Map::new(),
1915 body_val,
1916 ) {
1917 let status = axum::http::StatusCode::from_u16(status_code)
1918 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
1919 return (status, Json(payload));
1920 }
1921
1922 tracing::info!(
1923 "[FIXTURE DEBUG] Starting fixture check for {} {} (custom_loader available: {})",
1924 route.method,
1925 route.path,
1926 custom_loader_clone.is_some()
1927 );
1928
1929 if let Some(ref loader) = custom_loader_clone {
1931 use crate::request_fingerprint::RequestFingerprint;
1932 use axum::http::{Method, Uri};
1933
1934 let query_string = if query.0.is_empty() {
1936 String::new()
1937 } else {
1938 query
1939 .0
1940 .iter()
1941 .map(|(k, v)| format!("{}={}", k, v))
1942 .collect::<Vec<_>>()
1943 .join("&")
1944 };
1945
1946 let normalized_request_path =
1948 crate::custom_fixture::CustomFixtureLoader::normalize_path(&route.path);
1949
1950 tracing::info!(
1951 "[FIXTURE DEBUG] Path normalization: original='{}', normalized='{}'",
1952 route.path,
1953 normalized_request_path
1954 );
1955
1956 let uri_str = if query_string.is_empty() {
1958 normalized_request_path.clone()
1959 } else {
1960 format!("{}?{}", normalized_request_path, query_string)
1961 };
1962
1963 tracing::info!(
1964 "[FIXTURE DEBUG] URI construction: uri_str='{}', query_string='{}'",
1965 uri_str,
1966 query_string
1967 );
1968
1969 if let Ok(uri) = uri_str.parse::<Uri>() {
1970 let http_method =
1971 Method::from_bytes(route.method.as_bytes()).unwrap_or(Method::GET);
1972
1973 let body_bytes =
1975 body.as_ref().and_then(|Json(b)| serde_json::to_vec(b).ok());
1976 let body_slice = body_bytes.as_deref();
1977
1978 let fingerprint =
1979 RequestFingerprint::new(http_method, &uri, &headers, body_slice);
1980
1981 tracing::info!(
1982 "[FIXTURE DEBUG] RequestFingerprint created: method='{}', path='{}', query='{}', body_hash={:?}",
1983 fingerprint.method,
1984 fingerprint.path,
1985 fingerprint.query,
1986 fingerprint.body_hash
1987 );
1988
1989 let available_fixtures = loader.has_fixture(&fingerprint);
1991 tracing::info!(
1992 "[FIXTURE DEBUG] Fixture check result: has_fixture={}",
1993 available_fixtures
1994 );
1995
1996 if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
1997 tracing::info!(
1998 "[FIXTURE DEBUG] ✅ FIXTURE MATCHED! Using custom fixture for {} {} (status: {}, path: '{}')",
1999 route.method,
2000 route.path,
2001 custom_fixture.status,
2002 custom_fixture.path
2003 );
2004
2005 if custom_fixture.delay_ms > 0 {
2007 tokio::time::sleep(tokio::time::Duration::from_millis(
2008 custom_fixture.delay_ms,
2009 ))
2010 .await;
2011 }
2012
2013 let response_body = if custom_fixture.response.is_string() {
2015 custom_fixture.response.as_str().unwrap().to_string()
2016 } else {
2017 serde_json::to_string(&custom_fixture.response)
2018 .unwrap_or_else(|_| "{}".to_string())
2019 };
2020
2021 let json_value: Value = serde_json::from_str(&response_body)
2023 .unwrap_or_else(|_| serde_json::json!({}));
2024
2025 let status =
2027 axum::http::StatusCode::from_u16(custom_fixture.status)
2028 .unwrap_or(axum::http::StatusCode::OK);
2029
2030 return (status, Json(json_value));
2032 } else {
2033 tracing::warn!(
2034 "[FIXTURE DEBUG] ❌ No fixture match found for {} {} (fingerprint.path='{}', normalized='{}')",
2035 route.method,
2036 route.path,
2037 fingerprint.path,
2038 normalized_request_path
2039 );
2040 }
2041 } else {
2042 tracing::warn!("[FIXTURE DEBUG] Failed to parse URI: '{}'", uri_str);
2043 }
2044 } else {
2045 tracing::warn!(
2046 "[FIXTURE DEBUG] Custom fixture loader not available for {} {}",
2047 route.method,
2048 route.path
2049 );
2050 }
2051
2052 tracing::debug!(
2053 "Handling MockAI request for route: {} {}",
2054 route.method,
2055 route.path
2056 );
2057
2058 let mockai_query = query.0;
2060
2061 let method_upper = route.method.to_uppercase();
2066 let should_use_mockai =
2067 matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH" | "DELETE");
2068
2069 if should_use_mockai {
2070 if let Some(mockai_arc) = mockai {
2071 let mockai_guard = mockai_arc.read().await;
2072
2073 let mut mockai_headers = HashMap::new();
2075 for (k, v) in headers.iter() {
2076 mockai_headers
2077 .insert(k.to_string(), v.to_str().unwrap_or("").to_string());
2078 }
2079
2080 let mockai_request = MockAIRequest {
2081 method: route.method.clone(),
2082 path: route.path.clone(),
2083 body: body.as_ref().map(|Json(b)| b.clone()),
2084 query_params: mockai_query,
2085 headers: mockai_headers,
2086 };
2087
2088 match mockai_guard.process_request(&mockai_request).await {
2090 Ok(mockai_response) => {
2091 let is_empty = mockai_response.body.is_object()
2093 && mockai_response
2094 .body
2095 .as_object()
2096 .map(|obj| obj.is_empty())
2097 .unwrap_or(false);
2098
2099 if is_empty {
2100 tracing::debug!(
2101 "MockAI returned empty object for {} {}, falling back to OpenAPI response generation",
2102 route.method,
2103 route.path
2104 );
2105 } else {
2107 let spec_status = route.find_first_available_status_code();
2111 tracing::debug!(
2112 "MockAI generated response for {} {}, using spec status: {} (MockAI suggested: {})",
2113 route.method,
2114 route.path,
2115 spec_status,
2116 mockai_response.status_code
2117 );
2118 return (
2119 axum::http::StatusCode::from_u16(spec_status)
2120 .unwrap_or(axum::http::StatusCode::OK),
2121 Json(mockai_response.body),
2122 );
2123 }
2124 }
2125 Err(e) => {
2126 tracing::warn!(
2127 "MockAI processing failed for {} {}: {}, falling back to standard response",
2128 route.method,
2129 route.path,
2130 e
2131 );
2132 }
2134 }
2135 }
2136 } else {
2137 tracing::debug!(
2138 "Skipping MockAI for {} request {} - using OpenAPI response generation",
2139 method_upper,
2140 route.path
2141 );
2142 }
2143
2144 let status_override = headers
2146 .get("X-Mockforge-Response-Status")
2147 .and_then(|v| v.to_str().ok())
2148 .and_then(|s| s.parse::<u16>().ok());
2149
2150 let scenario = headers
2152 .get("X-Mockforge-Scenario")
2153 .and_then(|v| v.to_str().ok())
2154 .map(|s| s.to_string())
2155 .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
2156
2157 let (status, response) = route
2159 .mock_response_with_status_and_scenario_and_override(
2160 scenario.as_deref(),
2161 status_override,
2162 );
2163 (
2164 axum::http::StatusCode::from_u16(status)
2165 .unwrap_or(axum::http::StatusCode::OK),
2166 Json(response),
2167 )
2168 }
2169 };
2170
2171 router = Self::route_for_method(router, axum_path, &route.method, handler);
2172 }
2173
2174 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
2177 }
2178}
2179
2180async fn extract_multipart_from_bytes(
2185 body: &axum::body::Bytes,
2186 headers: &HeaderMap,
2187) -> Result<(HashMap<String, Value>, HashMap<String, String>)> {
2188 let boundary = headers
2190 .get(axum::http::header::CONTENT_TYPE)
2191 .and_then(|v| v.to_str().ok())
2192 .and_then(|ct| {
2193 ct.split(';').find_map(|part| {
2194 let part = part.trim();
2195 if part.starts_with("boundary=") {
2196 Some(part.strip_prefix("boundary=").unwrap_or("").trim_matches('"'))
2197 } else {
2198 None
2199 }
2200 })
2201 })
2202 .ok_or_else(|| Error::internal("Missing boundary in Content-Type header"))?;
2203
2204 let mut fields = HashMap::new();
2205 let mut files = HashMap::new();
2206
2207 let boundary_prefix = format!("--{}", boundary).into_bytes();
2210 let boundary_line = format!("\r\n--{}\r\n", boundary).into_bytes();
2211 let end_boundary = format!("\r\n--{}--\r\n", boundary).into_bytes();
2212
2213 let mut pos = 0;
2215 let mut parts = Vec::new();
2216
2217 if body.starts_with(&boundary_prefix) {
2219 if let Some(first_crlf) = body.iter().position(|&b| b == b'\r') {
2220 pos = first_crlf + 2; }
2222 }
2223
2224 while let Some(boundary_pos) = body[pos..]
2226 .windows(boundary_line.len())
2227 .position(|window| window == boundary_line.as_slice())
2228 {
2229 let actual_pos = pos + boundary_pos;
2230 if actual_pos > pos {
2231 parts.push((pos, actual_pos));
2232 }
2233 pos = actual_pos + boundary_line.len();
2234 }
2235
2236 if let Some(end_pos) = body[pos..]
2238 .windows(end_boundary.len())
2239 .position(|window| window == end_boundary.as_slice())
2240 {
2241 let actual_end = pos + end_pos;
2242 if actual_end > pos {
2243 parts.push((pos, actual_end));
2244 }
2245 } else if pos < body.len() {
2246 parts.push((pos, body.len()));
2248 }
2249
2250 for (start, end) in parts {
2252 let part_data = &body[start..end];
2253
2254 let separator = b"\r\n\r\n";
2256 if let Some(sep_pos) =
2257 part_data.windows(separator.len()).position(|window| window == separator)
2258 {
2259 let header_bytes = &part_data[..sep_pos];
2260 let body_start = sep_pos + separator.len();
2261 let body_data = &part_data[body_start..];
2262
2263 let header_str = String::from_utf8_lossy(header_bytes);
2265 let mut field_name = None;
2266 let mut filename = None;
2267
2268 for header_line in header_str.lines() {
2269 if header_line.starts_with("Content-Disposition:") {
2270 if let Some(name_start) = header_line.find("name=\"") {
2272 let name_start = name_start + 6;
2273 if let Some(name_end) = header_line[name_start..].find('"') {
2274 field_name =
2275 Some(header_line[name_start..name_start + name_end].to_string());
2276 }
2277 }
2278
2279 if let Some(file_start) = header_line.find("filename=\"") {
2281 let file_start = file_start + 10;
2282 if let Some(file_end) = header_line[file_start..].find('"') {
2283 filename =
2284 Some(header_line[file_start..file_start + file_end].to_string());
2285 }
2286 }
2287 }
2288 }
2289
2290 if let Some(name) = field_name {
2291 if let Some(file) = filename {
2292 let temp_dir = std::env::temp_dir().join("mockforge-uploads");
2294 std::fs::create_dir_all(&temp_dir)
2295 .map_err(|e| Error::io_with_context("temp directory", e.to_string()))?;
2296
2297 let file_path = temp_dir.join(format!("{}_{}", uuid::Uuid::new_v4(), file));
2298 std::fs::write(&file_path, body_data)
2299 .map_err(|e| Error::io_with_context("file", e.to_string()))?;
2300
2301 let file_path_str = file_path.to_string_lossy().to_string();
2302 files.insert(name.clone(), file_path_str.clone());
2303 fields.insert(name, Value::String(file_path_str));
2304 } else {
2305 let body_str = body_data
2308 .strip_suffix(b"\r\n")
2309 .or_else(|| body_data.strip_suffix(b"\n"))
2310 .unwrap_or(body_data);
2311
2312 if let Ok(field_value) = String::from_utf8(body_str.to_vec()) {
2313 fields.insert(name, Value::String(field_value.trim().to_string()));
2314 } else {
2315 use base64::{engine::general_purpose, Engine as _};
2317 fields.insert(
2318 name,
2319 Value::String(general_purpose::STANDARD.encode(body_str)),
2320 );
2321 }
2322 }
2323 }
2324 }
2325 }
2326
2327 Ok((fields, files))
2328}
2329
2330static LAST_ERRORS: Lazy<Mutex<VecDeque<Value>>> =
2331 Lazy::new(|| Mutex::new(VecDeque::with_capacity(20)));
2332
2333pub fn classify_validation_reason(reason: &str) -> String {
2341 let r = reason.to_ascii_lowercase();
2342 if r.contains("required")
2343 && (r.contains("param") || r.contains("query") || r.contains("header"))
2344 {
2345 return "parameters".into();
2346 }
2347 if r.contains("schema") || r.contains("body") || r.contains("json") {
2348 return "request-body".into();
2349 }
2350 if r.contains("content-type") || r.contains("content type") {
2351 return "content-types".into();
2352 }
2353 if r.contains("header") {
2354 return "headers".into();
2355 }
2356 if r.contains("cookie") {
2357 return "cookies".into();
2358 }
2359 if r.contains("method") {
2360 return "http-methods".into();
2361 }
2362 if r.contains("auth") || r.contains("security") {
2363 return "security".into();
2364 }
2365 if r.contains("enum") || r.contains("min") || r.contains("max") || r.contains("pattern") {
2366 return "constraints".into();
2367 }
2368 String::new()
2369}
2370
2371pub fn record_validation_error(v: &Value) {
2373 if let Ok(mut q) = LAST_ERRORS.lock() {
2374 if q.len() >= 20 {
2375 q.pop_front();
2376 }
2377 q.push_back(v.clone());
2378 }
2379 }
2381
2382pub fn get_last_validation_error() -> Option<Value> {
2384 LAST_ERRORS.lock().ok()?.back().cloned()
2385}
2386
2387pub fn get_validation_errors() -> Vec<Value> {
2389 LAST_ERRORS.lock().map(|q| q.iter().cloned().collect()).unwrap_or_default()
2390}
2391
2392fn coerce_value_for_schema(value: &Value, schema: &openapiv3::Schema) -> Value {
2397 match value {
2399 Value::String(s) => {
2400 if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2402 &schema.schema_kind
2403 {
2404 if s.contains(',') {
2405 let parts: Vec<&str> = s.split(',').map(|s| s.trim()).collect();
2407 let mut array_values = Vec::new();
2408
2409 for part in parts {
2410 if let Some(items_schema) = &array_type.items {
2412 if let Some(items_schema_obj) = items_schema.as_item() {
2413 let part_value = Value::String(part.to_string());
2414 let coerced_part =
2415 coerce_value_for_schema(&part_value, items_schema_obj);
2416 array_values.push(coerced_part);
2417 } else {
2418 array_values.push(Value::String(part.to_string()));
2420 }
2421 } else {
2422 array_values.push(Value::String(part.to_string()));
2424 }
2425 }
2426 return Value::Array(array_values);
2427 }
2428 }
2429
2430 match &schema.schema_kind {
2432 openapiv3::SchemaKind::Type(openapiv3::Type::String(_)) => {
2433 value.clone()
2435 }
2436 openapiv3::SchemaKind::Type(openapiv3::Type::Number(_)) => {
2437 if let Ok(n) = s.parse::<f64>() {
2439 if let Some(num) = serde_json::Number::from_f64(n) {
2440 return Value::Number(num);
2441 }
2442 }
2443 value.clone()
2444 }
2445 openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_)) => {
2446 if let Ok(n) = s.parse::<i64>() {
2448 if let Some(num) = serde_json::Number::from_f64(n as f64) {
2449 return Value::Number(num);
2450 }
2451 }
2452 value.clone()
2453 }
2454 openapiv3::SchemaKind::Type(openapiv3::Type::Boolean(_)) => {
2455 match s.to_lowercase().as_str() {
2457 "true" | "1" | "yes" | "on" => Value::Bool(true),
2458 "false" | "0" | "no" | "off" => Value::Bool(false),
2459 _ => value.clone(),
2460 }
2461 }
2462 _ => {
2463 value.clone()
2465 }
2466 }
2467 }
2468 _ => value.clone(),
2469 }
2470}
2471
2472fn coerce_by_style(value: &Value, schema: &openapiv3::Schema, style: Option<&str>) -> Value {
2474 match value {
2476 Value::String(s) => {
2477 if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2479 &schema.schema_kind
2480 {
2481 let delimiter = match style {
2482 Some("spaceDelimited") => " ",
2483 Some("pipeDelimited") => "|",
2484 Some("form") | None => ",", _ => ",", };
2487
2488 if s.contains(delimiter) {
2489 let parts: Vec<&str> = s.split(delimiter).map(|s| s.trim()).collect();
2491 let mut array_values = Vec::new();
2492
2493 for part in parts {
2494 if let Some(items_schema) = &array_type.items {
2496 if let Some(items_schema_obj) = items_schema.as_item() {
2497 let part_value = Value::String(part.to_string());
2498 let coerced_part =
2499 coerce_by_style(&part_value, items_schema_obj, style);
2500 array_values.push(coerced_part);
2501 } else {
2502 array_values.push(Value::String(part.to_string()));
2504 }
2505 } else {
2506 array_values.push(Value::String(part.to_string()));
2508 }
2509 }
2510 return Value::Array(array_values);
2511 }
2512 }
2513
2514 if let Ok(n) = s.parse::<f64>() {
2516 if let Some(num) = serde_json::Number::from_f64(n) {
2517 return Value::Number(num);
2518 }
2519 }
2520 match s.to_lowercase().as_str() {
2522 "true" | "1" | "yes" | "on" => return Value::Bool(true),
2523 "false" | "0" | "no" | "off" => return Value::Bool(false),
2524 _ => {}
2525 }
2526 value.clone()
2528 }
2529 _ => value.clone(),
2530 }
2531}
2532
2533fn build_deep_object(name: &str, params: &Map<String, Value>) -> Option<Value> {
2535 let prefix = format!("{}[", name);
2536 let mut obj = Map::new();
2537 for (k, v) in params.iter() {
2538 if let Some(rest) = k.strip_prefix(&prefix) {
2539 if let Some(key) = rest.strip_suffix(']') {
2540 obj.insert(key.to_string(), v.clone());
2541 }
2542 }
2543 }
2544 if obj.is_empty() {
2545 None
2546 } else {
2547 Some(Value::Object(obj))
2548 }
2549}
2550
2551#[allow(clippy::too_many_arguments)]
2557fn generate_enhanced_422_response(
2558 validator: &OpenApiRouteRegistry,
2559 path_template: &str,
2560 method: &str,
2561 body: Option<&Value>,
2562 path_params: &Map<String, Value>,
2563 query_params: &Map<String, Value>,
2564 header_params: &Map<String, Value>,
2565 cookie_params: &Map<String, Value>,
2566) -> Value {
2567 let mut field_errors = Vec::new();
2568
2569 if let Some(route) = validator.get_route(path_template, method) {
2571 if let Some(schema) = &route.operation.request_body {
2573 if let Some(value) = body {
2574 if let Some(content) =
2575 schema.as_item().and_then(|rb| rb.content.get("application/json"))
2576 {
2577 if let Some(_schema_ref) = &content.schema {
2578 if serde_json::from_value::<Value>(value.clone()).is_err() {
2580 field_errors.push(json!({
2581 "path": "body",
2582 "message": "invalid JSON"
2583 }));
2584 }
2585 }
2586 }
2587 } else {
2588 field_errors.push(json!({
2589 "path": "body",
2590 "expected": "object",
2591 "found": "missing",
2592 "message": "Request body is required but not provided"
2593 }));
2594 }
2595 }
2596
2597 for param_ref in &route.operation.parameters {
2599 if let Some(param) = param_ref.as_item() {
2600 match param {
2601 openapiv3::Parameter::Path { parameter_data, .. } => {
2602 validate_parameter_detailed(
2603 parameter_data,
2604 path_params,
2605 "path",
2606 "path parameter",
2607 &mut field_errors,
2608 );
2609 }
2610 openapiv3::Parameter::Query { parameter_data, .. } => {
2611 let deep_value = if Some("form") == Some("deepObject") {
2612 build_deep_object(¶meter_data.name, query_params)
2613 } else {
2614 None
2615 };
2616 validate_parameter_detailed_with_deep(
2617 parameter_data,
2618 query_params,
2619 "query",
2620 "query parameter",
2621 deep_value,
2622 &mut field_errors,
2623 );
2624 }
2625 openapiv3::Parameter::Header { parameter_data, .. } => {
2626 validate_parameter_detailed(
2627 parameter_data,
2628 header_params,
2629 "header",
2630 "header parameter",
2631 &mut field_errors,
2632 );
2633 }
2634 openapiv3::Parameter::Cookie { parameter_data, .. } => {
2635 validate_parameter_detailed(
2636 parameter_data,
2637 cookie_params,
2638 "cookie",
2639 "cookie parameter",
2640 &mut field_errors,
2641 );
2642 }
2643 }
2644 }
2645 }
2646 }
2647
2648 json!({
2650 "error": "Schema validation failed",
2651 "details": field_errors,
2652 "method": method,
2653 "path": path_template,
2654 "timestamp": Utc::now().to_rfc3339(),
2655 "validation_type": "openapi_schema"
2656 })
2657}
2658
2659fn validate_parameter(
2661 parameter_data: &openapiv3::ParameterData,
2662 params_map: &Map<String, Value>,
2663 prefix: &str,
2664 aggregate: bool,
2665 errors: &mut Vec<String>,
2666 details: &mut Vec<Value>,
2667) {
2668 match params_map.get(¶meter_data.name) {
2669 Some(v) => {
2670 if let ParameterSchemaOrContent::Schema(s) = ¶meter_data.format {
2671 if let Some(schema) = s.as_item() {
2672 let coerced = coerce_value_for_schema(v, schema);
2673 if let Err(validation_error) =
2675 OpenApiSchema::new(schema.clone()).validate(&coerced)
2676 {
2677 let error_msg = validation_error.to_string();
2678 errors.push(format!(
2679 "{} parameter '{}' validation failed: {}",
2680 prefix, parameter_data.name, error_msg
2681 ));
2682 if aggregate {
2683 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2684 }
2685 }
2686 }
2687 }
2688 }
2689 None => {
2690 if parameter_data.required {
2691 errors.push(format!(
2692 "missing required {} parameter '{}'",
2693 prefix, parameter_data.name
2694 ));
2695 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2696 }
2697 }
2698 }
2699}
2700
2701#[allow(clippy::too_many_arguments)]
2703fn validate_parameter_with_deep_object(
2704 parameter_data: &openapiv3::ParameterData,
2705 params_map: &Map<String, Value>,
2706 prefix: &str,
2707 deep_value: Option<Value>,
2708 style: Option<&str>,
2709 aggregate: bool,
2710 errors: &mut Vec<String>,
2711 details: &mut Vec<Value>,
2712) {
2713 match deep_value.as_ref().or_else(|| params_map.get(¶meter_data.name)) {
2714 Some(v) => {
2715 if let ParameterSchemaOrContent::Schema(s) = ¶meter_data.format {
2716 if let Some(schema) = s.as_item() {
2717 let coerced = coerce_by_style(v, schema, style); if let Err(validation_error) =
2720 OpenApiSchema::new(schema.clone()).validate(&coerced)
2721 {
2722 let error_msg = validation_error.to_string();
2723 errors.push(format!(
2724 "{} parameter '{}' validation failed: {}",
2725 prefix, parameter_data.name, error_msg
2726 ));
2727 if aggregate {
2728 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
2729 }
2730 }
2731 }
2732 }
2733 }
2734 None => {
2735 if parameter_data.required {
2736 errors.push(format!(
2737 "missing required {} parameter '{}'",
2738 prefix, parameter_data.name
2739 ));
2740 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
2741 }
2742 }
2743 }
2744}
2745
2746fn validate_parameter_detailed(
2748 parameter_data: &openapiv3::ParameterData,
2749 params_map: &Map<String, Value>,
2750 location: &str,
2751 value_type: &str,
2752 field_errors: &mut Vec<Value>,
2753) {
2754 match params_map.get(¶meter_data.name) {
2755 Some(value) => {
2756 if let ParameterSchemaOrContent::Schema(schema) = ¶meter_data.format {
2757 let details: Vec<Value> = Vec::new();
2759 let param_path = format!("{}.{}", location, parameter_data.name);
2760
2761 if let Some(schema_ref) = schema.as_item() {
2763 let coerced_value = coerce_value_for_schema(value, schema_ref);
2764 if let Err(validation_error) =
2766 OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2767 {
2768 field_errors.push(json!({
2769 "path": param_path,
2770 "expected": "valid according to schema",
2771 "found": coerced_value,
2772 "message": validation_error.to_string()
2773 }));
2774 }
2775 }
2776
2777 for detail in details {
2778 field_errors.push(json!({
2779 "path": detail["path"],
2780 "expected": detail["expected_type"],
2781 "found": detail["value"],
2782 "message": detail["message"]
2783 }));
2784 }
2785 }
2786 }
2787 None => {
2788 if parameter_data.required {
2789 field_errors.push(json!({
2790 "path": format!("{}.{}", location, parameter_data.name),
2791 "expected": "value",
2792 "found": "missing",
2793 "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
2794 }));
2795 }
2796 }
2797 }
2798}
2799
2800fn validate_parameter_detailed_with_deep(
2802 parameter_data: &openapiv3::ParameterData,
2803 params_map: &Map<String, Value>,
2804 location: &str,
2805 value_type: &str,
2806 deep_value: Option<Value>,
2807 field_errors: &mut Vec<Value>,
2808) {
2809 match deep_value.as_ref().or_else(|| params_map.get(¶meter_data.name)) {
2810 Some(value) => {
2811 if let ParameterSchemaOrContent::Schema(schema) = ¶meter_data.format {
2812 let details: Vec<Value> = Vec::new();
2814 let param_path = format!("{}.{}", location, parameter_data.name);
2815
2816 if let Some(schema_ref) = schema.as_item() {
2818 let coerced_value = coerce_by_style(value, schema_ref, Some("form")); if let Err(validation_error) =
2821 OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2822 {
2823 field_errors.push(json!({
2824 "path": param_path,
2825 "expected": "valid according to schema",
2826 "found": coerced_value,
2827 "message": validation_error.to_string()
2828 }));
2829 }
2830 }
2831
2832 for detail in details {
2833 field_errors.push(json!({
2834 "path": detail["path"],
2835 "expected": detail["expected_type"],
2836 "found": detail["value"],
2837 "message": detail["message"]
2838 }));
2839 }
2840 }
2841 }
2842 None => {
2843 if parameter_data.required {
2844 field_errors.push(json!({
2845 "path": format!("{}.{}", location, parameter_data.name),
2846 "expected": "value",
2847 "found": "missing",
2848 "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
2849 }));
2850 }
2851 }
2852 }
2853}
2854
2855pub async fn create_registry_from_file<P: AsRef<std::path::Path>>(
2857 path: P,
2858) -> Result<OpenApiRouteRegistry> {
2859 let spec = OpenApiSpec::from_file(path).await?;
2860 spec.validate()?;
2861 Ok(OpenApiRouteRegistry::new(spec))
2862}
2863
2864pub fn create_registry_from_json(json: Value) -> Result<OpenApiRouteRegistry> {
2866 let spec = OpenApiSpec::from_json(json)?;
2867 spec.validate()?;
2868 Ok(OpenApiRouteRegistry::new(spec))
2869}
2870
2871#[cfg(test)]
2872mod tests {
2873 use super::*;
2874 use serde_json::json;
2875 use tempfile::TempDir;
2876
2877 #[tokio::test]
2878 async fn test_registry_creation() {
2879 let spec_json = json!({
2880 "openapi": "3.0.0",
2881 "info": {
2882 "title": "Test API",
2883 "version": "1.0.0"
2884 },
2885 "paths": {
2886 "/users": {
2887 "get": {
2888 "summary": "Get users",
2889 "responses": {
2890 "200": {
2891 "description": "Success",
2892 "content": {
2893 "application/json": {
2894 "schema": {
2895 "type": "array",
2896 "items": {
2897 "type": "object",
2898 "properties": {
2899 "id": {"type": "integer"},
2900 "name": {"type": "string"}
2901 }
2902 }
2903 }
2904 }
2905 }
2906 }
2907 }
2908 },
2909 "post": {
2910 "summary": "Create user",
2911 "requestBody": {
2912 "content": {
2913 "application/json": {
2914 "schema": {
2915 "type": "object",
2916 "properties": {
2917 "name": {"type": "string"}
2918 },
2919 "required": ["name"]
2920 }
2921 }
2922 }
2923 },
2924 "responses": {
2925 "201": {
2926 "description": "Created",
2927 "content": {
2928 "application/json": {
2929 "schema": {
2930 "type": "object",
2931 "properties": {
2932 "id": {"type": "integer"},
2933 "name": {"type": "string"}
2934 }
2935 }
2936 }
2937 }
2938 }
2939 }
2940 }
2941 },
2942 "/users/{id}": {
2943 "get": {
2944 "summary": "Get user by ID",
2945 "parameters": [
2946 {
2947 "name": "id",
2948 "in": "path",
2949 "required": true,
2950 "schema": {"type": "integer"}
2951 }
2952 ],
2953 "responses": {
2954 "200": {
2955 "description": "Success",
2956 "content": {
2957 "application/json": {
2958 "schema": {
2959 "type": "object",
2960 "properties": {
2961 "id": {"type": "integer"},
2962 "name": {"type": "string"}
2963 }
2964 }
2965 }
2966 }
2967 }
2968 }
2969 }
2970 }
2971 }
2972 });
2973
2974 let registry = create_registry_from_json(spec_json).unwrap();
2975
2976 assert_eq!(registry.paths().len(), 2);
2978 assert!(registry.paths().contains(&"/users".to_string()));
2979 assert!(registry.paths().contains(&"/users/{id}".to_string()));
2980
2981 assert_eq!(registry.methods().len(), 2);
2982 assert!(registry.methods().contains(&"GET".to_string()));
2983 assert!(registry.methods().contains(&"POST".to_string()));
2984
2985 let get_users_route = registry.get_route("/users", "GET").unwrap();
2987 assert_eq!(get_users_route.method, "GET");
2988 assert_eq!(get_users_route.path, "/users");
2989
2990 let post_users_route = registry.get_route("/users", "POST").unwrap();
2991 assert_eq!(post_users_route.method, "POST");
2992 assert!(post_users_route.operation.request_body.is_some());
2993
2994 let user_by_id_route = registry.get_route("/users/{id}", "GET").unwrap();
2996 assert_eq!(user_by_id_route.axum_path(), "/users/{id}");
2997 }
2998
2999 #[tokio::test]
3005 async fn check_request_content_type_flags_mismatch() {
3006 let spec_json = json!({
3007 "openapi": "3.0.0",
3008 "info": { "title": "T", "version": "1" },
3009 "paths": {
3010 "/api/appliance/access/consolecli": {
3011 "put": {
3012 "requestBody": {
3013 "required": true,
3014 "content": {
3015 "application/json": {
3016 "schema": {
3017 "type": "object",
3018 "required": ["enabled"],
3019 "properties": {"enabled": {"type": "boolean"}}
3020 }
3021 }
3022 }
3023 },
3024 "responses": { "204": { "description": "ok" } }
3025 }
3026 }
3027 }
3028 });
3029 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3030 let registry = OpenApiRouteRegistry::new(spec);
3031
3032 let r = registry.check_request_content_type(
3034 "/api/appliance/access/consolecli",
3035 "PUT",
3036 Some("application/xml"),
3037 );
3038 assert!(r.is_err(), "should flag application/xml: {:?}", r);
3039 let msg = r.unwrap_err();
3040 assert!(msg.contains("application/xml"), "{msg}");
3041 assert!(msg.contains("application/json"), "{msg}");
3042
3043 let r = registry.check_request_content_type(
3045 "/api/appliance/access/consolecli",
3046 "PUT",
3047 Some("application/json"),
3048 );
3049 assert!(r.is_ok(), "should accept application/json: {:?}", r);
3050
3051 let r = registry.check_request_content_type(
3053 "/api/appliance/access/consolecli",
3054 "PUT",
3055 Some("application/json; charset=utf-8"),
3056 );
3057 assert!(r.is_ok(), "should strip charset: {:?}", r);
3058
3059 let r = registry.check_request_content_type(
3061 "/api/appliance/access/consolecli",
3062 "GET",
3063 Some("application/xml"),
3064 );
3065 assert!(r.is_ok(), "GET has no requestBody on this op: {:?}", r);
3066
3067 let r =
3069 registry.check_request_content_type("/api/appliance/access/consolecli", "PUT", None);
3070 assert!(r.is_ok(), "no Content-Type → don't double-report: {:?}", r);
3071 }
3072
3073 #[tokio::test]
3074 async fn test_validate_request_with_params_and_formats() {
3075 let spec_json = json!({
3076 "openapi": "3.0.0",
3077 "info": { "title": "Test API", "version": "1.0.0" },
3078 "paths": {
3079 "/users/{id}": {
3080 "post": {
3081 "parameters": [
3082 { "name": "id", "in": "path", "required": true, "schema": {"type": "string"} },
3083 { "name": "q", "in": "query", "required": false, "schema": {"type": "integer"} }
3084 ],
3085 "requestBody": {
3086 "content": {
3087 "application/json": {
3088 "schema": {
3089 "type": "object",
3090 "required": ["email", "website"],
3091 "properties": {
3092 "email": {"type": "string", "format": "email"},
3093 "website": {"type": "string", "format": "uri"}
3094 }
3095 }
3096 }
3097 }
3098 },
3099 "responses": {"200": {"description": "ok"}}
3100 }
3101 }
3102 }
3103 });
3104
3105 let registry = create_registry_from_json(spec_json).unwrap();
3106 let mut path_params = Map::new();
3107 path_params.insert("id".to_string(), json!("abc"));
3108 let mut query_params = Map::new();
3109 query_params.insert("q".to_string(), json!(123));
3110
3111 let body = json!({"email":"a@b.co","website":"https://example.com"});
3113 assert!(registry
3114 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3115 .is_ok());
3116
3117 let bad_email = json!({"email":"not-an-email","website":"https://example.com"});
3119 assert!(registry
3120 .validate_request_with(
3121 "/users/{id}",
3122 "POST",
3123 &path_params,
3124 &query_params,
3125 Some(&bad_email)
3126 )
3127 .is_err());
3128
3129 let empty_path_params = Map::new();
3131 assert!(registry
3132 .validate_request_with(
3133 "/users/{id}",
3134 "POST",
3135 &empty_path_params,
3136 &query_params,
3137 Some(&body)
3138 )
3139 .is_err());
3140 }
3141
3142 #[tokio::test]
3143 async fn test_ref_resolution_for_params_and_body() {
3144 let spec_json = json!({
3145 "openapi": "3.0.0",
3146 "info": { "title": "Ref API", "version": "1.0.0" },
3147 "components": {
3148 "schemas": {
3149 "EmailWebsite": {
3150 "type": "object",
3151 "required": ["email", "website"],
3152 "properties": {
3153 "email": {"type": "string", "format": "email"},
3154 "website": {"type": "string", "format": "uri"}
3155 }
3156 }
3157 },
3158 "parameters": {
3159 "PathId": {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}},
3160 "QueryQ": {"name": "q", "in": "query", "required": false, "schema": {"type": "integer"}}
3161 },
3162 "requestBodies": {
3163 "CreateUser": {
3164 "content": {
3165 "application/json": {
3166 "schema": {"$ref": "#/components/schemas/EmailWebsite"}
3167 }
3168 }
3169 }
3170 }
3171 },
3172 "paths": {
3173 "/users/{id}": {
3174 "post": {
3175 "parameters": [
3176 {"$ref": "#/components/parameters/PathId"},
3177 {"$ref": "#/components/parameters/QueryQ"}
3178 ],
3179 "requestBody": {"$ref": "#/components/requestBodies/CreateUser"},
3180 "responses": {"200": {"description": "ok"}}
3181 }
3182 }
3183 }
3184 });
3185
3186 let registry = create_registry_from_json(spec_json).unwrap();
3187 let mut path_params = Map::new();
3188 path_params.insert("id".to_string(), json!("abc"));
3189 let mut query_params = Map::new();
3190 query_params.insert("q".to_string(), json!(7));
3191
3192 let body = json!({"email":"user@example.com","website":"https://example.com"});
3193 assert!(registry
3194 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3195 .is_ok());
3196
3197 let bad = json!({"email":"nope","website":"https://example.com"});
3198 assert!(registry
3199 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&bad))
3200 .is_err());
3201 }
3202
3203 #[tokio::test]
3204 async fn test_header_cookie_and_query_coercion() {
3205 let spec_json = json!({
3206 "openapi": "3.0.0",
3207 "info": { "title": "Params API", "version": "1.0.0" },
3208 "paths": {
3209 "/items": {
3210 "get": {
3211 "parameters": [
3212 {"name": "X-Flag", "in": "header", "required": true, "schema": {"type": "boolean"}},
3213 {"name": "session", "in": "cookie", "required": true, "schema": {"type": "string"}},
3214 {"name": "ids", "in": "query", "required": false, "schema": {"type": "array", "items": {"type": "integer"}}}
3215 ],
3216 "responses": {"200": {"description": "ok"}}
3217 }
3218 }
3219 }
3220 });
3221
3222 let registry = create_registry_from_json(spec_json).unwrap();
3223
3224 let path_params = Map::new();
3225 let mut query_params = Map::new();
3226 query_params.insert("ids".to_string(), json!("1,2,3"));
3228 let mut header_params = Map::new();
3229 header_params.insert("X-Flag".to_string(), json!("true"));
3230 let mut cookie_params = Map::new();
3231 cookie_params.insert("session".to_string(), json!("abc123"));
3232
3233 assert!(registry
3234 .validate_request_with_all(
3235 "/items",
3236 "GET",
3237 &path_params,
3238 &query_params,
3239 &header_params,
3240 &cookie_params,
3241 None
3242 )
3243 .is_ok());
3244
3245 let empty_cookie = Map::new();
3247 assert!(registry
3248 .validate_request_with_all(
3249 "/items",
3250 "GET",
3251 &path_params,
3252 &query_params,
3253 &header_params,
3254 &empty_cookie,
3255 None
3256 )
3257 .is_err());
3258
3259 let mut bad_header = Map::new();
3261 bad_header.insert("X-Flag".to_string(), json!("notabool"));
3262 assert!(registry
3263 .validate_request_with_all(
3264 "/items",
3265 "GET",
3266 &path_params,
3267 &query_params,
3268 &bad_header,
3269 &cookie_params,
3270 None
3271 )
3272 .is_err());
3273 }
3274
3275 #[tokio::test]
3276 async fn test_query_styles_space_pipe_deepobject() {
3277 let spec_json = json!({
3278 "openapi": "3.0.0",
3279 "info": { "title": "Query Styles API", "version": "1.0.0" },
3280 "paths": {"/search": {"get": {
3281 "parameters": [
3282 {"name":"tags","in":"query","style":"spaceDelimited","schema":{"type":"array","items":{"type":"string"}}},
3283 {"name":"ids","in":"query","style":"pipeDelimited","schema":{"type":"array","items":{"type":"integer"}}},
3284 {"name":"filter","in":"query","style":"deepObject","schema":{"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}}
3285 ],
3286 "responses": {"200": {"description":"ok"}}
3287 }} }
3288 });
3289
3290 let registry = create_registry_from_json(spec_json).unwrap();
3291
3292 let path_params = Map::new();
3293 let mut query = Map::new();
3294 query.insert("tags".into(), json!("alpha beta gamma"));
3295 query.insert("ids".into(), json!("1|2|3"));
3296 query.insert("filter[color]".into(), json!("red"));
3297
3298 assert!(registry
3299 .validate_request_with("/search", "GET", &path_params, &query, None)
3300 .is_ok());
3301 }
3302
3303 #[tokio::test]
3304 async fn test_oneof_anyof_allof_validation() {
3305 let spec_json = json!({
3306 "openapi": "3.0.0",
3307 "info": { "title": "Composite API", "version": "1.0.0" },
3308 "paths": {
3309 "/composite": {
3310 "post": {
3311 "requestBody": {
3312 "content": {
3313 "application/json": {
3314 "schema": {
3315 "allOf": [
3316 {"type": "object", "required": ["base"], "properties": {"base": {"type": "string"}}}
3317 ],
3318 "oneOf": [
3319 {"type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"], "not": {"required": ["b"]}},
3320 {"type": "object", "properties": {"b": {"type": "integer"}}, "required": ["b"], "not": {"required": ["a"]}}
3321 ],
3322 "anyOf": [
3323 {"type": "object", "properties": {"flag": {"type": "boolean"}}, "required": ["flag"]},
3324 {"type": "object", "properties": {"extra": {"type": "string"}}, "required": ["extra"]}
3325 ]
3326 }
3327 }
3328 }
3329 },
3330 "responses": {"200": {"description": "ok"}}
3331 }
3332 }
3333 }
3334 });
3335
3336 let registry = create_registry_from_json(spec_json).unwrap();
3337 let ok = json!({"base": "x", "a": 1, "flag": true});
3339 assert!(registry
3340 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&ok))
3341 .is_ok());
3342
3343 let bad_oneof = json!({"base": "x", "a": 1, "b": 2, "flag": false});
3345 assert!(registry
3346 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_oneof))
3347 .is_err());
3348
3349 let bad_anyof = json!({"base": "x", "a": 1});
3351 assert!(registry
3352 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_anyof))
3353 .is_err());
3354
3355 let bad_allof = json!({"a": 1, "flag": true});
3357 assert!(registry
3358 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_allof))
3359 .is_err());
3360 }
3361
3362 #[tokio::test]
3371 async fn dotted_schema_ref_resolves_in_route_validator() {
3372 let spec_json = json!({
3373 "openapi": "3.0.0",
3374 "info": { "title": "Dotted", "version": "1.0.0" },
3375 "paths": {
3376 "/x": {
3377 "post": {
3378 "requestBody": {
3379 "required": true,
3380 "content": {
3381 "application/json": {
3382 "schema": {
3383 "$ref": "#/components/schemas/Esx.Settings.Inventory.EntitySpec"
3384 }
3385 }
3386 }
3387 },
3388 "responses": {"200": {"description": "ok"}}
3389 }
3390 }
3391 },
3392 "components": {
3393 "schemas": {
3394 "Esx.Settings.Inventory.EntitySpec": {
3395 "type": "object",
3396 "required": ["type"],
3397 "properties": {"type": {"type": "string"}}
3398 }
3399 }
3400 }
3401 });
3402 let registry = create_registry_from_json(spec_json).unwrap();
3403 let good = json!({"type": "HOST"});
3406 let res =
3407 registry.validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&good));
3408 assert!(res.is_ok(), "valid body should pass; got {res:?}");
3409 let bad = json!({"unrelated": 1});
3411 let err = registry
3412 .validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&bad))
3413 .unwrap_err();
3414 let msg = format!("{err}");
3415 assert!(
3416 !msg.contains("Pointer") || !msg.contains("does not exist"),
3417 "should not be a pointer-resolution failure; got: {msg}"
3418 );
3419 }
3420
3421 #[tokio::test]
3422 async fn test_overrides_warn_mode_allows_invalid() {
3423 let spec_json = json!({
3425 "openapi": "3.0.0",
3426 "info": { "title": "Overrides API", "version": "1.0.0" },
3427 "paths": {"/things": {"post": {
3428 "parameters": [{"name":"q","in":"query","required":true,"schema":{"type":"integer"}}],
3429 "responses": {"200": {"description":"ok"}}
3430 }}}
3431 });
3432
3433 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3434 let mut overrides = HashMap::new();
3435 overrides.insert("POST /things".to_string(), ValidationMode::Warn);
3436 let registry = OpenApiRouteRegistry::new_with_options(
3437 spec,
3438 ValidationOptions {
3439 request_mode: ValidationMode::Enforce,
3440 aggregate_errors: true,
3441 validate_responses: false,
3442 overrides,
3443 admin_skip_prefixes: vec![],
3444 response_template_expand: false,
3445 validation_status: None,
3446 },
3447 );
3448
3449 let ok = registry.validate_request_with("/things", "POST", &Map::new(), &Map::new(), None);
3451 assert!(ok.is_ok());
3452 }
3453
3454 #[tokio::test]
3455 async fn test_admin_skip_prefix_short_circuit() {
3456 let spec_json = json!({
3457 "openapi": "3.0.0",
3458 "info": { "title": "Skip API", "version": "1.0.0" },
3459 "paths": {}
3460 });
3461 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3462 let registry = OpenApiRouteRegistry::new_with_options(
3463 spec,
3464 ValidationOptions {
3465 request_mode: ValidationMode::Enforce,
3466 aggregate_errors: true,
3467 validate_responses: false,
3468 overrides: HashMap::new(),
3469 admin_skip_prefixes: vec!["/admin".into()],
3470 response_template_expand: false,
3471 validation_status: None,
3472 },
3473 );
3474
3475 let res = registry.validate_request_with_all(
3477 "/admin/__mockforge/health",
3478 "GET",
3479 &Map::new(),
3480 &Map::new(),
3481 &Map::new(),
3482 &Map::new(),
3483 None,
3484 );
3485 assert!(res.is_ok());
3486 }
3487
3488 #[test]
3489 fn test_path_conversion() {
3490 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users"), "/users");
3491 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users/{id}"), "/users/{id}");
3492 assert_eq!(
3493 OpenApiRouteRegistry::convert_path_to_axum("/users/{id}/posts/{postId}"),
3494 "/users/{id}/posts/{postId}"
3495 );
3496 }
3497
3498 #[test]
3499 fn test_validation_options_default() {
3500 let options = ValidationOptions::default();
3501 assert!(matches!(options.request_mode, ValidationMode::Enforce));
3502 assert!(options.aggregate_errors);
3503 assert!(!options.validate_responses);
3504 assert!(options.overrides.is_empty());
3505 assert!(options.admin_skip_prefixes.is_empty());
3506 assert!(!options.response_template_expand);
3507 assert!(options.validation_status.is_none());
3508 }
3509
3510 #[test]
3511 fn test_validation_mode_variants() {
3512 let disabled = ValidationMode::Disabled;
3514 let warn = ValidationMode::Warn;
3515 let enforce = ValidationMode::Enforce;
3516 let default = ValidationMode::default();
3517
3518 assert!(matches!(default, ValidationMode::Warn));
3520
3521 assert!(!matches!(disabled, ValidationMode::Warn));
3523 assert!(!matches!(warn, ValidationMode::Enforce));
3524 assert!(!matches!(enforce, ValidationMode::Disabled));
3525 }
3526
3527 #[test]
3528 fn test_registry_spec_accessor() {
3529 let spec_json = json!({
3530 "openapi": "3.0.0",
3531 "info": {
3532 "title": "Test API",
3533 "version": "1.0.0"
3534 },
3535 "paths": {}
3536 });
3537 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3538 let registry = OpenApiRouteRegistry::new(spec.clone());
3539
3540 let accessed_spec = registry.spec();
3542 assert_eq!(accessed_spec.title(), "Test API");
3543 }
3544
3545 #[test]
3546 fn test_clone_for_validation() {
3547 let spec_json = json!({
3548 "openapi": "3.0.0",
3549 "info": {
3550 "title": "Test API",
3551 "version": "1.0.0"
3552 },
3553 "paths": {
3554 "/users": {
3555 "get": {
3556 "responses": {
3557 "200": {
3558 "description": "Success"
3559 }
3560 }
3561 }
3562 }
3563 }
3564 });
3565 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3566 let registry = OpenApiRouteRegistry::new(spec);
3567
3568 let cloned = registry.clone_for_validation();
3570 assert_eq!(cloned.routes().len(), registry.routes().len());
3571 assert_eq!(cloned.spec().title(), registry.spec().title());
3572 }
3573
3574 #[test]
3575 fn test_with_custom_fixture_loader() {
3576 let temp_dir = TempDir::new().unwrap();
3577 let spec_json = json!({
3578 "openapi": "3.0.0",
3579 "info": {
3580 "title": "Test API",
3581 "version": "1.0.0"
3582 },
3583 "paths": {}
3584 });
3585 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3586 let registry = OpenApiRouteRegistry::new(spec);
3587 let original_routes_len = registry.routes().len();
3588
3589 let custom_loader = Arc::new(crate::custom_fixture::CustomFixtureLoader::new(
3591 temp_dir.path().to_path_buf(),
3592 true,
3593 ));
3594 let registry_with_loader = registry.with_custom_fixture_loader(custom_loader);
3595
3596 assert_eq!(registry_with_loader.routes().len(), original_routes_len);
3598 }
3599
3600 #[test]
3601 fn test_get_route() {
3602 let spec_json = json!({
3603 "openapi": "3.0.0",
3604 "info": {
3605 "title": "Test API",
3606 "version": "1.0.0"
3607 },
3608 "paths": {
3609 "/users": {
3610 "get": {
3611 "operationId": "getUsers",
3612 "responses": {
3613 "200": {
3614 "description": "Success"
3615 }
3616 }
3617 },
3618 "post": {
3619 "operationId": "createUser",
3620 "responses": {
3621 "201": {
3622 "description": "Created"
3623 }
3624 }
3625 }
3626 }
3627 }
3628 });
3629 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3630 let registry = OpenApiRouteRegistry::new(spec);
3631
3632 let route = registry.get_route("/users", "GET");
3634 assert!(route.is_some());
3635 assert_eq!(route.unwrap().method, "GET");
3636 assert_eq!(route.unwrap().path, "/users");
3637
3638 let route = registry.get_route("/nonexistent", "GET");
3640 assert!(route.is_none());
3641
3642 let route = registry.get_route("/users", "POST");
3644 assert!(route.is_some());
3645 assert_eq!(route.unwrap().method, "POST");
3646 }
3647
3648 #[test]
3649 fn test_get_routes_for_path() {
3650 let spec_json = json!({
3651 "openapi": "3.0.0",
3652 "info": {
3653 "title": "Test API",
3654 "version": "1.0.0"
3655 },
3656 "paths": {
3657 "/users": {
3658 "get": {
3659 "responses": {
3660 "200": {
3661 "description": "Success"
3662 }
3663 }
3664 },
3665 "post": {
3666 "responses": {
3667 "201": {
3668 "description": "Created"
3669 }
3670 }
3671 },
3672 "put": {
3673 "responses": {
3674 "200": {
3675 "description": "Success"
3676 }
3677 }
3678 }
3679 },
3680 "/posts": {
3681 "get": {
3682 "responses": {
3683 "200": {
3684 "description": "Success"
3685 }
3686 }
3687 }
3688 }
3689 }
3690 });
3691 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3692 let registry = OpenApiRouteRegistry::new(spec);
3693
3694 let routes = registry.get_routes_for_path("/users");
3696 assert_eq!(routes.len(), 3);
3697 let methods: Vec<&str> = routes.iter().map(|r| r.method.as_str()).collect();
3698 assert!(methods.contains(&"GET"));
3699 assert!(methods.contains(&"POST"));
3700 assert!(methods.contains(&"PUT"));
3701
3702 let routes = registry.get_routes_for_path("/posts");
3704 assert_eq!(routes.len(), 1);
3705 assert_eq!(routes[0].method, "GET");
3706
3707 let routes = registry.get_routes_for_path("/nonexistent");
3709 assert!(routes.is_empty());
3710 }
3711
3712 #[test]
3713 fn test_new_vs_new_with_options() {
3714 let spec_json = json!({
3715 "openapi": "3.0.0",
3716 "info": {
3717 "title": "Test API",
3718 "version": "1.0.0"
3719 },
3720 "paths": {}
3721 });
3722 let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3723 let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3724
3725 let registry1 = OpenApiRouteRegistry::new(spec1);
3727 assert_eq!(registry1.spec().title(), "Test API");
3728
3729 let options = ValidationOptions {
3731 request_mode: ValidationMode::Disabled,
3732 aggregate_errors: false,
3733 validate_responses: true,
3734 overrides: HashMap::new(),
3735 admin_skip_prefixes: vec!["/admin".to_string()],
3736 response_template_expand: true,
3737 validation_status: Some(422),
3738 };
3739 let registry2 = OpenApiRouteRegistry::new_with_options(spec2, options);
3740 assert_eq!(registry2.spec().title(), "Test API");
3741 }
3742
3743 #[test]
3744 fn test_new_with_env_vs_new() {
3745 let spec_json = json!({
3746 "openapi": "3.0.0",
3747 "info": {
3748 "title": "Test API",
3749 "version": "1.0.0"
3750 },
3751 "paths": {}
3752 });
3753 let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
3754 let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
3755
3756 let registry1 = OpenApiRouteRegistry::new(spec1);
3758
3759 let registry2 = OpenApiRouteRegistry::new_with_env(spec2);
3761
3762 assert_eq!(registry1.spec().title(), "Test API");
3764 assert_eq!(registry2.spec().title(), "Test API");
3765 }
3766
3767 #[test]
3768 fn test_validation_options_custom() {
3769 let options = ValidationOptions {
3770 request_mode: ValidationMode::Warn,
3771 aggregate_errors: false,
3772 validate_responses: true,
3773 overrides: {
3774 let mut map = HashMap::new();
3775 map.insert("getUsers".to_string(), ValidationMode::Disabled);
3776 map
3777 },
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::Warn));
3784 assert!(!options.aggregate_errors);
3785 assert!(options.validate_responses);
3786 assert_eq!(options.overrides.len(), 1);
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_validation_mode_default_standalone() {
3794 let mode = ValidationMode::default();
3795 assert!(matches!(mode, ValidationMode::Warn));
3796 }
3797
3798 #[test]
3799 fn test_validation_mode_clone() {
3800 let mode1 = ValidationMode::Enforce;
3801 let mode2 = mode1.clone();
3802 assert!(matches!(mode1, ValidationMode::Enforce));
3803 assert!(matches!(mode2, ValidationMode::Enforce));
3804 }
3805
3806 #[test]
3807 fn test_validation_mode_debug() {
3808 let mode = ValidationMode::Disabled;
3809 let debug_str = format!("{:?}", mode);
3810 assert!(debug_str.contains("Disabled") || debug_str.contains("ValidationMode"));
3811 }
3812
3813 #[test]
3814 fn test_validation_options_clone() {
3815 let options1 = ValidationOptions {
3816 request_mode: ValidationMode::Warn,
3817 aggregate_errors: true,
3818 validate_responses: false,
3819 overrides: HashMap::new(),
3820 admin_skip_prefixes: vec![],
3821 response_template_expand: false,
3822 validation_status: None,
3823 };
3824 let options2 = options1.clone();
3825 assert!(matches!(options2.request_mode, ValidationMode::Warn));
3826 assert_eq!(options1.aggregate_errors, options2.aggregate_errors);
3827 }
3828
3829 #[test]
3830 fn test_validation_options_debug() {
3831 let options = ValidationOptions::default();
3832 let debug_str = format!("{:?}", options);
3833 assert!(debug_str.contains("ValidationOptions"));
3834 }
3835
3836 #[test]
3837 fn test_validation_options_with_all_fields() {
3838 let mut overrides = HashMap::new();
3839 overrides.insert("op1".to_string(), ValidationMode::Disabled);
3840 overrides.insert("op2".to_string(), ValidationMode::Warn);
3841
3842 let options = ValidationOptions {
3843 request_mode: ValidationMode::Enforce,
3844 aggregate_errors: false,
3845 validate_responses: true,
3846 overrides: overrides.clone(),
3847 admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
3848 response_template_expand: true,
3849 validation_status: Some(422),
3850 };
3851
3852 assert!(matches!(options.request_mode, ValidationMode::Enforce));
3853 assert!(!options.aggregate_errors);
3854 assert!(options.validate_responses);
3855 assert_eq!(options.overrides.len(), 2);
3856 assert_eq!(options.admin_skip_prefixes.len(), 2);
3857 assert!(options.response_template_expand);
3858 assert_eq!(options.validation_status, Some(422));
3859 }
3860
3861 #[test]
3862 fn test_openapi_route_registry_clone() {
3863 let spec_json = json!({
3864 "openapi": "3.0.0",
3865 "info": { "title": "Test API", "version": "1.0.0" },
3866 "paths": {}
3867 });
3868 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3869 let registry1 = OpenApiRouteRegistry::new(spec);
3870 let registry2 = registry1.clone();
3871 assert_eq!(registry1.spec().title(), registry2.spec().title());
3872 }
3873
3874 #[test]
3875 fn test_validation_mode_serialization() {
3876 let mode = ValidationMode::Enforce;
3877 let json = serde_json::to_string(&mode).unwrap();
3878 assert!(json.contains("Enforce") || json.contains("enforce"));
3879 }
3880
3881 #[test]
3882 fn test_validation_mode_deserialization() {
3883 let json = r#""Disabled""#;
3884 let mode: ValidationMode = serde_json::from_str(json).unwrap();
3885 assert!(matches!(mode, ValidationMode::Disabled));
3886 }
3887
3888 #[test]
3889 fn test_validation_options_default_values() {
3890 let options = ValidationOptions::default();
3891 assert!(matches!(options.request_mode, ValidationMode::Enforce));
3892 assert!(options.aggregate_errors);
3893 assert!(!options.validate_responses);
3894 assert!(options.overrides.is_empty());
3895 assert!(options.admin_skip_prefixes.is_empty());
3896 assert!(!options.response_template_expand);
3897 assert_eq!(options.validation_status, None);
3898 }
3899
3900 #[test]
3901 fn test_validation_mode_all_variants() {
3902 let disabled = ValidationMode::Disabled;
3903 let warn = ValidationMode::Warn;
3904 let enforce = ValidationMode::Enforce;
3905
3906 assert!(matches!(disabled, ValidationMode::Disabled));
3907 assert!(matches!(warn, ValidationMode::Warn));
3908 assert!(matches!(enforce, ValidationMode::Enforce));
3909 }
3910
3911 #[test]
3912 fn test_validation_options_with_overrides() {
3913 let mut overrides = HashMap::new();
3914 overrides.insert("operation1".to_string(), ValidationMode::Disabled);
3915 overrides.insert("operation2".to_string(), ValidationMode::Warn);
3916
3917 let options = ValidationOptions {
3918 request_mode: ValidationMode::Enforce,
3919 aggregate_errors: true,
3920 validate_responses: false,
3921 overrides,
3922 admin_skip_prefixes: vec![],
3923 response_template_expand: false,
3924 validation_status: None,
3925 };
3926
3927 assert_eq!(options.overrides.len(), 2);
3928 assert!(matches!(options.overrides.get("operation1"), Some(ValidationMode::Disabled)));
3929 assert!(matches!(options.overrides.get("operation2"), Some(ValidationMode::Warn)));
3930 }
3931
3932 #[test]
3933 fn test_validation_options_with_admin_skip_prefixes() {
3934 let options = ValidationOptions {
3935 request_mode: ValidationMode::Enforce,
3936 aggregate_errors: true,
3937 validate_responses: false,
3938 overrides: HashMap::new(),
3939 admin_skip_prefixes: vec![
3940 "/admin".to_string(),
3941 "/internal".to_string(),
3942 "/debug".to_string(),
3943 ],
3944 response_template_expand: false,
3945 validation_status: None,
3946 };
3947
3948 assert_eq!(options.admin_skip_prefixes.len(), 3);
3949 assert!(options.admin_skip_prefixes.contains(&"/admin".to_string()));
3950 assert!(options.admin_skip_prefixes.contains(&"/internal".to_string()));
3951 assert!(options.admin_skip_prefixes.contains(&"/debug".to_string()));
3952 }
3953
3954 #[test]
3955 fn test_validation_options_with_validation_status() {
3956 let options1 = ValidationOptions {
3957 request_mode: ValidationMode::Enforce,
3958 aggregate_errors: true,
3959 validate_responses: false,
3960 overrides: HashMap::new(),
3961 admin_skip_prefixes: vec![],
3962 response_template_expand: false,
3963 validation_status: Some(400),
3964 };
3965
3966 let options2 = ValidationOptions {
3967 request_mode: ValidationMode::Enforce,
3968 aggregate_errors: true,
3969 validate_responses: false,
3970 overrides: HashMap::new(),
3971 admin_skip_prefixes: vec![],
3972 response_template_expand: false,
3973 validation_status: Some(422),
3974 };
3975
3976 assert_eq!(options1.validation_status, Some(400));
3977 assert_eq!(options2.validation_status, Some(422));
3978 }
3979
3980 #[test]
3981 fn test_validate_request_with_disabled_mode() {
3982 let spec_json = json!({
3984 "openapi": "3.0.0",
3985 "info": {"title": "Test API", "version": "1.0.0"},
3986 "paths": {
3987 "/users": {
3988 "get": {
3989 "responses": {"200": {"description": "OK"}}
3990 }
3991 }
3992 }
3993 });
3994 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3995 let options = ValidationOptions {
3996 request_mode: ValidationMode::Disabled,
3997 ..Default::default()
3998 };
3999 let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4000
4001 let result = registry.validate_request_with_all(
4003 "/users",
4004 "GET",
4005 &Map::new(),
4006 &Map::new(),
4007 &Map::new(),
4008 &Map::new(),
4009 None,
4010 );
4011 assert!(result.is_ok());
4012 }
4013
4014 #[test]
4015 fn test_validate_request_with_warn_mode() {
4016 let spec_json = json!({
4018 "openapi": "3.0.0",
4019 "info": {"title": "Test API", "version": "1.0.0"},
4020 "paths": {
4021 "/users": {
4022 "post": {
4023 "requestBody": {
4024 "required": true,
4025 "content": {
4026 "application/json": {
4027 "schema": {
4028 "type": "object",
4029 "required": ["name"],
4030 "properties": {
4031 "name": {"type": "string"}
4032 }
4033 }
4034 }
4035 }
4036 },
4037 "responses": {"200": {"description": "OK"}}
4038 }
4039 }
4040 }
4041 });
4042 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4043 let options = ValidationOptions {
4044 request_mode: ValidationMode::Warn,
4045 ..Default::default()
4046 };
4047 let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4048
4049 let result = registry.validate_request_with_all(
4051 "/users",
4052 "POST",
4053 &Map::new(),
4054 &Map::new(),
4055 &Map::new(),
4056 &Map::new(),
4057 None, );
4059 assert!(result.is_ok()); }
4061
4062 #[test]
4063 fn test_validate_request_body_validation_error() {
4064 let spec_json = json!({
4066 "openapi": "3.0.0",
4067 "info": {"title": "Test API", "version": "1.0.0"},
4068 "paths": {
4069 "/users": {
4070 "post": {
4071 "requestBody": {
4072 "required": true,
4073 "content": {
4074 "application/json": {
4075 "schema": {
4076 "type": "object",
4077 "required": ["name"],
4078 "properties": {
4079 "name": {"type": "string"}
4080 }
4081 }
4082 }
4083 }
4084 },
4085 "responses": {"200": {"description": "OK"}}
4086 }
4087 }
4088 }
4089 });
4090 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4091 let registry = OpenApiRouteRegistry::new(spec);
4092
4093 let result = registry.validate_request_with_all(
4095 "/users",
4096 "POST",
4097 &Map::new(),
4098 &Map::new(),
4099 &Map::new(),
4100 &Map::new(),
4101 None, );
4103 assert!(result.is_err());
4104 }
4105
4106 #[test]
4107 fn test_validate_request_body_schema_validation_error() {
4108 let spec_json = json!({
4110 "openapi": "3.0.0",
4111 "info": {"title": "Test API", "version": "1.0.0"},
4112 "paths": {
4113 "/users": {
4114 "post": {
4115 "requestBody": {
4116 "required": true,
4117 "content": {
4118 "application/json": {
4119 "schema": {
4120 "type": "object",
4121 "required": ["name"],
4122 "properties": {
4123 "name": {"type": "string"}
4124 }
4125 }
4126 }
4127 }
4128 },
4129 "responses": {"200": {"description": "OK"}}
4130 }
4131 }
4132 }
4133 });
4134 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4135 let registry = OpenApiRouteRegistry::new(spec);
4136
4137 let invalid_body = json!({}); let result = registry.validate_request_with_all(
4140 "/users",
4141 "POST",
4142 &Map::new(),
4143 &Map::new(),
4144 &Map::new(),
4145 &Map::new(),
4146 Some(&invalid_body),
4147 );
4148 assert!(result.is_err());
4149 }
4150
4151 #[test]
4152 fn test_validate_request_body_referenced_schema_error() {
4153 let spec_json = json!({
4155 "openapi": "3.0.0",
4156 "info": {"title": "Test API", "version": "1.0.0"},
4157 "paths": {
4158 "/users": {
4159 "post": {
4160 "requestBody": {
4161 "required": true,
4162 "content": {
4163 "application/json": {
4164 "schema": {
4165 "$ref": "#/components/schemas/NonExistentSchema"
4166 }
4167 }
4168 }
4169 },
4170 "responses": {"200": {"description": "OK"}}
4171 }
4172 }
4173 },
4174 "components": {}
4175 });
4176 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4177 let registry = OpenApiRouteRegistry::new(spec);
4178
4179 let body = json!({"name": "test"});
4181 let result = registry.validate_request_with_all(
4182 "/users",
4183 "POST",
4184 &Map::new(),
4185 &Map::new(),
4186 &Map::new(),
4187 &Map::new(),
4188 Some(&body),
4189 );
4190 assert!(result.is_err());
4191 }
4192
4193 #[test]
4194 fn test_validate_request_body_referenced_request_body_error() {
4195 let spec_json = json!({
4197 "openapi": "3.0.0",
4198 "info": {"title": "Test API", "version": "1.0.0"},
4199 "paths": {
4200 "/users": {
4201 "post": {
4202 "requestBody": {
4203 "$ref": "#/components/requestBodies/NonExistentRequestBody"
4204 },
4205 "responses": {"200": {"description": "OK"}}
4206 }
4207 }
4208 },
4209 "components": {}
4210 });
4211 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4212 let registry = OpenApiRouteRegistry::new(spec);
4213
4214 let body = json!({"name": "test"});
4216 let result = registry.validate_request_with_all(
4217 "/users",
4218 "POST",
4219 &Map::new(),
4220 &Map::new(),
4221 &Map::new(),
4222 &Map::new(),
4223 Some(&body),
4224 );
4225 assert!(result.is_err());
4226 }
4227
4228 #[test]
4229 fn test_validate_request_body_provided_when_not_expected() {
4230 let spec_json = json!({
4232 "openapi": "3.0.0",
4233 "info": {"title": "Test API", "version": "1.0.0"},
4234 "paths": {
4235 "/users": {
4236 "get": {
4237 "responses": {"200": {"description": "OK"}}
4238 }
4239 }
4240 }
4241 });
4242 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4243 let registry = OpenApiRouteRegistry::new(spec);
4244
4245 let body = json!({"extra": "data"});
4247 let result = registry.validate_request_with_all(
4248 "/users",
4249 "GET",
4250 &Map::new(),
4251 &Map::new(),
4252 &Map::new(),
4253 &Map::new(),
4254 Some(&body),
4255 );
4256 assert!(result.is_ok());
4258 }
4259
4260 #[test]
4261 fn test_get_operation() {
4262 let spec_json = json!({
4264 "openapi": "3.0.0",
4265 "info": {"title": "Test API", "version": "1.0.0"},
4266 "paths": {
4267 "/users": {
4268 "get": {
4269 "operationId": "getUsers",
4270 "summary": "Get users",
4271 "responses": {"200": {"description": "OK"}}
4272 }
4273 }
4274 }
4275 });
4276 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4277 let registry = OpenApiRouteRegistry::new(spec);
4278
4279 let operation = registry.get_operation("/users", "GET");
4281 assert!(operation.is_some());
4282 assert_eq!(operation.unwrap().method, "GET");
4283
4284 assert!(registry.get_operation("/nonexistent", "GET").is_none());
4286 }
4287
4288 #[test]
4289 fn test_extract_path_parameters() {
4290 let spec_json = json!({
4292 "openapi": "3.0.0",
4293 "info": {"title": "Test API", "version": "1.0.0"},
4294 "paths": {
4295 "/users/{id}": {
4296 "get": {
4297 "parameters": [
4298 {
4299 "name": "id",
4300 "in": "path",
4301 "required": true,
4302 "schema": {"type": "string"}
4303 }
4304 ],
4305 "responses": {"200": {"description": "OK"}}
4306 }
4307 }
4308 }
4309 });
4310 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4311 let registry = OpenApiRouteRegistry::new(spec);
4312
4313 let params = registry.extract_path_parameters("/users/123", "GET");
4315 assert_eq!(params.get("id"), Some(&"123".to_string()));
4316
4317 let empty_params = registry.extract_path_parameters("/users", "GET");
4319 assert!(empty_params.is_empty());
4320 }
4321
4322 #[test]
4323 fn extract_path_parameters_prefers_static_route_and_rejects_empty() {
4324 let spec_json = json!({
4327 "openapi": "3.0.0",
4328 "info": {"title": "Test API", "version": "1.0.0"},
4329 "paths": {
4330 "/users/{id}": { "get": { "responses": {"200": {"description": "OK"}} } },
4331 "/users/me": { "get": { "responses": {"200": {"description": "OK"}} } }
4332 }
4333 });
4334 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4335 let registry = OpenApiRouteRegistry::new(spec);
4336
4337 let me = registry.extract_path_parameters("/users/me", "GET");
4339 assert!(!me.contains_key("id"), "literal route should win, got {me:?}");
4340
4341 let by_id = registry.extract_path_parameters("/users/123", "GET");
4343 assert_eq!(by_id.get("id"), Some(&"123".to_string()));
4344
4345 let trailing = registry.extract_path_parameters("/users/", "GET");
4347 assert!(
4348 trailing.is_empty(),
4349 "empty trailing segment should not bind id, got {trailing:?}"
4350 );
4351 }
4352
4353 #[test]
4354 fn test_extract_path_parameters_multiple_params() {
4355 let spec_json = json!({
4357 "openapi": "3.0.0",
4358 "info": {"title": "Test API", "version": "1.0.0"},
4359 "paths": {
4360 "/users/{userId}/posts/{postId}": {
4361 "get": {
4362 "parameters": [
4363 {
4364 "name": "userId",
4365 "in": "path",
4366 "required": true,
4367 "schema": {"type": "string"}
4368 },
4369 {
4370 "name": "postId",
4371 "in": "path",
4372 "required": true,
4373 "schema": {"type": "string"}
4374 }
4375 ],
4376 "responses": {"200": {"description": "OK"}}
4377 }
4378 }
4379 }
4380 });
4381 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4382 let registry = OpenApiRouteRegistry::new(spec);
4383
4384 let params = registry.extract_path_parameters("/users/123/posts/456", "GET");
4386 assert_eq!(params.get("userId"), Some(&"123".to_string()));
4387 assert_eq!(params.get("postId"), Some(&"456".to_string()));
4388 }
4389
4390 #[test]
4391 fn test_validate_request_route_not_found() {
4392 let spec_json = json!({
4394 "openapi": "3.0.0",
4395 "info": {"title": "Test API", "version": "1.0.0"},
4396 "paths": {
4397 "/users": {
4398 "get": {
4399 "responses": {"200": {"description": "OK"}}
4400 }
4401 }
4402 }
4403 });
4404 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4405 let registry = OpenApiRouteRegistry::new(spec);
4406
4407 let result = registry.validate_request_with_all(
4409 "/nonexistent",
4410 "GET",
4411 &Map::new(),
4412 &Map::new(),
4413 &Map::new(),
4414 &Map::new(),
4415 None,
4416 );
4417 assert!(result.is_err());
4418 assert!(result.unwrap_err().to_string().contains("not found"));
4419 }
4420
4421 #[test]
4422 fn test_validate_request_with_path_parameters() {
4423 let spec_json = json!({
4425 "openapi": "3.0.0",
4426 "info": {"title": "Test API", "version": "1.0.0"},
4427 "paths": {
4428 "/users/{id}": {
4429 "get": {
4430 "parameters": [
4431 {
4432 "name": "id",
4433 "in": "path",
4434 "required": true,
4435 "schema": {"type": "string", "minLength": 1}
4436 }
4437 ],
4438 "responses": {"200": {"description": "OK"}}
4439 }
4440 }
4441 }
4442 });
4443 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4444 let registry = OpenApiRouteRegistry::new(spec);
4445
4446 let mut path_params = Map::new();
4448 path_params.insert("id".to_string(), json!("123"));
4449 let result = registry.validate_request_with_all(
4450 "/users/{id}",
4451 "GET",
4452 &path_params,
4453 &Map::new(),
4454 &Map::new(),
4455 &Map::new(),
4456 None,
4457 );
4458 assert!(result.is_ok());
4459 }
4460
4461 #[test]
4462 fn test_validate_request_with_query_parameters() {
4463 let spec_json = json!({
4465 "openapi": "3.0.0",
4466 "info": {"title": "Test API", "version": "1.0.0"},
4467 "paths": {
4468 "/users": {
4469 "get": {
4470 "parameters": [
4471 {
4472 "name": "page",
4473 "in": "query",
4474 "required": true,
4475 "schema": {"type": "integer", "minimum": 1}
4476 }
4477 ],
4478 "responses": {"200": {"description": "OK"}}
4479 }
4480 }
4481 }
4482 });
4483 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4484 let registry = OpenApiRouteRegistry::new(spec);
4485
4486 let mut query_params = Map::new();
4488 query_params.insert("page".to_string(), json!(1));
4489 let result = registry.validate_request_with_all(
4490 "/users",
4491 "GET",
4492 &Map::new(),
4493 &query_params,
4494 &Map::new(),
4495 &Map::new(),
4496 None,
4497 );
4498 assert!(result.is_ok());
4499 }
4500
4501 #[test]
4502 fn test_validate_request_with_header_parameters() {
4503 let spec_json = json!({
4505 "openapi": "3.0.0",
4506 "info": {"title": "Test API", "version": "1.0.0"},
4507 "paths": {
4508 "/users": {
4509 "get": {
4510 "parameters": [
4511 {
4512 "name": "X-API-Key",
4513 "in": "header",
4514 "required": true,
4515 "schema": {"type": "string"}
4516 }
4517 ],
4518 "responses": {"200": {"description": "OK"}}
4519 }
4520 }
4521 }
4522 });
4523 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4524 let registry = OpenApiRouteRegistry::new(spec);
4525
4526 let mut header_params = Map::new();
4528 header_params.insert("X-API-Key".to_string(), json!("secret-key"));
4529 let result = registry.validate_request_with_all(
4530 "/users",
4531 "GET",
4532 &Map::new(),
4533 &Map::new(),
4534 &header_params,
4535 &Map::new(),
4536 None,
4537 );
4538 assert!(result.is_ok());
4539 }
4540
4541 #[test]
4542 fn test_validate_request_with_cookie_parameters() {
4543 let spec_json = json!({
4545 "openapi": "3.0.0",
4546 "info": {"title": "Test API", "version": "1.0.0"},
4547 "paths": {
4548 "/users": {
4549 "get": {
4550 "parameters": [
4551 {
4552 "name": "sessionId",
4553 "in": "cookie",
4554 "required": true,
4555 "schema": {"type": "string"}
4556 }
4557 ],
4558 "responses": {"200": {"description": "OK"}}
4559 }
4560 }
4561 }
4562 });
4563 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4564 let registry = OpenApiRouteRegistry::new(spec);
4565
4566 let mut cookie_params = Map::new();
4568 cookie_params.insert("sessionId".to_string(), json!("abc123"));
4569 let result = registry.validate_request_with_all(
4570 "/users",
4571 "GET",
4572 &Map::new(),
4573 &Map::new(),
4574 &Map::new(),
4575 &cookie_params,
4576 None,
4577 );
4578 assert!(result.is_ok());
4579 }
4580
4581 #[test]
4582 fn test_validate_request_no_errors_early_return() {
4583 let spec_json = json!({
4585 "openapi": "3.0.0",
4586 "info": {"title": "Test API", "version": "1.0.0"},
4587 "paths": {
4588 "/users": {
4589 "get": {
4590 "responses": {"200": {"description": "OK"}}
4591 }
4592 }
4593 }
4594 });
4595 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4596 let registry = OpenApiRouteRegistry::new(spec);
4597
4598 let result = registry.validate_request_with_all(
4600 "/users",
4601 "GET",
4602 &Map::new(),
4603 &Map::new(),
4604 &Map::new(),
4605 &Map::new(),
4606 None,
4607 );
4608 assert!(result.is_ok());
4609 }
4610
4611 #[test]
4612 fn test_validate_request_query_parameter_different_styles() {
4613 let spec_json = json!({
4615 "openapi": "3.0.0",
4616 "info": {"title": "Test API", "version": "1.0.0"},
4617 "paths": {
4618 "/users": {
4619 "get": {
4620 "parameters": [
4621 {
4622 "name": "tags",
4623 "in": "query",
4624 "style": "pipeDelimited",
4625 "schema": {
4626 "type": "array",
4627 "items": {"type": "string"}
4628 }
4629 }
4630 ],
4631 "responses": {"200": {"description": "OK"}}
4632 }
4633 }
4634 }
4635 });
4636 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4637 let registry = OpenApiRouteRegistry::new(spec);
4638
4639 let mut query_params = Map::new();
4641 query_params.insert("tags".to_string(), json!(["tag1", "tag2"]));
4642 let result = registry.validate_request_with_all(
4643 "/users",
4644 "GET",
4645 &Map::new(),
4646 &query_params,
4647 &Map::new(),
4648 &Map::new(),
4649 None,
4650 );
4651 assert!(result.is_ok() || result.is_err()); }
4654}