1pub mod builder;
13pub mod generation;
14#[doc(hidden)]
15pub mod registry;
16pub mod validation;
17
18use crate::response::AiGenerator;
19use crate::response_rewriter::{OverrideHook, OverrideRequest, 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 overrides: Option<Arc<dyn OverrideHook>>,
55}
56
57#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)]
59pub enum ValidationMode {
60 Disabled,
62 #[default]
64 Warn,
65 Enforce,
67}
68
69#[derive(Debug, Clone)]
71pub struct ValidationOptions {
72 pub request_mode: ValidationMode,
74 pub aggregate_errors: bool,
76 pub validate_responses: bool,
78 pub overrides: HashMap<String, ValidationMode>,
80 pub admin_skip_prefixes: Vec<String>,
82 pub response_template_expand: bool,
84 pub validation_status: Option<u16>,
86}
87
88impl Default for ValidationOptions {
89 fn default() -> Self {
90 Self {
91 request_mode: ValidationMode::Enforce,
92 aggregate_errors: true,
93 validate_responses: false,
94 overrides: HashMap::new(),
95 admin_skip_prefixes: Vec::new(),
96 response_template_expand: false,
97 validation_status: None,
98 }
99 }
100}
101
102#[derive(Clone)]
107pub struct RouterContext {
108 pub custom_fixture_loader: Option<Arc<crate::custom_fixture::CustomFixtureLoader>>,
110 pub latency_injector: Option<LatencyInjector>,
112 pub failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
114 pub response_rewriter: Option<Arc<dyn ResponseRewriter>>,
117 pub overrides: Option<Arc<dyn OverrideHook>>,
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: None,
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 overrides: None,
236 }
237 }
238
239 pub fn new_with_options(spec: OpenApiSpec, options: ValidationOptions) -> Self {
241 Self::new_with_options_and_persona(spec, options, None)
242 }
243
244 pub fn new_with_options_and_persona(
246 spec: OpenApiSpec,
247 options: ValidationOptions,
248 persona: Option<Arc<mockforge_foundation::intelligent_behavior::Persona>>,
249 ) -> Self {
250 tracing::debug!("Creating OpenAPI route registry with custom options");
251 let spec = Arc::new(spec);
252 let routes = Self::generate_routes_with_persona(&spec, persona);
253 Self {
254 spec,
255 routes,
256 options,
257 custom_fixture_loader: None,
258 overrides: None,
259 }
260 }
261
262 pub fn with_custom_fixture_loader(
264 mut self,
265 loader: Arc<crate::custom_fixture::CustomFixtureLoader>,
266 ) -> Self {
267 self.custom_fixture_loader = Some(loader);
268 self
269 }
270
271 pub fn with_overrides(mut self, overrides: Arc<dyn OverrideHook>) -> Self {
273 self.overrides = Some(overrides);
274 self
275 }
276
277 pub fn clone_for_validation(&self) -> Self {
282 OpenApiRouteRegistry {
283 spec: self.spec.clone(),
284 routes: self.routes.clone(),
285 options: self.options.clone(),
286 custom_fixture_loader: self.custom_fixture_loader.clone(),
287 overrides: self.overrides.clone(),
288 }
289 }
290
291 fn generate_routes_with_persona(
293 spec: &Arc<OpenApiSpec>,
294 persona: Option<Arc<mockforge_foundation::intelligent_behavior::Persona>>,
295 ) -> Vec<OpenApiRoute> {
296 let mut routes = Vec::new();
297
298 let all_paths_ops = spec.all_paths_and_operations();
299 tracing::debug!("Generating routes from OpenAPI spec with {} paths", all_paths_ops.len());
300
301 for (path, operations) in all_paths_ops {
302 tracing::debug!("Processing path: {}", path);
303 for (method, operation) in operations {
304 routes.push(OpenApiRoute::from_operation_with_persona(
305 &method,
306 path.clone(),
307 &operation,
308 spec.clone(),
309 persona.clone(),
310 ));
311 }
312 }
313
314 tracing::debug!("Generated {} total routes from OpenAPI spec", routes.len());
315 routes
316 }
317
318 pub fn routes(&self) -> &[OpenApiRoute] {
320 &self.routes
321 }
322
323 pub fn spec(&self) -> &OpenApiSpec {
325 &self.spec
326 }
327
328 fn normalize_path_for_dedup(path: &str) -> String {
332 let mut result = String::with_capacity(path.len());
333 let mut in_brace = false;
334 for ch in path.chars() {
335 if ch == '{' {
336 in_brace = true;
337 result.push_str("{_}");
338 } else if ch == '}' {
339 in_brace = false;
340 } else if !in_brace {
341 result.push(ch);
342 }
343 }
344 result
345 }
346
347 fn deduplicated_routes(&self) -> Vec<(String, &OpenApiRoute)> {
353 let mut result = Vec::new();
354 let mut registered_routes: HashSet<(String, String)> = HashSet::new();
355 let mut canonical_paths: HashMap<String, String> = HashMap::new();
356
357 for route in &self.routes {
358 if !route.is_valid_axum_path() {
359 tracing::warn!(
360 "Skipping route with unsupported path syntax: {} {}",
361 route.method,
362 route.path
363 );
364 continue;
365 }
366 let axum_path = route.axum_path();
367 let normalized = Self::normalize_path_for_dedup(&axum_path);
368 let axum_path = canonical_paths
369 .entry(normalized.clone())
370 .or_insert_with(|| axum_path.clone())
371 .clone();
372 let route_key = (route.method.clone(), normalized);
373 if !registered_routes.insert(route_key) {
374 tracing::debug!(
375 "Skipping duplicate route: {} {} (axum path: {})",
376 route.method,
377 route.path,
378 axum_path
379 );
380 continue;
381 }
382 result.push((axum_path, route));
383 }
384 result
385 }
386
387 fn route_for_method<H, T>(router: Router, path: &str, method: &str, handler: H) -> Router
391 where
392 H: axum::handler::Handler<T, ()>,
393 T: 'static,
394 {
395 match method {
396 "GET" => router.route(path, get(handler)),
397 "POST" => router.route(path, post(handler)),
398 "PUT" => router.route(path, put(handler)),
399 "DELETE" => router.route(path, delete(handler)),
400 "PATCH" => router.route(path, patch(handler)),
401 "HEAD" => router.route(path, head(handler)),
402 "OPTIONS" => router.route(path, options(handler)),
403 _ => router,
404 }
405 }
406
407 pub fn build_router(self) -> Router {
409 let ctx = RouterContext {
410 custom_fixture_loader: self.custom_fixture_loader.clone(),
411 overrides: self.overrides.clone(),
412 enable_full_validation: true,
413 enable_template_expand: true,
414 add_spec_endpoint: true,
415 ..Default::default()
416 };
417 self.build_router_with_context(ctx)
418 }
419
420 fn build_router_with_context(self, ctx: RouterContext) -> Router {
425 let mut router = Router::new();
426 tracing::debug!("Building router from {} routes", self.routes.len());
427
428 let deduped = self.deduplicated_routes();
429 let ctx = Arc::new(ctx);
430 let validator = Arc::new(self.clone_for_validation());
438 for (axum_path, route) in &deduped {
439 tracing::debug!("Adding route: {} {}", route.method, route.path);
440 let operation = route.operation.clone();
441 let method = route.method.clone();
442 let path_template = route.path.clone();
443 let validator = validator.clone();
444 let route_clone = (*route).clone();
445 let ctx = ctx.clone();
446
447 let mut operation_tags = operation.tags.clone();
449 if let Some(operation_id) = &operation.operation_id {
450 operation_tags.push(operation_id.clone());
451 }
452
453 let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
455 RawQuery(raw_query): RawQuery,
456 headers: HeaderMap,
457 body: axum::body::Bytes| async move {
458 tracing::debug!("Handling OpenAPI request: {} {}", method, path_template);
459
460 if let Some(ref loader) = ctx.custom_fixture_loader {
462 use crate::request_fingerprint::RequestFingerprint;
463 use axum::http::{Method, Uri};
464
465 let mut request_path = path_template.clone();
467 for (key, value) in &path_params {
468 request_path = request_path.replace(&format!("{{{}}}", key), value);
469 }
470
471 let normalized_request_path =
473 crate::custom_fixture::CustomFixtureLoader::normalize_path(&request_path);
474
475 let query_string =
477 raw_query.as_ref().map(|q| q.to_string()).unwrap_or_default();
478
479 let uri_str = if query_string.is_empty() {
482 normalized_request_path.clone()
483 } else {
484 format!("{}?{}", normalized_request_path, query_string)
485 };
486
487 if let Ok(uri) = uri_str.parse::<Uri>() {
488 let http_method =
489 Method::from_bytes(method.as_bytes()).unwrap_or(Method::GET);
490 let body_slice = if body.is_empty() {
491 None
492 } else {
493 Some(body.as_ref())
494 };
495 let fingerprint =
496 RequestFingerprint::new(http_method, &uri, &headers, body_slice);
497
498 tracing::debug!(
500 "Checking fixture for {} {} (template: '{}', request_path: '{}', normalized: '{}', fingerprint.path: '{}')",
501 method,
502 path_template,
503 path_template,
504 request_path,
505 normalized_request_path,
506 fingerprint.path
507 );
508
509 if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
510 tracing::debug!(
511 "Using custom fixture for {} {}",
512 method,
513 path_template
514 );
515
516 if custom_fixture.delay_ms > 0 {
518 tokio::time::sleep(tokio::time::Duration::from_millis(
519 custom_fixture.delay_ms,
520 ))
521 .await;
522 }
523
524 let response_body = if custom_fixture.response.is_string() {
526 custom_fixture.response.as_str().unwrap().to_string()
527 } else {
528 serde_json::to_string(&custom_fixture.response)
529 .unwrap_or_else(|_| "{}".to_string())
530 };
531
532 let json_value: Value = serde_json::from_str(&response_body)
534 .unwrap_or_else(|_| serde_json::json!({}));
535
536 let status = axum::http::StatusCode::from_u16(custom_fixture.status)
538 .unwrap_or(axum::http::StatusCode::OK);
539
540 let mut response = (status, Json(json_value)).into_response();
541
542 let response_headers = response.headers_mut();
544 for (key, value) in &custom_fixture.headers {
545 if let (Ok(header_name), Ok(header_value)) = (
546 axum::http::HeaderName::from_bytes(key.as_bytes()),
547 axum::http::HeaderValue::from_str(value),
548 ) {
549 response_headers.insert(header_name, header_value);
550 }
551 }
552
553 if !custom_fixture.headers.contains_key("content-type") {
555 response_headers.insert(
556 axum::http::header::CONTENT_TYPE,
557 axum::http::HeaderValue::from_static("application/json"),
558 );
559 }
560
561 return response;
562 }
563 }
564 }
565
566 if let Some(ref failure_injector) = ctx.failure_injector {
568 if let Some((status_code, error_message)) =
569 failure_injector.process_request(&operation_tags)
570 {
571 let payload = serde_json::json!({
572 "error": error_message,
573 "injected_failure": true
574 });
575 let body_bytes = serde_json::to_vec(&payload)
576 .unwrap_or_else(|_| br#"{"error":"injected failure"}"#.to_vec());
577 return axum::http::Response::builder()
578 .status(
579 axum::http::StatusCode::from_u16(status_code)
580 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
581 )
582 .header(axum::http::header::CONTENT_TYPE, "application/json")
583 .body(axum::body::Body::from(body_bytes))
584 .expect("Response builder should create valid response");
585 }
586 }
587
588 if let Some(ref injector) = ctx.latency_injector {
590 if let Err(e) = injector.inject_latency(&operation_tags).await {
591 tracing::warn!("Failed to inject latency: {}", e);
592 }
593 }
594
595 let scenario = headers
597 .get("X-Mockforge-Scenario")
598 .and_then(|v| v.to_str().ok())
599 .map(|s| s.to_string())
600 .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
601
602 let status_override = headers
603 .get("X-Mockforge-Response-Status")
604 .and_then(|v| v.to_str().ok())
605 .and_then(|s| s.parse::<u16>().ok());
606
607 let (selected_status, mock_response) = route_clone
609 .mock_response_with_status_and_scenario_and_override(
610 scenario.as_deref(),
611 status_override,
612 );
613
614 if ctx.enable_full_validation {
616 let mut path_map = Map::new();
618 for (k, v) in &path_params {
619 path_map.insert(k.clone(), Value::String(v.clone()));
620 }
621
622 let mut query_map = Map::new();
624 if let Some(ref q) = raw_query {
625 for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
626 query_map.insert(k.to_string(), Value::String(v.to_string()));
627 }
628 }
629
630 let mut header_map = Map::new();
632 for p_ref in &operation.parameters {
633 if let Some(openapiv3::Parameter::Header { parameter_data, .. }) =
634 p_ref.as_item()
635 {
636 let name_lc = parameter_data.name.to_ascii_lowercase();
637 if let Ok(hn) = axum::http::HeaderName::from_bytes(name_lc.as_bytes()) {
638 if let Some(val) = headers.get(hn) {
639 if let Ok(s) = val.to_str() {
640 header_map.insert(
641 parameter_data.name.clone(),
642 Value::String(s.to_string()),
643 );
644 }
645 }
646 }
647 }
648 }
649
650 let mut cookie_map = Map::new();
652 if let Some(val) = headers.get(axum::http::header::COOKIE) {
653 if let Ok(s) = val.to_str() {
654 for part in s.split(';') {
655 let part = part.trim();
656 if let Some((k, v)) = part.split_once('=') {
657 cookie_map.insert(k.to_string(), Value::String(v.to_string()));
658 }
659 }
660 }
661 }
662
663 let is_multipart = headers
665 .get(axum::http::header::CONTENT_TYPE)
666 .and_then(|v| v.to_str().ok())
667 .map(|ct| ct.starts_with("multipart/form-data"))
668 .unwrap_or(false);
669
670 #[allow(unused_assignments)]
672 let mut multipart_fields = HashMap::new();
673 let mut _multipart_files = HashMap::new();
674 let mut body_json: Option<Value> = None;
675
676 if is_multipart {
677 match extract_multipart_from_bytes(&body, &headers).await {
679 Ok((fields, files)) => {
680 multipart_fields = fields;
681 _multipart_files = files;
682 let mut body_obj = Map::new();
684 for (k, v) in &multipart_fields {
685 body_obj.insert(k.clone(), v.clone());
686 }
687 if !body_obj.is_empty() {
688 body_json = Some(Value::Object(body_obj));
689 }
690 }
691 Err(e) => {
692 tracing::warn!("Failed to parse multipart data: {}", e);
693 }
694 }
695 } else {
696 body_json = if !body.is_empty() {
698 serde_json::from_slice(&body).ok()
699 } else {
700 None
701 };
702 }
703
704 let actual_ct =
717 headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok());
718 if let Err(ct_err) =
719 validator.check_request_content_type(&path_template, &method, actual_ct)
720 {
721 let status_code =
722 validator.options.validation_status.unwrap_or_else(|| {
723 std::env::var("MOCKFORGE_VALIDATION_STATUS")
724 .ok()
725 .and_then(|s| s.parse::<u16>().ok())
726 .unwrap_or(415)
727 });
728 let (client_mockforge_version, client_sent_at) =
729 mockforge_foundation::conformance_violations::read_client_stamps(
730 |name| {
731 headers
732 .get(name)
733 .and_then(|v| v.to_str().ok())
734 .map(|s| s.to_string())
735 },
736 );
737 mockforge_foundation::conformance_violations::record(
738 mockforge_foundation::conformance_violations::ServerConformanceViolation {
739 timestamp: Utc::now(),
740 method: method.to_string(),
741 path: path_template.clone(),
742 client_ip: "unknown".to_string(),
743 status: status_code,
744 reason: ct_err.clone(),
745 category: "content-types".to_string(),
746 occurrences: 1,
747 client_mockforge_version,
748 client_sent_at,
749 summary: String::new(),
750 categories: Vec::new(),
751 },
752 );
753 let status = axum::http::StatusCode::from_u16(status_code)
754 .unwrap_or(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
755 let payload = serde_json::json!({
756 "error": "content_type_not_allowed",
757 "message": ct_err,
758 });
759 let body_bytes = serde_json::to_vec(&payload)
760 .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
761 return axum::response::Response::builder()
762 .status(status)
763 .header("content-type", "application/json")
764 .body(axum::body::Body::from(body_bytes))
765 .unwrap_or_else(|_| {
766 axum::response::Response::builder()
767 .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
768 .body(axum::body::Body::empty())
769 .unwrap()
770 });
771 }
772
773 if let Err(e) = validator.validate_request_with_all_ex(
777 &path_template,
778 &method,
779 &path_map,
780 &query_map,
781 &header_map,
782 &cookie_map,
783 body_json.as_ref(),
784 !body.is_empty(),
785 ) {
786 let status_code =
788 validator.options.validation_status.unwrap_or_else(|| {
789 std::env::var("MOCKFORGE_VALIDATION_STATUS")
790 .ok()
791 .and_then(|s| s.parse::<u16>().ok())
792 .unwrap_or(400)
793 });
794
795 let payload = if status_code == 422 {
796 generate_enhanced_422_response(
798 &validator,
799 &path_template,
800 &method,
801 body_json.as_ref(),
802 &path_map,
803 &query_map,
804 &header_map,
805 &cookie_map,
806 )
807 } else {
808 let msg = format!("{}", e);
810 let detail_val = serde_json::from_str::<Value>(&msg)
811 .unwrap_or(serde_json::json!(msg));
812 json!({
813 "error": "request validation failed",
814 "detail": detail_val,
815 "method": method,
816 "path": path_template,
817 "timestamp": Utc::now().to_rfc3339(),
818 })
819 };
820
821 record_validation_error(&payload);
822
823 let reason = payload
830 .get("detail")
831 .and_then(|d| {
832 if d.is_string() {
833 d.as_str().map(|s| s.to_string())
834 } else {
835 serde_json::to_string(d).ok()
836 }
837 })
838 .unwrap_or_else(|| {
839 payload
840 .get("error")
841 .and_then(|v| v.as_str())
842 .unwrap_or("request validation failed")
843 .to_string()
844 });
845 let category = classify_validation_reason(&reason);
846 let (client_mockforge_version, client_sent_at) =
847 mockforge_foundation::conformance_violations::read_client_stamps(
848 |name| {
849 headers
850 .get(name)
851 .and_then(|v| v.to_str().ok())
852 .map(|s| s.to_string())
853 },
854 );
855 mockforge_foundation::conformance_violations::record(
856 mockforge_foundation::conformance_violations::ServerConformanceViolation {
857 timestamp: Utc::now(),
858 method: method.to_string(),
859 path: path_template.clone(),
860 client_ip: "unknown".to_string(),
861 status: status_code,
862 reason,
863 category,
864 occurrences: 1,
865 client_mockforge_version,
866 client_sent_at,
867 summary: String::new(),
868 categories: Vec::new(),
869 },
870 );
871
872 let status = axum::http::StatusCode::from_u16(status_code)
873 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
874
875 let body_bytes = serde_json::to_vec(&payload)
877 .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
878
879 return axum::http::Response::builder()
880 .status(status)
881 .header(axum::http::header::CONTENT_TYPE, "application/json")
882 .body(axum::body::Body::from(body_bytes))
883 .expect("Response builder should create valid response with valid headers and body");
884 }
885 }
886
887 let mut final_response = mock_response.clone();
896 let env_expand: Option<bool> = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
897 .ok()
898 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"));
899 let expand = match env_expand {
900 Some(v) => v,
901 None => {
902 ctx.enable_template_expand || validator.options.response_template_expand
903 }
904 };
905 if expand {
906 if let Some(ref rewriter) = ctx.response_rewriter {
907 rewriter.expand_tokens(&mut final_response);
908 }
909 }
910
911 if let Some(ref overrides) = ctx.overrides {
913 let request = OverrideRequest {
914 method: &method,
915 path_template: &path_template,
916 operation_id: operation.operation_id.as_deref().unwrap_or_default(),
917 tags: &operation_tags,
918 headers: &headers,
919 query: raw_query.as_deref(),
920 body: &body,
921 };
922 overrides.apply(&request, &mut final_response);
923 }
924
925 if ctx.enable_full_validation {
927 if validator.options.validate_responses {
929 if let Some((status_code, _response)) = operation
931 .responses
932 .responses
933 .iter()
934 .filter_map(|(status, resp)| match status {
935 openapiv3::StatusCode::Code(code)
936 if *code >= 200 && *code < 300 =>
937 {
938 resp.as_item().map(|r| ((*code), r))
939 }
940 openapiv3::StatusCode::Range(range)
941 if *range >= 200 && *range < 300 =>
942 {
943 resp.as_item().map(|r| (200, r))
944 }
945 _ => None,
946 })
947 .next()
948 {
949 if serde_json::from_value::<Value>(final_response.clone()).is_err() {
951 tracing::warn!(
952 "Response validation failed: invalid JSON for status {}",
953 status_code
954 );
955 }
956 }
957 }
958
959 let mut trace = ResponseGenerationTrace::new();
961 trace.set_final_payload(final_response.clone());
962
963 if let Some((_status_code, response_ref)) = operation
965 .responses
966 .responses
967 .iter()
968 .filter_map(|(status, resp)| match status {
969 openapiv3::StatusCode::Code(code) if *code == selected_status => {
970 resp.as_item().map(|r| ((*code), r))
971 }
972 openapiv3::StatusCode::Range(range)
973 if *range >= 200 && *range < 300 =>
974 {
975 resp.as_item().map(|r| (200, r))
976 }
977 _ => None,
978 })
979 .next()
980 .or_else(|| {
981 operation
983 .responses
984 .responses
985 .iter()
986 .filter_map(|(status, resp)| match status {
987 openapiv3::StatusCode::Code(code)
988 if *code >= 200 && *code < 300 =>
989 {
990 resp.as_item().map(|r| ((*code), r))
991 }
992 _ => None,
993 })
994 .next()
995 })
996 {
997 let response_item = response_ref;
999 if let Some(content) = response_item.content.get("application/json") {
1001 if let Some(schema_ref) = &content.schema {
1002 if let Some(schema) = schema_ref.as_item() {
1004 if let Ok(schema_json) = serde_json::to_value(schema) {
1005 let validation_errors =
1007 validation_diff(&schema_json, &final_response);
1008 trace.set_schema_validation_diff(validation_errors);
1009 }
1010 }
1011 }
1012 }
1013 }
1014
1015 let mut response = Json(final_response).into_response();
1017 response.extensions_mut().insert(trace);
1018 *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
1019 .unwrap_or(axum::http::StatusCode::OK);
1020 inject_spec_response_headers(&mut response, &route_clone, selected_status);
1021 return response;
1022 }
1023
1024 let mut response = Json(final_response).into_response();
1026 *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
1027 .unwrap_or(axum::http::StatusCode::OK);
1028 inject_spec_response_headers(&mut response, &route_clone, selected_status);
1029 response
1030 };
1031
1032 router = Self::route_for_method(router, axum_path, &route.method, handler);
1033 }
1034
1035 if ctx.add_spec_endpoint {
1037 let spec_json = serde_json::to_value(&self.spec.spec).unwrap_or(Value::Null);
1038 router = router.route("/openapi.json", get(move || async move { Json(spec_json) }));
1039 }
1040
1041 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
1045 }
1046
1047 pub fn build_router_with_latency(self, latency_injector: LatencyInjector) -> Router {
1049 self.build_router_with_injectors(latency_injector, None)
1050 }
1051
1052 pub fn build_router_with_injectors(
1054 self,
1055 latency_injector: LatencyInjector,
1056 failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1057 ) -> Router {
1058 self.build_router_with_injectors_and_rewriter(latency_injector, failure_injector, None)
1059 }
1060
1061 pub fn build_router_with_injectors_and_rewriter(
1065 self,
1066 latency_injector: LatencyInjector,
1067 failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1068 response_rewriter: Option<Arc<dyn ResponseRewriter>>,
1069 ) -> Router {
1070 let ctx = RouterContext {
1071 custom_fixture_loader: self.custom_fixture_loader.clone(),
1072 overrides: self.overrides.clone(),
1073 latency_injector: Some(latency_injector),
1074 failure_injector,
1075 response_rewriter,
1076 enable_full_validation: true,
1077 enable_template_expand: true,
1078 add_spec_endpoint: true,
1079 ..Default::default()
1080 };
1081 self.build_router_with_context(ctx)
1082 }
1083
1084 pub fn get_route(&self, path: &str, method: &str) -> Option<&OpenApiRoute> {
1086 self.routes.iter().find(|route| route.path == path && route.method == method)
1087 }
1088
1089 pub fn get_routes_for_path(&self, path: &str) -> Vec<&OpenApiRoute> {
1091 self.routes.iter().filter(|route| route.path == path).collect()
1092 }
1093
1094 pub fn validate_request(&self, path: &str, method: &str, body: Option<&Value>) -> Result<()> {
1096 self.validate_request_with(path, method, &Map::new(), &Map::new(), body)
1097 }
1098
1099 pub fn check_request_content_type(
1115 &self,
1116 path: &str,
1117 method: &str,
1118 actual_content_type: Option<&str>,
1119 ) -> std::result::Result<(), String> {
1120 let Some(route) = self.get_route(path, method) else {
1121 return Ok(());
1122 };
1123 let Some(rb_ref) = &route.operation.request_body else {
1124 return Ok(());
1125 };
1126 let request_body = match rb_ref {
1127 openapiv3::ReferenceOr::Item(rb) => rb,
1128 openapiv3::ReferenceOr::Reference { reference } => {
1129 let resolved = self
1130 .spec
1131 .spec
1132 .components
1133 .as_ref()
1134 .and_then(|components| {
1135 components
1136 .request_bodies
1137 .get(reference.trim_start_matches("#/components/requestBodies/"))
1138 })
1139 .and_then(|rb_ref| rb_ref.as_item());
1140 let Some(rb) = resolved else { return Ok(()) };
1141 rb
1142 }
1143 };
1144 if request_body.content.is_empty() {
1145 return Ok(());
1146 }
1147 let actual = actual_content_type
1148 .and_then(|s| s.split(';').next())
1149 .map(|s| s.trim().to_ascii_lowercase());
1150 let Some(actual) = actual else {
1151 return Ok(());
1156 };
1157 let allowed: Vec<String> = request_body
1158 .content
1159 .keys()
1160 .map(|k| k.split(';').next().unwrap_or(k).trim().to_ascii_lowercase())
1161 .collect();
1162 if allowed.iter().any(|a| a == &actual) {
1163 return Ok(());
1164 }
1165 Err(format!(
1166 "Content-Type '{actual}' not allowed; spec declares: [{}]",
1167 allowed.join(", ")
1168 ))
1169 }
1170
1171 pub fn validate_request_with(
1173 &self,
1174 path: &str,
1175 method: &str,
1176 path_params: &Map<String, Value>,
1177 query_params: &Map<String, Value>,
1178 body: Option<&Value>,
1179 ) -> Result<()> {
1180 self.validate_request_with_all(
1181 path,
1182 method,
1183 path_params,
1184 query_params,
1185 &Map::new(),
1186 &Map::new(),
1187 body,
1188 )
1189 }
1190
1191 #[allow(clippy::too_many_arguments)]
1204 pub fn run_validation_with_recording(
1205 &self,
1206 path_template: &str,
1207 method: &str,
1208 path_params: &Map<String, Value>,
1209 query_params: &Map<String, Value>,
1210 header_map: &Map<String, Value>,
1211 cookie_map: &Map<String, Value>,
1212 body: Option<&Value>,
1213 ) -> std::result::Result<(), (u16, Value)> {
1214 let body_present = body.is_some();
1215 self.run_validation_with_recording_ex(
1216 path_template,
1217 method,
1218 path_params,
1219 query_params,
1220 header_map,
1221 cookie_map,
1222 body,
1223 body_present,
1224 )
1225 }
1226
1227 #[allow(clippy::too_many_arguments)]
1231 pub fn run_validation_with_recording_ex(
1232 &self,
1233 path_template: &str,
1234 method: &str,
1235 path_params: &Map<String, Value>,
1236 query_params: &Map<String, Value>,
1237 header_map: &Map<String, Value>,
1238 cookie_map: &Map<String, Value>,
1239 body: Option<&Value>,
1240 body_present: bool,
1241 ) -> std::result::Result<(), (u16, Value)> {
1242 let e = match self.validate_request_with_all_ex(
1243 path_template,
1244 method,
1245 path_params,
1246 query_params,
1247 header_map,
1248 cookie_map,
1249 body,
1250 body_present,
1251 ) {
1252 Ok(()) => {
1253 mockforge_foundation::conformance_violations::record_ok();
1257 return Ok(());
1258 }
1259 Err(e) => e,
1260 };
1261
1262 let status_code = self.options.validation_status.unwrap_or_else(|| {
1263 std::env::var("MOCKFORGE_VALIDATION_STATUS")
1264 .ok()
1265 .and_then(|s| s.parse::<u16>().ok())
1266 .unwrap_or(400)
1267 });
1268
1269 let payload = if status_code == 422 {
1270 generate_enhanced_422_response(
1271 self,
1272 path_template,
1273 method,
1274 body,
1275 path_params,
1276 query_params,
1277 header_map,
1278 cookie_map,
1279 )
1280 } else {
1281 let msg = format!("{}", e);
1282 let detail_val = serde_json::from_str::<Value>(&msg).unwrap_or(serde_json::json!(msg));
1283 json!({
1284 "error": "request validation failed",
1285 "detail": detail_val,
1286 "method": method,
1287 "path": path_template,
1288 "timestamp": Utc::now().to_rfc3339(),
1289 })
1290 };
1291
1292 record_validation_error(&payload);
1293
1294 let reason = payload
1295 .get("detail")
1296 .and_then(|d| {
1297 if d.is_string() {
1298 d.as_str().map(|s| s.to_string())
1299 } else {
1300 serde_json::to_string(d).ok()
1301 }
1302 })
1303 .unwrap_or_else(|| {
1304 payload
1305 .get("error")
1306 .and_then(|v| v.as_str())
1307 .unwrap_or("request validation failed")
1308 .to_string()
1309 });
1310 let category = classify_validation_reason(&reason);
1311 tracing::debug!(
1319 target: "mockforge::conformance",
1320 method = %method,
1321 path = %path_template,
1322 status = status_code,
1323 category = %category,
1324 reason = %reason,
1325 "request conformance violation"
1326 );
1327 let (client_mockforge_version, client_sent_at) =
1328 mockforge_foundation::conformance_violations::read_client_stamps(|name| {
1329 header_map
1330 .iter()
1331 .find(|(k, _)| k.eq_ignore_ascii_case(name))
1332 .and_then(|(_, v)| v.as_str().map(|s| s.to_string()))
1333 });
1334
1335 let per_location: Vec<(String, String)> =
1342 validation_details(&payload).iter().filter_map(split_detail_entry).collect();
1343
1344 if per_location.is_empty() {
1345 mockforge_foundation::conformance_violations::record(
1346 mockforge_foundation::conformance_violations::ServerConformanceViolation {
1347 timestamp: Utc::now(),
1348 method: method.to_string(),
1349 path: path_template.to_string(),
1350 client_ip: "unknown".to_string(),
1351 status: status_code,
1352 reason,
1353 category,
1354 occurrences: 1,
1355 client_mockforge_version,
1356 client_sent_at,
1357 summary: String::new(),
1358 categories: Vec::new(),
1359 },
1360 );
1361 } else {
1362 for (loc_category, loc_reason) in per_location {
1363 tracing::debug!(
1364 target: "mockforge::conformance",
1365 method = %method,
1366 path = %path_template,
1367 status = status_code,
1368 category = %loc_category,
1369 reason = %loc_reason,
1370 "request conformance violation (per-location)"
1371 );
1372 mockforge_foundation::conformance_violations::record(
1373 mockforge_foundation::conformance_violations::ServerConformanceViolation {
1374 timestamp: Utc::now(),
1375 method: method.to_string(),
1376 path: path_template.to_string(),
1377 client_ip: "unknown".to_string(),
1378 status: status_code,
1379 reason: loc_reason,
1380 category: loc_category.clone(),
1381 occurrences: 1,
1382 client_mockforge_version: client_mockforge_version.clone(),
1383 client_sent_at,
1384 summary: String::new(),
1385 categories: vec![loc_category],
1386 },
1387 );
1388 }
1389 }
1390
1391 if mockforge_foundation::unknown_paths::shadow_mode_enabled() {
1398 return Ok(());
1399 }
1400
1401 Err((status_code, payload))
1402 }
1403
1404 #[allow(clippy::too_many_arguments)]
1411 pub fn validate_request_with_all(
1412 &self,
1413 path: &str,
1414 method: &str,
1415 path_params: &Map<String, Value>,
1416 query_params: &Map<String, Value>,
1417 header_params: &Map<String, Value>,
1418 cookie_params: &Map<String, Value>,
1419 body: Option<&Value>,
1420 ) -> Result<()> {
1421 let body_present = body.is_some();
1422 self.validate_request_with_all_ex(
1423 path,
1424 method,
1425 path_params,
1426 query_params,
1427 header_params,
1428 cookie_params,
1429 body,
1430 body_present,
1431 )
1432 }
1433
1434 #[allow(clippy::too_many_arguments)]
1446 pub fn validate_request_with_all_ex(
1447 &self,
1448 path: &str,
1449 method: &str,
1450 path_params: &Map<String, Value>,
1451 query_params: &Map<String, Value>,
1452 header_params: &Map<String, Value>,
1453 cookie_params: &Map<String, Value>,
1454 body: Option<&Value>,
1455 body_present: bool,
1456 ) -> Result<()> {
1457 for pref in &self.options.admin_skip_prefixes {
1459 if !pref.is_empty() && path.starts_with(pref) {
1460 return Ok(());
1461 }
1462 }
1463 let env_mode = std::env::var("MOCKFORGE_REQUEST_VALIDATION").ok().map(|v| {
1465 match v.to_ascii_lowercase().as_str() {
1466 "off" | "disable" | "disabled" => ValidationMode::Disabled,
1467 "warn" | "warning" => ValidationMode::Warn,
1468 _ => ValidationMode::Enforce,
1469 }
1470 });
1471 let aggregate = std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
1472 .ok()
1473 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1474 .unwrap_or(self.options.aggregate_errors);
1475 let env_overrides: Option<Map<String, Value>> =
1477 std::env::var("MOCKFORGE_VALIDATION_OVERRIDES_JSON")
1478 .ok()
1479 .and_then(|s| serde_json::from_str::<Value>(&s).ok())
1480 .and_then(|v| v.as_object().cloned());
1481 let mut effective_mode = env_mode.unwrap_or(self.options.request_mode.clone());
1483 if let Some(map) = &env_overrides {
1485 if let Some(v) = map.get(&format!("{} {}", method, path)) {
1486 if let Some(m) = v.as_str() {
1487 effective_mode = match m {
1488 "off" => ValidationMode::Disabled,
1489 "warn" => ValidationMode::Warn,
1490 _ => ValidationMode::Enforce,
1491 };
1492 }
1493 }
1494 }
1495 if let Some(override_mode) = self.options.overrides.get(&format!("{} {}", method, path)) {
1497 effective_mode = override_mode.clone();
1498 }
1499 if matches!(effective_mode, ValidationMode::Disabled) {
1500 return Ok(());
1501 }
1502 if let Some(route) = self.get_route(path, method) {
1503 if matches!(effective_mode, ValidationMode::Disabled) {
1504 return Ok(());
1505 }
1506 let mut errors: Vec<String> = Vec::new();
1507 let mut details: Vec<Value> = Vec::new();
1508 if let Some(schema) = &route.operation.request_body {
1510 let request_body = match schema {
1513 openapiv3::ReferenceOr::Item(rb) => Some(rb),
1514 openapiv3::ReferenceOr::Reference { reference } => {
1515 self.spec
1517 .spec
1518 .components
1519 .as_ref()
1520 .and_then(|components| {
1521 components.request_bodies.get(
1522 reference.trim_start_matches("#/components/requestBodies/"),
1523 )
1524 })
1525 .and_then(|rb_ref| rb_ref.as_item())
1526 }
1527 };
1528
1529 if let Some(value) = body {
1530 if let Some(rb) = request_body {
1531 if let Some(content) = rb.content.get("application/json") {
1532 if let Some(schema_ref) = &content.schema {
1533 let root_schema = match schema_ref {
1546 openapiv3::ReferenceOr::Item(s) => Some((*s).clone()),
1547 openapiv3::ReferenceOr::Reference { reference } => {
1548 self.spec.get_schema(reference).map(|s| s.schema.clone())
1549 }
1550 };
1551 if let Some(root_schema) = root_schema {
1552 let result = crate::schema_ref_resolver::build_validator(
1553 &root_schema,
1554 &self.spec.spec,
1555 )
1556 .and_then(|validator| {
1557 let errs: Vec<String> = validator
1558 .iter_errors(value)
1559 .map(|e| e.to_string())
1560 .collect();
1561 if errs.is_empty() {
1562 Ok(())
1563 } else {
1564 Err(errs.join("; "))
1565 }
1566 });
1567 if let Err(error_msg) = result {
1568 errors
1569 .push(format!("body validation failed: {}", error_msg));
1570 if aggregate {
1571 details.push(serde_json::json!({"path":"body","code":"schema_validation","message":error_msg}));
1572 }
1573 }
1574 } else if let openapiv3::ReferenceOr::Reference { reference } =
1575 schema_ref
1576 {
1577 errors.push(format!("body validation failed: could not resolve schema reference {}", reference));
1579 if aggregate {
1580 details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve schema reference"}));
1581 }
1582 }
1583 }
1584 }
1585 } else {
1586 errors.push("body validation failed: could not resolve request body or no application/json content".to_string());
1588 if aggregate {
1589 details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve request body reference"}));
1590 }
1591 }
1592 } else if body_present {
1593 tracing::debug!(
1600 "Non-JSON request body present; skipping JSON schema validation"
1601 );
1602 } else if request_body.map(|rb| rb.required).unwrap_or(false) {
1603 errors.push("body: Request body is required but not provided".to_string());
1608 details.push(serde_json::json!({"path":"body","code":"required","message":"Request body is required"}));
1609 }
1610 } else if body_present {
1611 tracing::debug!("Body provided for operation without requestBody; accepting");
1613 }
1614
1615 for p_ref in &route.operation.parameters {
1617 if let Some(p) = p_ref.as_item() {
1618 match p {
1619 openapiv3::Parameter::Path { parameter_data, .. } => {
1620 validate_parameter(
1621 parameter_data,
1622 path_params,
1623 "path",
1624 aggregate,
1625 &mut errors,
1626 &mut details,
1627 );
1628 }
1629 openapiv3::Parameter::Query {
1630 parameter_data,
1631 style,
1632 ..
1633 } => {
1634 let deep_value = if matches!(style, openapiv3::QueryStyle::DeepObject) {
1637 let prefix_bracket = format!("{}[", parameter_data.name);
1638 let mut obj = Map::new();
1639 for (key, val) in query_params.iter() {
1640 if let Some(rest) = key.strip_prefix(&prefix_bracket) {
1641 if let Some(prop) = rest.strip_suffix(']') {
1642 obj.insert(prop.to_string(), val.clone());
1643 }
1644 }
1645 }
1646 if obj.is_empty() {
1647 None
1648 } else {
1649 Some(Value::Object(obj))
1650 }
1651 } else {
1652 None
1653 };
1654 let style_str = match style {
1655 openapiv3::QueryStyle::Form => Some("form"),
1656 openapiv3::QueryStyle::SpaceDelimited => Some("spaceDelimited"),
1657 openapiv3::QueryStyle::PipeDelimited => Some("pipeDelimited"),
1658 openapiv3::QueryStyle::DeepObject => Some("deepObject"),
1659 };
1660 validate_parameter_with_deep_object(
1661 parameter_data,
1662 query_params,
1663 "query",
1664 deep_value,
1665 style_str,
1666 aggregate,
1667 &mut errors,
1668 &mut details,
1669 );
1670 }
1671 openapiv3::Parameter::Header { parameter_data, .. } => {
1672 validate_parameter(
1673 parameter_data,
1674 header_params,
1675 "header",
1676 aggregate,
1677 &mut errors,
1678 &mut details,
1679 );
1680 }
1681 openapiv3::Parameter::Cookie { parameter_data, .. } => {
1682 validate_parameter(
1683 parameter_data,
1684 cookie_params,
1685 "cookie",
1686 aggregate,
1687 &mut errors,
1688 &mut details,
1689 );
1690 }
1691 }
1692 }
1693 }
1694 if errors.is_empty() {
1695 return Ok(());
1696 }
1697 match effective_mode {
1698 ValidationMode::Disabled => Ok(()),
1699 ValidationMode::Warn => {
1700 tracing::warn!("Request validation warnings: {:?}", errors);
1701 Ok(())
1702 }
1703 ValidationMode::Enforce => Err(Error::validation(
1704 serde_json::json!({"errors": errors, "details": details}).to_string(),
1705 )),
1706 }
1707 } else {
1708 Err(Error::internal(format!("Route {} {} not found in OpenAPI spec", method, path)))
1709 }
1710 }
1711
1712 pub fn paths(&self) -> Vec<String> {
1716 let mut paths: Vec<String> = self.routes.iter().map(|route| route.path.clone()).collect();
1717 paths.sort();
1718 paths.dedup();
1719 paths
1720 }
1721
1722 pub fn methods(&self) -> Vec<String> {
1724 let mut methods: Vec<String> =
1725 self.routes.iter().map(|route| route.method.clone()).collect();
1726 methods.sort();
1727 methods.dedup();
1728 methods
1729 }
1730
1731 pub fn get_operation(&self, path: &str, method: &str) -> Option<OpenApiOperation> {
1733 self.get_route(path, method).map(|route| {
1734 OpenApiOperation::from_operation(
1735 &route.method,
1736 route.path.clone(),
1737 &route.operation,
1738 &self.spec,
1739 )
1740 })
1741 }
1742
1743 pub fn extract_path_parameters(&self, path: &str, method: &str) -> HashMap<String, String> {
1745 let mut best: Option<(usize, HashMap<String, String>)> = None;
1751 for route in &self.routes {
1752 if route.method != method {
1753 continue;
1754 }
1755
1756 if let Some(params) = self.match_path_to_route(path, &route.path) {
1757 let static_segments = route
1758 .path
1759 .trim_start_matches('/')
1760 .split('/')
1761 .filter(|s| !(s.starts_with('{') && s.ends_with('}')))
1762 .count();
1763 let is_more_specific = match &best {
1764 None => true,
1765 Some((score, _)) => static_segments > *score,
1766 };
1767 if is_more_specific {
1768 best = Some((static_segments, params));
1769 }
1770 }
1771 }
1772 best.map(|(_, params)| params).unwrap_or_default()
1773 }
1774
1775 fn match_path_to_route(
1777 &self,
1778 request_path: &str,
1779 route_pattern: &str,
1780 ) -> Option<HashMap<String, String>> {
1781 let mut params = HashMap::new();
1782
1783 let request_segments: Vec<&str> = request_path.trim_start_matches('/').split('/').collect();
1785 let pattern_segments: Vec<&str> =
1786 route_pattern.trim_start_matches('/').split('/').collect();
1787
1788 if request_segments.len() != pattern_segments.len() {
1789 return None;
1790 }
1791
1792 for (req_seg, pat_seg) in request_segments.iter().zip(pattern_segments.iter()) {
1793 if pat_seg.starts_with('{') && pat_seg.ends_with('}') {
1794 if req_seg.is_empty() {
1799 return None;
1800 }
1801 let param_name = &pat_seg[1..pat_seg.len() - 1];
1802 params.insert(param_name.to_string(), req_seg.to_string());
1803 } else if req_seg != pat_seg {
1804 return None;
1806 }
1807 }
1808
1809 Some(params)
1810 }
1811
1812 pub fn convert_path_to_axum(openapi_path: &str) -> String {
1815 openapi_path.to_string()
1817 }
1818
1819 pub fn build_router_with_ai(
1821 &self,
1822 ai_generator: Option<Arc<dyn AiGenerator + Send + Sync>>,
1823 ) -> Router {
1824 let mut router = Router::new();
1825 let deduped = self.deduplicated_routes();
1826 tracing::debug!("Building router with AI support from {} routes", self.routes.len());
1827
1828 let validator = Arc::new(self.clone_for_validation());
1832 for (axum_path, route) in &deduped {
1833 tracing::debug!("Adding AI-enabled route: {} {}", route.method, route.path);
1834
1835 let route_clone = (*route).clone();
1836 let ai_generator_clone = ai_generator.clone();
1837 let validator_clone = validator.clone();
1841
1842 let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
1844 axum::extract::Query(query_params): axum::extract::Query<
1845 HashMap<String, String>,
1846 >,
1847 headers: HeaderMap,
1848 body_bytes: axum::body::Bytes| {
1849 let route = route_clone.clone();
1850 let ai_generator = ai_generator_clone.clone();
1851 let validator = validator_clone.clone();
1852
1853 async move {
1854 let body_present = !body_bytes.is_empty();
1863 let body: Option<Json<Value>> = if body_bytes.is_empty() {
1864 None
1865 } else {
1866 serde_json::from_slice::<Value>(&body_bytes).ok().map(Json)
1867 };
1868 let mut path_map = Map::new();
1873 for (k, v) in &path_params {
1874 path_map.insert(k.clone(), Value::String(v.clone()));
1875 }
1876 let mut query_map = Map::new();
1877 for (k, v) in &query_params {
1878 query_map.insert(k.clone(), Value::String(v.clone()));
1879 }
1880 let mut header_map = Map::new();
1881 for (k, v) in headers.iter() {
1882 if let Ok(s) = v.to_str() {
1883 header_map.insert(k.to_string(), Value::String(s.to_string()));
1884 }
1885 }
1886 let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
1887 if let Err((status_code, payload)) = validator.run_validation_with_recording_ex(
1888 &route.path,
1889 &route.method,
1890 &path_map,
1891 &query_map,
1892 &header_map,
1893 &Map::new(),
1894 body_val,
1895 body_present,
1896 ) {
1897 let status = axum::http::StatusCode::from_u16(status_code)
1898 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
1899 return (status, Json(payload));
1900 }
1901
1902 tracing::debug!(
1903 "Handling AI request for route: {} {}",
1904 route.method,
1905 route.path
1906 );
1907
1908 let mut context = RequestContext::new(route.method.clone(), route.path.clone());
1910
1911 context.headers = headers
1913 .iter()
1914 .map(|(k, v)| {
1915 (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string()))
1916 })
1917 .collect();
1918
1919 context.body = body.map(|Json(b)| b);
1921
1922 let (status, response) = if let (Some(generator), Some(_ai_config)) =
1924 (ai_generator, &route.ai_config)
1925 {
1926 route
1927 .mock_response_with_status_async(&context, Some(generator.as_ref()))
1928 .await
1929 } else {
1930 route.mock_response_with_status()
1932 };
1933
1934 (
1935 axum::http::StatusCode::from_u16(status)
1936 .unwrap_or(axum::http::StatusCode::OK),
1937 Json(response),
1938 )
1939 }
1940 };
1941
1942 router = Self::route_for_method(router, axum_path, &route.method, handler);
1943 }
1944
1945 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
1949 }
1950
1951 pub fn build_router_with_mockai(
1962 &self,
1963 mockai: Option<
1964 Arc<
1965 tokio::sync::RwLock<
1966 dyn mockforge_foundation::intelligent_behavior::MockAiBehavior + Send + Sync,
1967 >,
1968 >,
1969 >,
1970 ) -> Router {
1971 use mockforge_foundation::intelligent_behavior::Request as MockAIRequest;
1972
1973 let mut router = Router::new();
1974 let deduped = self.deduplicated_routes();
1975 tracing::debug!("Building router with MockAI support from {} routes", self.routes.len());
1976
1977 let custom_loader = self.custom_fixture_loader.clone();
1978 let validator = Arc::new(self.clone_for_validation());
1982 for (axum_path, route) in &deduped {
1983 tracing::debug!("Adding MockAI-enabled route: {} {}", route.method, route.path);
1984
1985 let route_clone = (*route).clone();
1986 let mockai_clone = mockai.clone();
1987 let overrides_clone = self.overrides.clone();
1988 let custom_loader_clone = custom_loader.clone();
1989 let validator_clone = validator.clone();
1995
1996 let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
2012 query: axum::extract::Query<HashMap<String, String>>,
2013 headers: HeaderMap,
2014 body_bytes: axum::body::Bytes| {
2015 let route = route_clone.clone();
2016 let mockai = mockai_clone.clone();
2017 let overrides = overrides_clone.clone();
2018 let validator = validator_clone.clone();
2019
2020 async move {
2021 let mut path_map = Map::new();
2022 for (k, v) in &path_params {
2023 path_map.insert(k.clone(), Value::String(v.clone()));
2024 }
2025 let mut query_map = Map::new();
2026 for (k, v) in &query.0 {
2027 query_map.insert(k.clone(), Value::String(v.clone()));
2028 }
2029 let mut header_map = Map::new();
2030 for (k, v) in headers.iter() {
2031 if let Ok(s) = v.to_str() {
2032 header_map.insert(k.to_string(), Value::String(s.to_string()));
2033 }
2034 }
2035
2036 if !body_bytes.is_empty() {
2042 let actual_ct = headers
2043 .get(axum::http::header::CONTENT_TYPE)
2044 .and_then(|v| v.to_str().ok());
2045 if let Err(ct_err) = validator.check_request_content_type(
2046 &route.path,
2047 &route.method,
2048 actual_ct,
2049 ) {
2050 let status_code =
2051 validator.options.validation_status.unwrap_or_else(|| {
2052 std::env::var("MOCKFORGE_VALIDATION_STATUS")
2053 .ok()
2054 .and_then(|s| s.parse::<u16>().ok())
2055 .unwrap_or(415)
2056 });
2057 let (client_mockforge_version, client_sent_at) =
2058 mockforge_foundation::conformance_violations::read_client_stamps(
2059 |name| {
2060 headers
2061 .get(name)
2062 .and_then(|v| v.to_str().ok())
2063 .map(|s| s.to_string())
2064 },
2065 );
2066 mockforge_foundation::conformance_violations::record(
2067 mockforge_foundation::conformance_violations::ServerConformanceViolation {
2068 timestamp: Utc::now(),
2069 method: route.method.clone(),
2070 path: route.path.clone(),
2071 client_ip: "unknown".to_string(),
2072 status: status_code,
2073 reason: ct_err.clone(),
2074 category: "content-types".to_string(),
2075 occurrences: 1,
2076 client_mockforge_version,
2077 client_sent_at,
2078 summary: String::new(),
2079 categories: Vec::new(),
2080 },
2081 );
2082 let status = axum::http::StatusCode::from_u16(status_code)
2083 .unwrap_or(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
2084 return (
2085 status,
2086 Json(serde_json::json!({
2087 "error": "content_type_not_allowed",
2088 "message": ct_err,
2089 })),
2090 )
2091 .into_response();
2092 }
2093 }
2094
2095 let is_multipart_req = headers
2114 .get(axum::http::header::CONTENT_TYPE)
2115 .and_then(|v| v.to_str().ok())
2116 .map(|ct| ct.starts_with("multipart/form-data"))
2117 .unwrap_or(false);
2118 let body: Option<Json<Value>> = if body_bytes.is_empty() {
2119 None
2120 } else if is_multipart_req {
2121 match extract_multipart_from_bytes(&body_bytes, &headers).await {
2122 Ok((fields, _files)) => {
2123 let mut obj = Map::new();
2124 for (k, v) in fields {
2125 obj.insert(k, v);
2126 }
2127 if obj.is_empty() {
2128 Some(Json(Value::Object(Map::new())))
2136 } else {
2137 Some(Json(Value::Object(obj)))
2138 }
2139 }
2140 Err(e) => {
2141 tracing::warn!(
2142 "multipart parse failed for {} {}: {}",
2143 route.method,
2144 route.path,
2145 e
2146 );
2147 Some(Json(Value::Object(Map::new())))
2148 }
2149 }
2150 } else {
2151 serde_json::from_slice::<Value>(&body_bytes).ok().map(Json)
2152 };
2153
2154 let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
2159 if let Err((status_code, payload)) = validator.run_validation_with_recording_ex(
2161 &route.path,
2162 &route.method,
2163 &path_map,
2164 &query_map,
2165 &header_map,
2166 &Map::new(),
2167 body_val,
2168 !body_bytes.is_empty(),
2169 ) {
2170 let status = axum::http::StatusCode::from_u16(status_code)
2171 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
2172 return (status, Json(payload)).into_response();
2173 }
2174
2175 tracing::debug!(
2176 "[FIXTURE DEBUG] Starting fixture check for {} {} (custom_loader available: {})",
2177 route.method,
2178 route.path,
2179 custom_loader_clone.is_some()
2180 );
2181
2182 if let Some(ref loader) = custom_loader_clone {
2184 use crate::request_fingerprint::RequestFingerprint;
2185 use axum::http::{Method, Uri};
2186
2187 let query_string = if query.0.is_empty() {
2189 String::new()
2190 } else {
2191 query
2192 .0
2193 .iter()
2194 .map(|(k, v)| format!("{}={}", k, v))
2195 .collect::<Vec<_>>()
2196 .join("&")
2197 };
2198
2199 let normalized_request_path =
2201 crate::custom_fixture::CustomFixtureLoader::normalize_path(&route.path);
2202
2203 tracing::debug!(
2204 "[FIXTURE DEBUG] Path normalization: original='{}', normalized='{}'",
2205 route.path,
2206 normalized_request_path
2207 );
2208
2209 let uri_str = if query_string.is_empty() {
2211 normalized_request_path.clone()
2212 } else {
2213 format!("{}?{}", normalized_request_path, query_string)
2214 };
2215
2216 tracing::debug!(
2217 "[FIXTURE DEBUG] URI construction: uri_str='{}', query_string='{}'",
2218 uri_str,
2219 query_string
2220 );
2221
2222 if let Ok(uri) = uri_str.parse::<Uri>() {
2223 let http_method =
2224 Method::from_bytes(route.method.as_bytes()).unwrap_or(Method::GET);
2225
2226 let body_bytes =
2228 body.as_ref().and_then(|Json(b)| serde_json::to_vec(b).ok());
2229 let body_slice = body_bytes.as_deref();
2230
2231 let fingerprint =
2232 RequestFingerprint::new(http_method, &uri, &headers, body_slice);
2233
2234 tracing::debug!(
2235 "[FIXTURE DEBUG] RequestFingerprint created: method='{}', path='{}', query='{}', body_hash={:?}",
2236 fingerprint.method,
2237 fingerprint.path,
2238 fingerprint.query,
2239 fingerprint.body_hash
2240 );
2241
2242 let available_fixtures = loader.has_fixture(&fingerprint);
2244 tracing::debug!(
2245 "[FIXTURE DEBUG] Fixture check result: has_fixture={}",
2246 available_fixtures
2247 );
2248
2249 if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
2250 tracing::debug!(
2251 "[FIXTURE DEBUG] ✅ FIXTURE MATCHED! Using custom fixture for {} {} (status: {}, path: '{}')",
2252 route.method,
2253 route.path,
2254 custom_fixture.status,
2255 custom_fixture.path
2256 );
2257
2258 if custom_fixture.delay_ms > 0 {
2260 tokio::time::sleep(tokio::time::Duration::from_millis(
2261 custom_fixture.delay_ms,
2262 ))
2263 .await;
2264 }
2265
2266 let response_body = if custom_fixture.response.is_string() {
2268 custom_fixture.response.as_str().unwrap().to_string()
2269 } else {
2270 serde_json::to_string(&custom_fixture.response)
2271 .unwrap_or_else(|_| "{}".to_string())
2272 };
2273
2274 let json_value: Value = serde_json::from_str(&response_body)
2276 .unwrap_or_else(|_| serde_json::json!({}));
2277
2278 let status =
2280 axum::http::StatusCode::from_u16(custom_fixture.status)
2281 .unwrap_or(axum::http::StatusCode::OK);
2282
2283 return (status, Json(json_value)).into_response();
2285 } else {
2286 tracing::debug!(
2287 "[FIXTURE DEBUG] ❌ No fixture match found for {} {} (fingerprint.path='{}', normalized='{}')",
2288 route.method,
2289 route.path,
2290 fingerprint.path,
2291 normalized_request_path
2292 );
2293 }
2294 } else {
2295 tracing::warn!("[FIXTURE DEBUG] Failed to parse URI: '{}'", uri_str);
2296 }
2297 } else {
2298 tracing::debug!(
2299 "[FIXTURE DEBUG] Custom fixture loader not available for {} {}",
2300 route.method,
2301 route.path
2302 );
2303 }
2304
2305 tracing::debug!(
2306 "Handling MockAI request for route: {} {}",
2307 route.method,
2308 route.path
2309 );
2310
2311 let mockai_query = query.0;
2313
2314 let method_upper = route.method.to_uppercase();
2319 let should_use_mockai =
2320 matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH" | "DELETE");
2321
2322 if should_use_mockai {
2323 if let Some(mockai_arc) = mockai {
2324 let mockai_guard = mockai_arc.read().await;
2325
2326 let mut mockai_headers = HashMap::new();
2328 for (k, v) in headers.iter() {
2329 mockai_headers
2330 .insert(k.to_string(), v.to_str().unwrap_or("").to_string());
2331 }
2332
2333 let mockai_request = MockAIRequest {
2334 method: route.method.clone(),
2335 path: route.path.clone(),
2336 body: body.as_ref().map(|Json(b)| b.clone()),
2337 query_params: mockai_query.clone(),
2338 headers: mockai_headers,
2339 };
2340
2341 match mockai_guard.process_request(&mockai_request).await {
2343 Ok(mockai_response) => {
2344 let is_empty = mockai_response.body.is_object()
2346 && mockai_response
2347 .body
2348 .as_object()
2349 .map(|obj| obj.is_empty())
2350 .unwrap_or(false);
2351
2352 if is_empty {
2353 tracing::debug!(
2354 "MockAI returned empty object for {} {}, falling back to OpenAPI response generation",
2355 route.method,
2356 route.path
2357 );
2358 } else {
2360 let spec_status = route.find_first_available_status_code();
2364 tracing::debug!(
2365 "MockAI generated response for {} {}, using spec status: {} (MockAI suggested: {})",
2366 route.method,
2367 route.path,
2368 spec_status,
2369 mockai_response.status_code
2370 );
2371 let status = axum::http::StatusCode::from_u16(spec_status)
2372 .unwrap_or(axum::http::StatusCode::OK);
2373 let mut mockai_body = mockai_response.body;
2374 apply_route_overrides(
2375 overrides.as_deref(),
2376 &route,
2377 &headers,
2378 &mockai_query,
2379 &body_bytes,
2380 &mut mockai_body,
2381 );
2382 let mut resp = (status, Json(mockai_body)).into_response();
2383 inject_spec_response_headers(
2384 &mut resp,
2385 &route,
2386 spec_status,
2387 );
2388 return resp;
2389 }
2390 }
2391 Err(e) => {
2392 tracing::warn!(
2393 "MockAI processing failed for {} {}: {}, falling back to standard response",
2394 route.method,
2395 route.path,
2396 e
2397 );
2398 }
2400 }
2401 }
2402 } else {
2403 tracing::debug!(
2404 "Skipping MockAI for {} request {} - using OpenAPI response generation",
2405 method_upper,
2406 route.path
2407 );
2408 }
2409
2410 let status_override = headers
2412 .get("X-Mockforge-Response-Status")
2413 .and_then(|v| v.to_str().ok())
2414 .and_then(|s| s.parse::<u16>().ok());
2415
2416 let scenario = headers
2418 .get("X-Mockforge-Scenario")
2419 .and_then(|v| v.to_str().ok())
2420 .map(|s| s.to_string())
2421 .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
2422
2423 let (status, mut response) = route
2425 .mock_response_with_status_and_scenario_and_override(
2426 scenario.as_deref(),
2427 status_override,
2428 );
2429 apply_route_overrides(
2430 overrides.as_deref(),
2431 &route,
2432 &headers,
2433 &mockai_query,
2434 &body_bytes,
2435 &mut response,
2436 );
2437 let status_code = axum::http::StatusCode::from_u16(status)
2438 .unwrap_or(axum::http::StatusCode::OK);
2439 let mut resp = (status_code, Json(response)).into_response();
2440 inject_spec_response_headers(&mut resp, &route, status);
2441 resp
2442 }
2443 };
2444
2445 router = Self::route_for_method(router, axum_path, &route.method, handler);
2446 }
2447
2448 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
2451 }
2452}
2453
2454fn apply_route_overrides(
2469 overrides: Option<&dyn OverrideHook>,
2470 route: &OpenApiRoute,
2471 headers: &HeaderMap,
2472 query: &HashMap<String, String>,
2473 request_body: &[u8],
2474 body: &mut Value,
2475) {
2476 let Some(overrides) = overrides else {
2477 return;
2478 };
2479 let raw_query = (!query.is_empty()).then(|| {
2480 url::form_urlencoded::Serializer::new(String::new())
2481 .extend_pairs(query.iter())
2482 .finish()
2483 });
2484 let mut tags = route.operation.tags.clone();
2485 tags.extend(route.operation.operation_id.clone());
2486 let request = OverrideRequest {
2487 method: &route.method,
2488 path_template: &route.path,
2489 operation_id: route.operation.operation_id.as_deref().unwrap_or_default(),
2490 tags: &tags,
2491 headers,
2492 query: raw_query.as_deref(),
2493 body: request_body,
2494 };
2495 overrides.apply(&request, body);
2496}
2497
2498fn inject_spec_response_headers(
2499 response: &mut axum::response::Response,
2500 route: &OpenApiRoute,
2501 status_code: u16,
2502) {
2503 let synthesized = route.mock_response_headers_for_status(status_code);
2504 if synthesized.is_empty() {
2505 return;
2506 }
2507 let response_headers = response.headers_mut();
2508 for (name, value) in synthesized {
2509 let Ok(header_name) = axum::http::HeaderName::from_bytes(name.as_bytes()) else {
2510 continue;
2511 };
2512 if response_headers.contains_key(&header_name) {
2513 continue;
2514 }
2515 let Ok(header_value) = axum::http::HeaderValue::from_str(&value) else {
2516 continue;
2517 };
2518 response_headers.insert(header_name, header_value);
2519 }
2520}
2521
2522async fn extract_multipart_from_bytes(
2527 body: &axum::body::Bytes,
2528 headers: &HeaderMap,
2529) -> Result<(HashMap<String, Value>, HashMap<String, String>)> {
2530 let boundary = headers
2532 .get(axum::http::header::CONTENT_TYPE)
2533 .and_then(|v| v.to_str().ok())
2534 .and_then(|ct| {
2535 ct.split(';').find_map(|part| {
2536 let part = part.trim();
2537 if part.starts_with("boundary=") {
2538 Some(part.strip_prefix("boundary=").unwrap_or("").trim_matches('"'))
2539 } else {
2540 None
2541 }
2542 })
2543 })
2544 .ok_or_else(|| Error::internal("Missing boundary in Content-Type header"))?;
2545
2546 let mut fields = HashMap::new();
2547 let mut files = HashMap::new();
2548
2549 let boundary_prefix = format!("--{}", boundary).into_bytes();
2552 let boundary_line = format!("\r\n--{}\r\n", boundary).into_bytes();
2553 let end_boundary = format!("\r\n--{}--\r\n", boundary).into_bytes();
2554
2555 let mut pos = 0;
2557 let mut parts = Vec::new();
2558
2559 if body.starts_with(&boundary_prefix) {
2561 if let Some(first_crlf) = body.iter().position(|&b| b == b'\r') {
2562 pos = first_crlf + 2; }
2564 }
2565
2566 while let Some(boundary_pos) = body[pos..]
2568 .windows(boundary_line.len())
2569 .position(|window| window == boundary_line.as_slice())
2570 {
2571 let actual_pos = pos + boundary_pos;
2572 if actual_pos > pos {
2573 parts.push((pos, actual_pos));
2574 }
2575 pos = actual_pos + boundary_line.len();
2576 }
2577
2578 if let Some(end_pos) = body[pos..]
2580 .windows(end_boundary.len())
2581 .position(|window| window == end_boundary.as_slice())
2582 {
2583 let actual_end = pos + end_pos;
2584 if actual_end > pos {
2585 parts.push((pos, actual_end));
2586 }
2587 } else if pos < body.len() {
2588 parts.push((pos, body.len()));
2590 }
2591
2592 for (start, end) in parts {
2594 let part_data = &body[start..end];
2595
2596 let separator = b"\r\n\r\n";
2598 if let Some(sep_pos) =
2599 part_data.windows(separator.len()).position(|window| window == separator)
2600 {
2601 let header_bytes = &part_data[..sep_pos];
2602 let body_start = sep_pos + separator.len();
2603 let body_data = &part_data[body_start..];
2604
2605 let header_str = String::from_utf8_lossy(header_bytes);
2607 let mut field_name = None;
2608 let mut filename = None;
2609
2610 for header_line in header_str.lines() {
2611 if header_line.starts_with("Content-Disposition:") {
2612 if let Some(name_start) = header_line.find("name=\"") {
2614 let name_start = name_start + 6;
2615 if let Some(name_end) = header_line[name_start..].find('"') {
2616 field_name =
2617 Some(header_line[name_start..name_start + name_end].to_string());
2618 }
2619 }
2620
2621 if let Some(file_start) = header_line.find("filename=\"") {
2623 let file_start = file_start + 10;
2624 if let Some(file_end) = header_line[file_start..].find('"') {
2625 filename =
2626 Some(header_line[file_start..file_start + file_end].to_string());
2627 }
2628 }
2629 }
2630 }
2631
2632 if let Some(name) = field_name {
2633 if let Some(file) = filename {
2634 let temp_dir = std::env::temp_dir().join("mockforge-uploads");
2636 std::fs::create_dir_all(&temp_dir)
2637 .map_err(|e| Error::io_with_context("temp directory", e.to_string()))?;
2638
2639 let file_path = temp_dir.join(format!("{}_{}", uuid::Uuid::new_v4(), file));
2640 std::fs::write(&file_path, body_data)
2641 .map_err(|e| Error::io_with_context("file", e.to_string()))?;
2642
2643 let file_path_str = file_path.to_string_lossy().to_string();
2644 files.insert(name.clone(), file_path_str.clone());
2645 fields.insert(name, Value::String(file_path_str));
2646 } else {
2647 let body_str = body_data
2650 .strip_suffix(b"\r\n")
2651 .or_else(|| body_data.strip_suffix(b"\n"))
2652 .unwrap_or(body_data);
2653
2654 if let Ok(field_value) = String::from_utf8(body_str.to_vec()) {
2655 fields.insert(name, Value::String(field_value.trim().to_string()));
2656 } else {
2657 use base64::{engine::general_purpose, Engine as _};
2659 fields.insert(
2660 name,
2661 Value::String(general_purpose::STANDARD.encode(body_str)),
2662 );
2663 }
2664 }
2665 }
2666 }
2667 }
2668
2669 Ok((fields, files))
2670}
2671
2672static LAST_ERRORS: Lazy<Mutex<VecDeque<Value>>> =
2673 Lazy::new(|| Mutex::new(VecDeque::with_capacity(20)));
2674
2675fn split_detail_entry(detail: &Value) -> Option<(String, String)> {
2687 let obj = detail.as_object()?;
2688 let path = obj.get("path")?.as_str()?;
2689 let message = obj.get("message").and_then(|m| m.as_str()).unwrap_or("validation failed");
2690 let (loc, name) = match path.split_once('.') {
2694 Some(("query", rest)) => ("query", rest),
2695 Some(("header", rest)) => ("headers", rest),
2696 Some(("cookie", rest)) => ("cookies", rest),
2697 Some(("path", rest)) => ("parameters", rest),
2698 Some(("body", rest)) => ("request-body", rest),
2699 _ => match path {
2700 "query" => ("query", ""),
2701 "header" | "headers" => ("headers", ""),
2702 "cookie" | "cookies" => ("cookies", ""),
2703 "path" => ("parameters", ""),
2704 "body" | "request-body" => ("request-body", ""),
2705 _ => return None,
2706 },
2707 };
2708 let reason = if name.is_empty() {
2709 message.to_string()
2710 } else {
2711 format!("{name}: {message}")
2712 };
2713 Some((loc.to_string(), reason))
2714}
2715
2716fn validation_details(payload: &Value) -> Vec<Value> {
2721 let Some(detail) = payload.get("detail") else {
2722 return Vec::new();
2723 };
2724 let parsed = match detail {
2725 Value::String(s) => match (s.find('{'), s.rfind('}')) {
2728 (Some(start), Some(end)) if end > start => {
2729 serde_json::from_str::<Value>(&s[start..=end]).ok()
2730 }
2731 _ => None,
2732 },
2733 v => Some(v.clone()),
2734 };
2735 let Some(parsed) = parsed else {
2736 return Vec::new();
2737 };
2738 let Some(details) = parsed.get("details").and_then(|d| d.as_array()) else {
2739 return Vec::new();
2740 };
2741 details
2742 .iter()
2743 .filter(|d| d.get("path").and_then(|p| p.as_str()).is_some())
2744 .cloned()
2745 .collect()
2746}
2747
2748pub fn classify_validation_reason(reason: &str) -> String {
2749 let r = reason.to_ascii_lowercase();
2758
2759 if r.contains("method") && (r.contains("not allowed") || r.contains("unsupported")) {
2772 return "http-methods".into();
2773 }
2774
2775 let path_starts_with = |prefix: &str| r.contains(&format!("\"path\":\"{}", prefix));
2777 if path_starts_with("query.") {
2778 return "query".into();
2779 }
2780 if path_starts_with("header.") {
2781 return "headers".into();
2782 }
2783 if path_starts_with("cookie.") {
2784 return "cookies".into();
2785 }
2786 if path_starts_with("path.") {
2787 return "parameters".into();
2788 }
2789 if path_starts_with("body") {
2790 return "request-body".into();
2791 }
2792
2793 if r.contains("content-type") || r.contains("content type") {
2796 return "content-types".into();
2797 }
2798
2799 if r.contains("required")
2803 && (r.contains("param") || r.contains("query") || r.contains("header"))
2804 {
2805 return "parameters".into();
2806 }
2807 if r.contains("auth") || r.contains("security") {
2808 return "security".into();
2809 }
2810 if r.contains("method") {
2811 return "http-methods".into();
2812 }
2813 if r.contains("schema") || r.contains("body") || r.contains("json") {
2814 return "request-body".into();
2815 }
2816 if r.contains("enum") || r.contains("min") || r.contains("max") || r.contains("pattern") {
2817 return "constraints".into();
2818 }
2819 String::new()
2820}
2821
2822pub fn record_validation_error(v: &Value) {
2824 if let Ok(mut q) = LAST_ERRORS.lock() {
2825 if q.len() >= 20 {
2826 q.pop_front();
2827 }
2828 q.push_back(v.clone());
2829 }
2830 }
2832
2833pub fn get_last_validation_error() -> Option<Value> {
2835 LAST_ERRORS.lock().ok()?.back().cloned()
2836}
2837
2838pub fn get_validation_errors() -> Vec<Value> {
2840 LAST_ERRORS.lock().map(|q| q.iter().cloned().collect()).unwrap_or_default()
2841}
2842
2843fn coerce_value_for_schema(value: &Value, schema: &openapiv3::Schema) -> Value {
2848 match value {
2850 Value::String(s) => {
2851 if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2853 &schema.schema_kind
2854 {
2855 if s.contains(',') {
2856 let parts: Vec<&str> = s.split(',').map(|s| s.trim()).collect();
2858 let mut array_values = Vec::new();
2859
2860 for part in parts {
2861 if let Some(items_schema) = &array_type.items {
2863 if let Some(items_schema_obj) = items_schema.as_item() {
2864 let part_value = Value::String(part.to_string());
2865 let coerced_part =
2866 coerce_value_for_schema(&part_value, items_schema_obj);
2867 array_values.push(coerced_part);
2868 } else {
2869 array_values.push(Value::String(part.to_string()));
2871 }
2872 } else {
2873 array_values.push(Value::String(part.to_string()));
2875 }
2876 }
2877 return Value::Array(array_values);
2878 }
2879 }
2880
2881 match &schema.schema_kind {
2883 openapiv3::SchemaKind::Type(openapiv3::Type::String(_)) => {
2884 value.clone()
2886 }
2887 openapiv3::SchemaKind::Type(openapiv3::Type::Number(_)) => {
2888 if let Ok(n) = s.parse::<f64>() {
2890 if let Some(num) = serde_json::Number::from_f64(n) {
2891 return Value::Number(num);
2892 }
2893 }
2894 value.clone()
2895 }
2896 openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_)) => {
2897 if let Ok(n) = s.parse::<i64>() {
2899 if let Some(num) = serde_json::Number::from_f64(n as f64) {
2900 return Value::Number(num);
2901 }
2902 }
2903 value.clone()
2904 }
2905 openapiv3::SchemaKind::Type(openapiv3::Type::Boolean(_)) => {
2906 match s.to_lowercase().as_str() {
2908 "true" | "1" | "yes" | "on" => Value::Bool(true),
2909 "false" | "0" | "no" | "off" => Value::Bool(false),
2910 _ => value.clone(),
2911 }
2912 }
2913 _ => {
2914 value.clone()
2916 }
2917 }
2918 }
2919 _ => value.clone(),
2920 }
2921}
2922
2923fn coerce_by_style(value: &Value, schema: &openapiv3::Schema, style: Option<&str>) -> Value {
2925 match value {
2927 Value::String(s) => {
2928 if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2930 &schema.schema_kind
2931 {
2932 let delimiter = match style {
2933 Some("spaceDelimited") => " ",
2934 Some("pipeDelimited") => "|",
2935 Some("form") | None => ",", _ => ",", };
2938
2939 if s.contains(delimiter) {
2940 let parts: Vec<&str> = s.split(delimiter).map(|s| s.trim()).collect();
2942 let mut array_values = Vec::new();
2943
2944 for part in parts {
2945 if let Some(items_schema) = &array_type.items {
2947 if let Some(items_schema_obj) = items_schema.as_item() {
2948 let part_value = Value::String(part.to_string());
2949 let coerced_part =
2950 coerce_by_style(&part_value, items_schema_obj, style);
2951 array_values.push(coerced_part);
2952 } else {
2953 array_values.push(Value::String(part.to_string()));
2955 }
2956 } else {
2957 array_values.push(Value::String(part.to_string()));
2959 }
2960 }
2961 return Value::Array(array_values);
2962 }
2963 }
2964
2965 if let Ok(n) = s.parse::<f64>() {
2967 if let Some(num) = serde_json::Number::from_f64(n) {
2968 return Value::Number(num);
2969 }
2970 }
2971 match s.to_lowercase().as_str() {
2973 "true" | "1" | "yes" | "on" => return Value::Bool(true),
2974 "false" | "0" | "no" | "off" => return Value::Bool(false),
2975 _ => {}
2976 }
2977 value.clone()
2979 }
2980 _ => value.clone(),
2981 }
2982}
2983
2984fn build_deep_object(name: &str, params: &Map<String, Value>) -> Option<Value> {
2986 let prefix = format!("{}[", name);
2987 let mut obj = Map::new();
2988 for (k, v) in params.iter() {
2989 if let Some(rest) = k.strip_prefix(&prefix) {
2990 if let Some(key) = rest.strip_suffix(']') {
2991 obj.insert(key.to_string(), v.clone());
2992 }
2993 }
2994 }
2995 if obj.is_empty() {
2996 None
2997 } else {
2998 Some(Value::Object(obj))
2999 }
3000}
3001
3002#[allow(clippy::too_many_arguments)]
3008fn generate_enhanced_422_response(
3009 validator: &OpenApiRouteRegistry,
3010 path_template: &str,
3011 method: &str,
3012 body: Option<&Value>,
3013 path_params: &Map<String, Value>,
3014 query_params: &Map<String, Value>,
3015 header_params: &Map<String, Value>,
3016 cookie_params: &Map<String, Value>,
3017) -> Value {
3018 let mut field_errors = Vec::new();
3019
3020 if let Some(route) = validator.get_route(path_template, method) {
3022 if let Some(schema) = &route.operation.request_body {
3024 if let Some(value) = body {
3025 if let Some(content) =
3026 schema.as_item().and_then(|rb| rb.content.get("application/json"))
3027 {
3028 if let Some(_schema_ref) = &content.schema {
3029 if serde_json::from_value::<Value>(value.clone()).is_err() {
3031 field_errors.push(json!({
3032 "path": "body",
3033 "message": "invalid JSON"
3034 }));
3035 }
3036 }
3037 }
3038 } else {
3039 field_errors.push(json!({
3040 "path": "body",
3041 "expected": "object",
3042 "found": "missing",
3043 "message": "Request body is required but not provided"
3044 }));
3045 }
3046 }
3047
3048 for param_ref in &route.operation.parameters {
3050 if let Some(param) = param_ref.as_item() {
3051 match param {
3052 openapiv3::Parameter::Path { parameter_data, .. } => {
3053 validate_parameter_detailed(
3054 parameter_data,
3055 path_params,
3056 "path",
3057 "path parameter",
3058 &mut field_errors,
3059 );
3060 }
3061 openapiv3::Parameter::Query { parameter_data, .. } => {
3062 let deep_value = if Some("form") == Some("deepObject") {
3063 build_deep_object(¶meter_data.name, query_params)
3064 } else {
3065 None
3066 };
3067 validate_parameter_detailed_with_deep(
3068 parameter_data,
3069 query_params,
3070 "query",
3071 "query parameter",
3072 deep_value,
3073 &mut field_errors,
3074 );
3075 }
3076 openapiv3::Parameter::Header { parameter_data, .. } => {
3077 validate_parameter_detailed(
3078 parameter_data,
3079 header_params,
3080 "header",
3081 "header parameter",
3082 &mut field_errors,
3083 );
3084 }
3085 openapiv3::Parameter::Cookie { parameter_data, .. } => {
3086 validate_parameter_detailed(
3087 parameter_data,
3088 cookie_params,
3089 "cookie",
3090 "cookie parameter",
3091 &mut field_errors,
3092 );
3093 }
3094 }
3095 }
3096 }
3097 }
3098
3099 json!({
3101 "error": "Schema validation failed",
3102 "details": field_errors,
3103 "method": method,
3104 "path": path_template,
3105 "timestamp": Utc::now().to_rfc3339(),
3106 "validation_type": "openapi_schema"
3107 })
3108}
3109
3110fn validate_parameter(
3112 parameter_data: &openapiv3::ParameterData,
3113 params_map: &Map<String, Value>,
3114 prefix: &str,
3115 aggregate: bool,
3116 errors: &mut Vec<String>,
3117 details: &mut Vec<Value>,
3118) {
3119 match params_map.get(¶meter_data.name) {
3120 Some(v) => {
3121 if let ParameterSchemaOrContent::Schema(s) = ¶meter_data.format {
3122 if let Some(schema) = s.as_item() {
3123 let coerced = coerce_value_for_schema(v, schema);
3124 if let Err(validation_error) =
3126 OpenApiSchema::new(schema.clone()).validate(&coerced)
3127 {
3128 let error_msg = validation_error.to_string();
3129 errors.push(format!(
3130 "{} parameter '{}' validation failed: {}",
3131 prefix, parameter_data.name, error_msg
3132 ));
3133 if aggregate {
3134 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
3135 }
3136 }
3137 }
3138 }
3139 }
3140 None => {
3141 if parameter_data.required {
3142 errors.push(format!(
3143 "missing required {} parameter '{}'",
3144 prefix, parameter_data.name
3145 ));
3146 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
3147 }
3148 }
3149 }
3150}
3151
3152#[allow(clippy::too_many_arguments)]
3154fn validate_parameter_with_deep_object(
3155 parameter_data: &openapiv3::ParameterData,
3156 params_map: &Map<String, Value>,
3157 prefix: &str,
3158 deep_value: Option<Value>,
3159 style: Option<&str>,
3160 aggregate: bool,
3161 errors: &mut Vec<String>,
3162 details: &mut Vec<Value>,
3163) {
3164 match deep_value.as_ref().or_else(|| params_map.get(¶meter_data.name)) {
3165 Some(v) => {
3166 if let ParameterSchemaOrContent::Schema(s) = ¶meter_data.format {
3167 if let Some(schema) = s.as_item() {
3168 let coerced = coerce_by_style(v, schema, style); if let Err(validation_error) =
3171 OpenApiSchema::new(schema.clone()).validate(&coerced)
3172 {
3173 let error_msg = validation_error.to_string();
3174 errors.push(format!(
3175 "{} parameter '{}' validation failed: {}",
3176 prefix, parameter_data.name, error_msg
3177 ));
3178 if aggregate {
3179 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
3180 }
3181 }
3182 }
3183 }
3184 }
3185 None => {
3186 if parameter_data.required {
3187 errors.push(format!(
3188 "missing required {} parameter '{}'",
3189 prefix, parameter_data.name
3190 ));
3191 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
3192 }
3193 }
3194 }
3195}
3196
3197fn validate_parameter_detailed(
3199 parameter_data: &openapiv3::ParameterData,
3200 params_map: &Map<String, Value>,
3201 location: &str,
3202 value_type: &str,
3203 field_errors: &mut Vec<Value>,
3204) {
3205 match params_map.get(¶meter_data.name) {
3206 Some(value) => {
3207 if let ParameterSchemaOrContent::Schema(schema) = ¶meter_data.format {
3208 let details: Vec<Value> = Vec::new();
3210 let param_path = format!("{}.{}", location, parameter_data.name);
3211
3212 if let Some(schema_ref) = schema.as_item() {
3214 let coerced_value = coerce_value_for_schema(value, schema_ref);
3215 if let Err(validation_error) =
3217 OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
3218 {
3219 field_errors.push(json!({
3220 "path": param_path,
3221 "expected": "valid according to schema",
3222 "found": coerced_value,
3223 "message": validation_error.to_string()
3224 }));
3225 }
3226 }
3227
3228 for detail in details {
3229 field_errors.push(json!({
3230 "path": detail["path"],
3231 "expected": detail["expected_type"],
3232 "found": detail["value"],
3233 "message": detail["message"]
3234 }));
3235 }
3236 }
3237 }
3238 None => {
3239 if parameter_data.required {
3240 field_errors.push(json!({
3241 "path": format!("{}.{}", location, parameter_data.name),
3242 "expected": "value",
3243 "found": "missing",
3244 "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
3245 }));
3246 }
3247 }
3248 }
3249}
3250
3251fn validate_parameter_detailed_with_deep(
3253 parameter_data: &openapiv3::ParameterData,
3254 params_map: &Map<String, Value>,
3255 location: &str,
3256 value_type: &str,
3257 deep_value: Option<Value>,
3258 field_errors: &mut Vec<Value>,
3259) {
3260 match deep_value.as_ref().or_else(|| params_map.get(¶meter_data.name)) {
3261 Some(value) => {
3262 if let ParameterSchemaOrContent::Schema(schema) = ¶meter_data.format {
3263 let details: Vec<Value> = Vec::new();
3265 let param_path = format!("{}.{}", location, parameter_data.name);
3266
3267 if let Some(schema_ref) = schema.as_item() {
3269 let coerced_value = coerce_by_style(value, schema_ref, Some("form")); if let Err(validation_error) =
3272 OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
3273 {
3274 field_errors.push(json!({
3275 "path": param_path,
3276 "expected": "valid according to schema",
3277 "found": coerced_value,
3278 "message": validation_error.to_string()
3279 }));
3280 }
3281 }
3282
3283 for detail in details {
3284 field_errors.push(json!({
3285 "path": detail["path"],
3286 "expected": detail["expected_type"],
3287 "found": detail["value"],
3288 "message": detail["message"]
3289 }));
3290 }
3291 }
3292 }
3293 None => {
3294 if parameter_data.required {
3295 field_errors.push(json!({
3296 "path": format!("{}.{}", location, parameter_data.name),
3297 "expected": "value",
3298 "found": "missing",
3299 "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
3300 }));
3301 }
3302 }
3303 }
3304}
3305
3306pub async fn create_registry_from_file<P: AsRef<std::path::Path>>(
3308 path: P,
3309) -> Result<OpenApiRouteRegistry> {
3310 let spec = OpenApiSpec::from_file(path).await?;
3311 spec.validate()?;
3312 Ok(OpenApiRouteRegistry::new(spec))
3313}
3314
3315pub fn create_registry_from_json(json: Value) -> Result<OpenApiRouteRegistry> {
3317 let spec = OpenApiSpec::from_json(json)?;
3318 spec.validate()?;
3319 Ok(OpenApiRouteRegistry::new(spec))
3320}
3321
3322#[cfg(test)]
3323mod tests {
3324 use super::*;
3325 use serde_json::json;
3326 use tempfile::TempDir;
3327
3328 #[test]
3337 fn classify_validation_reason_uses_structured_path_field_first() {
3338 let query_only = r#"{"details":[{"code":"schema_validation","message":"Validation error","path":"query.$.xgafv"}]}"#;
3341 assert_eq!(classify_validation_reason(query_only), "query");
3342
3343 let header_only = r#"{"details":[{"code":"schema_validation","message":"missing required X-Trace","path":"header.X-Trace"}]}"#;
3344 assert_eq!(classify_validation_reason(header_only), "headers");
3345
3346 let cookie_only = r#"{"details":[{"code":"schema_validation","message":"missing session","path":"cookie.session"}]}"#;
3347 assert_eq!(classify_validation_reason(cookie_only), "cookies");
3348
3349 let body_only = r#"{"details":[{"code":"schema_validation","message":"name required","path":"body.name"}]}"#;
3351 assert_eq!(classify_validation_reason(body_only), "request-body");
3352
3353 assert_eq!(
3355 classify_validation_reason("Content-Type application/xml not allowed"),
3356 "content-types"
3357 );
3358 }
3359
3360 #[test]
3365 fn per_location_violation_split_records_one_entry_per_location() {
3366 mockforge_foundation::conformance_violations::clear();
3367
3368 let spec_json = json!({
3369 "openapi": "3.0.0",
3370 "info": { "title": "T", "version": "1" },
3371 "paths": {
3372 "/v1/things": {
3373 "post": {
3374 "summary": "Create thing",
3375 "parameters": [
3376 {
3377 "name": "kind",
3378 "in": "query",
3379 "required": true,
3380 "schema": { "type": "string", "enum": ["a", "b"] }
3381 }
3382 ],
3383 "requestBody": {
3384 "required": true,
3385 "content": {
3386 "application/json": {
3387 "schema": {
3388 "type": "object",
3389 "required": ["email"],
3390 "properties": { "email": { "type": "string" } }
3391 }
3392 }
3393 }
3394 },
3395 "responses": { "200": { "description": "ok" } }
3396 }
3397 }
3398 }
3399 });
3400 let spec = OpenApiSpec::from_json(spec_json).expect("spec parses");
3401 let router = OpenApiRouteRegistry::new(spec);
3402
3403 let result = router.run_validation_with_recording_ex(
3405 "/v1/things",
3406 "POST",
3407 &Map::new(),
3408 &[("kind".to_string(), json!("bogus"))].into_iter().collect(),
3409 &Map::new(),
3410 &Map::new(),
3411 Some(&json!({})),
3412 true,
3413 );
3414 assert!(result.is_err(), "request must fail validation");
3415
3416 let buffer = mockforge_foundation::conformance_violations::snapshot();
3417 let mine: Vec<_> =
3418 buffer.iter().filter(|v| v.path == "/v1/things" && v.method == "POST").collect();
3419 assert!(
3420 mine.iter().any(|v| v.category == "query" && v.reason.contains("kind")),
3421 "expected a query entry naming 'kind', got {:?}",
3422 mine.iter().map(|v| (&v.category, &v.reason)).collect::<Vec<_>>()
3423 );
3424 assert!(
3425 mine.iter().any(|v| v.category == "request-body"),
3426 "expected a separate request-body entry, got {:?}",
3427 mine.iter().map(|v| (&v.category, &v.reason)).collect::<Vec<_>>()
3428 );
3429 }
3430
3431 #[tokio::test]
3432 async fn test_registry_creation() {
3433 let spec_json = json!({
3434 "openapi": "3.0.0",
3435 "info": {
3436 "title": "Test API",
3437 "version": "1.0.0"
3438 },
3439 "paths": {
3440 "/users": {
3441 "get": {
3442 "summary": "Get users",
3443 "responses": {
3444 "200": {
3445 "description": "Success",
3446 "content": {
3447 "application/json": {
3448 "schema": {
3449 "type": "array",
3450 "items": {
3451 "type": "object",
3452 "properties": {
3453 "id": {"type": "integer"},
3454 "name": {"type": "string"}
3455 }
3456 }
3457 }
3458 }
3459 }
3460 }
3461 }
3462 },
3463 "post": {
3464 "summary": "Create user",
3465 "requestBody": {
3466 "content": {
3467 "application/json": {
3468 "schema": {
3469 "type": "object",
3470 "properties": {
3471 "name": {"type": "string"}
3472 },
3473 "required": ["name"]
3474 }
3475 }
3476 }
3477 },
3478 "responses": {
3479 "201": {
3480 "description": "Created",
3481 "content": {
3482 "application/json": {
3483 "schema": {
3484 "type": "object",
3485 "properties": {
3486 "id": {"type": "integer"},
3487 "name": {"type": "string"}
3488 }
3489 }
3490 }
3491 }
3492 }
3493 }
3494 }
3495 },
3496 "/users/{id}": {
3497 "get": {
3498 "summary": "Get user by ID",
3499 "parameters": [
3500 {
3501 "name": "id",
3502 "in": "path",
3503 "required": true,
3504 "schema": {"type": "integer"}
3505 }
3506 ],
3507 "responses": {
3508 "200": {
3509 "description": "Success",
3510 "content": {
3511 "application/json": {
3512 "schema": {
3513 "type": "object",
3514 "properties": {
3515 "id": {"type": "integer"},
3516 "name": {"type": "string"}
3517 }
3518 }
3519 }
3520 }
3521 }
3522 }
3523 }
3524 }
3525 }
3526 });
3527
3528 let registry = create_registry_from_json(spec_json).unwrap();
3529
3530 assert_eq!(registry.paths().len(), 2);
3532 assert!(registry.paths().contains(&"/users".to_string()));
3533 assert!(registry.paths().contains(&"/users/{id}".to_string()));
3534
3535 assert_eq!(registry.methods().len(), 2);
3536 assert!(registry.methods().contains(&"GET".to_string()));
3537 assert!(registry.methods().contains(&"POST".to_string()));
3538
3539 let get_users_route = registry.get_route("/users", "GET").unwrap();
3541 assert_eq!(get_users_route.method, "GET");
3542 assert_eq!(get_users_route.path, "/users");
3543
3544 let post_users_route = registry.get_route("/users", "POST").unwrap();
3545 assert_eq!(post_users_route.method, "POST");
3546 assert!(post_users_route.operation.request_body.is_some());
3547
3548 let user_by_id_route = registry.get_route("/users/{id}", "GET").unwrap();
3550 assert_eq!(user_by_id_route.axum_path(), "/users/{id}");
3551 }
3552
3553 #[tokio::test]
3559 async fn check_request_content_type_flags_mismatch() {
3560 let spec_json = json!({
3561 "openapi": "3.0.0",
3562 "info": { "title": "T", "version": "1" },
3563 "paths": {
3564 "/api/appliance/access/consolecli": {
3565 "put": {
3566 "requestBody": {
3567 "required": true,
3568 "content": {
3569 "application/json": {
3570 "schema": {
3571 "type": "object",
3572 "required": ["enabled"],
3573 "properties": {"enabled": {"type": "boolean"}}
3574 }
3575 }
3576 }
3577 },
3578 "responses": { "204": { "description": "ok" } }
3579 }
3580 }
3581 }
3582 });
3583 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3584 let registry = OpenApiRouteRegistry::new(spec);
3585
3586 let r = registry.check_request_content_type(
3588 "/api/appliance/access/consolecli",
3589 "PUT",
3590 Some("application/xml"),
3591 );
3592 assert!(r.is_err(), "should flag application/xml: {:?}", r);
3593 let msg = r.unwrap_err();
3594 assert!(msg.contains("application/xml"), "{msg}");
3595 assert!(msg.contains("application/json"), "{msg}");
3596
3597 let r = registry.check_request_content_type(
3599 "/api/appliance/access/consolecli",
3600 "PUT",
3601 Some("application/json"),
3602 );
3603 assert!(r.is_ok(), "should accept application/json: {:?}", r);
3604
3605 let r = registry.check_request_content_type(
3607 "/api/appliance/access/consolecli",
3608 "PUT",
3609 Some("application/json; charset=utf-8"),
3610 );
3611 assert!(r.is_ok(), "should strip charset: {:?}", r);
3612
3613 let r = registry.check_request_content_type(
3615 "/api/appliance/access/consolecli",
3616 "GET",
3617 Some("application/xml"),
3618 );
3619 assert!(r.is_ok(), "GET has no requestBody on this op: {:?}", r);
3620
3621 let r =
3623 registry.check_request_content_type("/api/appliance/access/consolecli", "PUT", None);
3624 assert!(r.is_ok(), "no Content-Type → don't double-report: {:?}", r);
3625 }
3626
3627 #[tokio::test]
3628 async fn test_validate_request_with_params_and_formats() {
3629 let spec_json = json!({
3630 "openapi": "3.0.0",
3631 "info": { "title": "Test API", "version": "1.0.0" },
3632 "paths": {
3633 "/users/{id}": {
3634 "post": {
3635 "parameters": [
3636 { "name": "id", "in": "path", "required": true, "schema": {"type": "string"} },
3637 { "name": "q", "in": "query", "required": false, "schema": {"type": "integer"} }
3638 ],
3639 "requestBody": {
3640 "content": {
3641 "application/json": {
3642 "schema": {
3643 "type": "object",
3644 "required": ["email", "website"],
3645 "properties": {
3646 "email": {"type": "string", "format": "email"},
3647 "website": {"type": "string", "format": "uri"}
3648 }
3649 }
3650 }
3651 }
3652 },
3653 "responses": {"200": {"description": "ok"}}
3654 }
3655 }
3656 }
3657 });
3658
3659 let registry = create_registry_from_json(spec_json).unwrap();
3660 let mut path_params = Map::new();
3661 path_params.insert("id".to_string(), json!("abc"));
3662 let mut query_params = Map::new();
3663 query_params.insert("q".to_string(), json!(123));
3664
3665 let body = json!({"email":"a@b.co","website":"https://example.com"});
3667 assert!(registry
3668 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3669 .is_ok());
3670
3671 let bad_email = json!({"email":"not-an-email","website":"https://example.com"});
3673 assert!(registry
3674 .validate_request_with(
3675 "/users/{id}",
3676 "POST",
3677 &path_params,
3678 &query_params,
3679 Some(&bad_email)
3680 )
3681 .is_err());
3682
3683 let empty_path_params = Map::new();
3685 assert!(registry
3686 .validate_request_with(
3687 "/users/{id}",
3688 "POST",
3689 &empty_path_params,
3690 &query_params,
3691 Some(&body)
3692 )
3693 .is_err());
3694 }
3695
3696 #[tokio::test]
3697 async fn test_ref_resolution_for_params_and_body() {
3698 let spec_json = json!({
3699 "openapi": "3.0.0",
3700 "info": { "title": "Ref API", "version": "1.0.0" },
3701 "components": {
3702 "schemas": {
3703 "EmailWebsite": {
3704 "type": "object",
3705 "required": ["email", "website"],
3706 "properties": {
3707 "email": {"type": "string", "format": "email"},
3708 "website": {"type": "string", "format": "uri"}
3709 }
3710 }
3711 },
3712 "parameters": {
3713 "PathId": {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}},
3714 "QueryQ": {"name": "q", "in": "query", "required": false, "schema": {"type": "integer"}}
3715 },
3716 "requestBodies": {
3717 "CreateUser": {
3718 "content": {
3719 "application/json": {
3720 "schema": {"$ref": "#/components/schemas/EmailWebsite"}
3721 }
3722 }
3723 }
3724 }
3725 },
3726 "paths": {
3727 "/users/{id}": {
3728 "post": {
3729 "parameters": [
3730 {"$ref": "#/components/parameters/PathId"},
3731 {"$ref": "#/components/parameters/QueryQ"}
3732 ],
3733 "requestBody": {"$ref": "#/components/requestBodies/CreateUser"},
3734 "responses": {"200": {"description": "ok"}}
3735 }
3736 }
3737 }
3738 });
3739
3740 let registry = create_registry_from_json(spec_json).unwrap();
3741 let mut path_params = Map::new();
3742 path_params.insert("id".to_string(), json!("abc"));
3743 let mut query_params = Map::new();
3744 query_params.insert("q".to_string(), json!(7));
3745
3746 let body = json!({"email":"user@example.com","website":"https://example.com"});
3747 assert!(registry
3748 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3749 .is_ok());
3750
3751 let bad = json!({"email":"nope","website":"https://example.com"});
3752 assert!(registry
3753 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&bad))
3754 .is_err());
3755 }
3756
3757 #[tokio::test]
3758 async fn test_header_cookie_and_query_coercion() {
3759 let spec_json = json!({
3760 "openapi": "3.0.0",
3761 "info": { "title": "Params API", "version": "1.0.0" },
3762 "paths": {
3763 "/items": {
3764 "get": {
3765 "parameters": [
3766 {"name": "X-Flag", "in": "header", "required": true, "schema": {"type": "boolean"}},
3767 {"name": "session", "in": "cookie", "required": true, "schema": {"type": "string"}},
3768 {"name": "ids", "in": "query", "required": false, "schema": {"type": "array", "items": {"type": "integer"}}}
3769 ],
3770 "responses": {"200": {"description": "ok"}}
3771 }
3772 }
3773 }
3774 });
3775
3776 let registry = create_registry_from_json(spec_json).unwrap();
3777
3778 let path_params = Map::new();
3779 let mut query_params = Map::new();
3780 query_params.insert("ids".to_string(), json!("1,2,3"));
3782 let mut header_params = Map::new();
3783 header_params.insert("X-Flag".to_string(), json!("true"));
3784 let mut cookie_params = Map::new();
3785 cookie_params.insert("session".to_string(), json!("abc123"));
3786
3787 assert!(registry
3788 .validate_request_with_all(
3789 "/items",
3790 "GET",
3791 &path_params,
3792 &query_params,
3793 &header_params,
3794 &cookie_params,
3795 None
3796 )
3797 .is_ok());
3798
3799 let empty_cookie = Map::new();
3801 assert!(registry
3802 .validate_request_with_all(
3803 "/items",
3804 "GET",
3805 &path_params,
3806 &query_params,
3807 &header_params,
3808 &empty_cookie,
3809 None
3810 )
3811 .is_err());
3812
3813 let mut bad_header = Map::new();
3815 bad_header.insert("X-Flag".to_string(), json!("notabool"));
3816 assert!(registry
3817 .validate_request_with_all(
3818 "/items",
3819 "GET",
3820 &path_params,
3821 &query_params,
3822 &bad_header,
3823 &cookie_params,
3824 None
3825 )
3826 .is_err());
3827 }
3828
3829 #[tokio::test]
3830 async fn test_query_styles_space_pipe_deepobject() {
3831 let spec_json = json!({
3832 "openapi": "3.0.0",
3833 "info": { "title": "Query Styles API", "version": "1.0.0" },
3834 "paths": {"/search": {"get": {
3835 "parameters": [
3836 {"name":"tags","in":"query","style":"spaceDelimited","schema":{"type":"array","items":{"type":"string"}}},
3837 {"name":"ids","in":"query","style":"pipeDelimited","schema":{"type":"array","items":{"type":"integer"}}},
3838 {"name":"filter","in":"query","style":"deepObject","schema":{"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}}
3839 ],
3840 "responses": {"200": {"description":"ok"}}
3841 }} }
3842 });
3843
3844 let registry = create_registry_from_json(spec_json).unwrap();
3845
3846 let path_params = Map::new();
3847 let mut query = Map::new();
3848 query.insert("tags".into(), json!("alpha beta gamma"));
3849 query.insert("ids".into(), json!("1|2|3"));
3850 query.insert("filter[color]".into(), json!("red"));
3851
3852 assert!(registry
3853 .validate_request_with("/search", "GET", &path_params, &query, None)
3854 .is_ok());
3855 }
3856
3857 #[tokio::test]
3858 async fn test_oneof_anyof_allof_validation() {
3859 let spec_json = json!({
3860 "openapi": "3.0.0",
3861 "info": { "title": "Composite API", "version": "1.0.0" },
3862 "paths": {
3863 "/composite": {
3864 "post": {
3865 "requestBody": {
3866 "content": {
3867 "application/json": {
3868 "schema": {
3869 "allOf": [
3870 {"type": "object", "required": ["base"], "properties": {"base": {"type": "string"}}}
3871 ],
3872 "oneOf": [
3873 {"type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"], "not": {"required": ["b"]}},
3874 {"type": "object", "properties": {"b": {"type": "integer"}}, "required": ["b"], "not": {"required": ["a"]}}
3875 ],
3876 "anyOf": [
3877 {"type": "object", "properties": {"flag": {"type": "boolean"}}, "required": ["flag"]},
3878 {"type": "object", "properties": {"extra": {"type": "string"}}, "required": ["extra"]}
3879 ]
3880 }
3881 }
3882 }
3883 },
3884 "responses": {"200": {"description": "ok"}}
3885 }
3886 }
3887 }
3888 });
3889
3890 let registry = create_registry_from_json(spec_json).unwrap();
3891 let ok = json!({"base": "x", "a": 1, "flag": true});
3893 assert!(registry
3894 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&ok))
3895 .is_ok());
3896
3897 let bad_oneof = json!({"base": "x", "a": 1, "b": 2, "flag": false});
3899 assert!(registry
3900 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_oneof))
3901 .is_err());
3902
3903 let bad_anyof = json!({"base": "x", "a": 1});
3905 assert!(registry
3906 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_anyof))
3907 .is_err());
3908
3909 let bad_allof = json!({"a": 1, "flag": true});
3911 assert!(registry
3912 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_allof))
3913 .is_err());
3914 }
3915
3916 #[tokio::test]
3925 async fn dotted_schema_ref_resolves_in_route_validator() {
3926 let spec_json = json!({
3927 "openapi": "3.0.0",
3928 "info": { "title": "Dotted", "version": "1.0.0" },
3929 "paths": {
3930 "/x": {
3931 "post": {
3932 "requestBody": {
3933 "required": true,
3934 "content": {
3935 "application/json": {
3936 "schema": {
3937 "$ref": "#/components/schemas/Esx.Settings.Inventory.EntitySpec"
3938 }
3939 }
3940 }
3941 },
3942 "responses": {"200": {"description": "ok"}}
3943 }
3944 }
3945 },
3946 "components": {
3947 "schemas": {
3948 "Esx.Settings.Inventory.EntitySpec": {
3949 "type": "object",
3950 "required": ["type"],
3951 "properties": {"type": {"type": "string"}}
3952 }
3953 }
3954 }
3955 });
3956 let registry = create_registry_from_json(spec_json).unwrap();
3957 let good = json!({"type": "HOST"});
3960 let res =
3961 registry.validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&good));
3962 assert!(res.is_ok(), "valid body should pass; got {res:?}");
3963 let bad = json!({"unrelated": 1});
3965 let err = registry
3966 .validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&bad))
3967 .unwrap_err();
3968 let msg = format!("{err}");
3969 assert!(
3970 !msg.contains("Pointer") || !msg.contains("does not exist"),
3971 "should not be a pointer-resolution failure; got: {msg}"
3972 );
3973 }
3974
3975 #[tokio::test]
3976 async fn test_overrides_warn_mode_allows_invalid() {
3977 let spec_json = json!({
3979 "openapi": "3.0.0",
3980 "info": { "title": "Overrides API", "version": "1.0.0" },
3981 "paths": {"/things": {"post": {
3982 "parameters": [{"name":"q","in":"query","required":true,"schema":{"type":"integer"}}],
3983 "responses": {"200": {"description":"ok"}}
3984 }}}
3985 });
3986
3987 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3988 let mut overrides = HashMap::new();
3989 overrides.insert("POST /things".to_string(), ValidationMode::Warn);
3990 let registry = OpenApiRouteRegistry::new_with_options(
3991 spec,
3992 ValidationOptions {
3993 request_mode: ValidationMode::Enforce,
3994 aggregate_errors: true,
3995 validate_responses: false,
3996 overrides,
3997 admin_skip_prefixes: vec![],
3998 response_template_expand: false,
3999 validation_status: None,
4000 },
4001 );
4002
4003 let ok = registry.validate_request_with("/things", "POST", &Map::new(), &Map::new(), None);
4005 assert!(ok.is_ok());
4006 }
4007
4008 #[tokio::test]
4009 async fn test_admin_skip_prefix_short_circuit() {
4010 let spec_json = json!({
4011 "openapi": "3.0.0",
4012 "info": { "title": "Skip API", "version": "1.0.0" },
4013 "paths": {}
4014 });
4015 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4016 let registry = OpenApiRouteRegistry::new_with_options(
4017 spec,
4018 ValidationOptions {
4019 request_mode: ValidationMode::Enforce,
4020 aggregate_errors: true,
4021 validate_responses: false,
4022 overrides: HashMap::new(),
4023 admin_skip_prefixes: vec!["/admin".into()],
4024 response_template_expand: false,
4025 validation_status: None,
4026 },
4027 );
4028
4029 let res = registry.validate_request_with_all(
4031 "/admin/__mockforge/health",
4032 "GET",
4033 &Map::new(),
4034 &Map::new(),
4035 &Map::new(),
4036 &Map::new(),
4037 None,
4038 );
4039 assert!(res.is_ok());
4040 }
4041
4042 #[test]
4043 fn test_path_conversion() {
4044 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users"), "/users");
4045 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users/{id}"), "/users/{id}");
4046 assert_eq!(
4047 OpenApiRouteRegistry::convert_path_to_axum("/users/{id}/posts/{postId}"),
4048 "/users/{id}/posts/{postId}"
4049 );
4050 }
4051
4052 #[test]
4053 fn test_validation_options_default() {
4054 let options = ValidationOptions::default();
4055 assert!(matches!(options.request_mode, ValidationMode::Enforce));
4056 assert!(options.aggregate_errors);
4057 assert!(!options.validate_responses);
4058 assert!(options.overrides.is_empty());
4059 assert!(options.admin_skip_prefixes.is_empty());
4060 assert!(!options.response_template_expand);
4061 assert!(options.validation_status.is_none());
4062 }
4063
4064 #[test]
4065 fn test_validation_mode_variants() {
4066 let disabled = ValidationMode::Disabled;
4068 let warn = ValidationMode::Warn;
4069 let enforce = ValidationMode::Enforce;
4070 let default = ValidationMode::default();
4071
4072 assert!(matches!(default, ValidationMode::Warn));
4074
4075 assert!(!matches!(disabled, ValidationMode::Warn));
4077 assert!(!matches!(warn, ValidationMode::Enforce));
4078 assert!(!matches!(enforce, ValidationMode::Disabled));
4079 }
4080
4081 #[test]
4082 fn test_registry_spec_accessor() {
4083 let spec_json = json!({
4084 "openapi": "3.0.0",
4085 "info": {
4086 "title": "Test API",
4087 "version": "1.0.0"
4088 },
4089 "paths": {}
4090 });
4091 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4092 let registry = OpenApiRouteRegistry::new(spec.clone());
4093
4094 let accessed_spec = registry.spec();
4096 assert_eq!(accessed_spec.title(), "Test API");
4097 }
4098
4099 #[test]
4100 fn test_clone_for_validation() {
4101 let spec_json = json!({
4102 "openapi": "3.0.0",
4103 "info": {
4104 "title": "Test API",
4105 "version": "1.0.0"
4106 },
4107 "paths": {
4108 "/users": {
4109 "get": {
4110 "responses": {
4111 "200": {
4112 "description": "Success"
4113 }
4114 }
4115 }
4116 }
4117 }
4118 });
4119 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4120 let registry = OpenApiRouteRegistry::new(spec);
4121
4122 let cloned = registry.clone_for_validation();
4124 assert_eq!(cloned.routes().len(), registry.routes().len());
4125 assert_eq!(cloned.spec().title(), registry.spec().title());
4126 }
4127
4128 #[test]
4129 fn test_with_custom_fixture_loader() {
4130 let temp_dir = TempDir::new().unwrap();
4131 let spec_json = json!({
4132 "openapi": "3.0.0",
4133 "info": {
4134 "title": "Test API",
4135 "version": "1.0.0"
4136 },
4137 "paths": {}
4138 });
4139 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4140 let registry = OpenApiRouteRegistry::new(spec);
4141 let original_routes_len = registry.routes().len();
4142
4143 let custom_loader = Arc::new(crate::custom_fixture::CustomFixtureLoader::new(
4145 temp_dir.path().to_path_buf(),
4146 true,
4147 ));
4148 let registry_with_loader = registry.with_custom_fixture_loader(custom_loader);
4149
4150 assert_eq!(registry_with_loader.routes().len(), original_routes_len);
4152 }
4153
4154 #[test]
4155 fn test_get_route() {
4156 let spec_json = json!({
4157 "openapi": "3.0.0",
4158 "info": {
4159 "title": "Test API",
4160 "version": "1.0.0"
4161 },
4162 "paths": {
4163 "/users": {
4164 "get": {
4165 "operationId": "getUsers",
4166 "responses": {
4167 "200": {
4168 "description": "Success"
4169 }
4170 }
4171 },
4172 "post": {
4173 "operationId": "createUser",
4174 "responses": {
4175 "201": {
4176 "description": "Created"
4177 }
4178 }
4179 }
4180 }
4181 }
4182 });
4183 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4184 let registry = OpenApiRouteRegistry::new(spec);
4185
4186 let route = registry.get_route("/users", "GET");
4188 assert!(route.is_some());
4189 assert_eq!(route.unwrap().method, "GET");
4190 assert_eq!(route.unwrap().path, "/users");
4191
4192 let route = registry.get_route("/nonexistent", "GET");
4194 assert!(route.is_none());
4195
4196 let route = registry.get_route("/users", "POST");
4198 assert!(route.is_some());
4199 assert_eq!(route.unwrap().method, "POST");
4200 }
4201
4202 #[test]
4203 fn test_get_routes_for_path() {
4204 let spec_json = json!({
4205 "openapi": "3.0.0",
4206 "info": {
4207 "title": "Test API",
4208 "version": "1.0.0"
4209 },
4210 "paths": {
4211 "/users": {
4212 "get": {
4213 "responses": {
4214 "200": {
4215 "description": "Success"
4216 }
4217 }
4218 },
4219 "post": {
4220 "responses": {
4221 "201": {
4222 "description": "Created"
4223 }
4224 }
4225 },
4226 "put": {
4227 "responses": {
4228 "200": {
4229 "description": "Success"
4230 }
4231 }
4232 }
4233 },
4234 "/posts": {
4235 "get": {
4236 "responses": {
4237 "200": {
4238 "description": "Success"
4239 }
4240 }
4241 }
4242 }
4243 }
4244 });
4245 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4246 let registry = OpenApiRouteRegistry::new(spec);
4247
4248 let routes = registry.get_routes_for_path("/users");
4250 assert_eq!(routes.len(), 3);
4251 let methods: Vec<&str> = routes.iter().map(|r| r.method.as_str()).collect();
4252 assert!(methods.contains(&"GET"));
4253 assert!(methods.contains(&"POST"));
4254 assert!(methods.contains(&"PUT"));
4255
4256 let routes = registry.get_routes_for_path("/posts");
4258 assert_eq!(routes.len(), 1);
4259 assert_eq!(routes[0].method, "GET");
4260
4261 let routes = registry.get_routes_for_path("/nonexistent");
4263 assert!(routes.is_empty());
4264 }
4265
4266 #[test]
4267 fn test_new_vs_new_with_options() {
4268 let spec_json = json!({
4269 "openapi": "3.0.0",
4270 "info": {
4271 "title": "Test API",
4272 "version": "1.0.0"
4273 },
4274 "paths": {}
4275 });
4276 let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
4277 let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
4278
4279 let registry1 = OpenApiRouteRegistry::new(spec1);
4281 assert_eq!(registry1.spec().title(), "Test API");
4282
4283 let options = ValidationOptions {
4285 request_mode: ValidationMode::Disabled,
4286 aggregate_errors: false,
4287 validate_responses: true,
4288 overrides: HashMap::new(),
4289 admin_skip_prefixes: vec!["/admin".to_string()],
4290 response_template_expand: true,
4291 validation_status: Some(422),
4292 };
4293 let registry2 = OpenApiRouteRegistry::new_with_options(spec2, options);
4294 assert_eq!(registry2.spec().title(), "Test API");
4295 }
4296
4297 #[test]
4298 fn test_new_with_env_vs_new() {
4299 let spec_json = json!({
4300 "openapi": "3.0.0",
4301 "info": {
4302 "title": "Test API",
4303 "version": "1.0.0"
4304 },
4305 "paths": {}
4306 });
4307 let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
4308 let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
4309
4310 let registry1 = OpenApiRouteRegistry::new(spec1);
4312
4313 let registry2 = OpenApiRouteRegistry::new_with_env(spec2);
4315
4316 assert_eq!(registry1.spec().title(), "Test API");
4318 assert_eq!(registry2.spec().title(), "Test API");
4319 }
4320
4321 #[test]
4322 fn test_validation_options_custom() {
4323 let options = ValidationOptions {
4324 request_mode: ValidationMode::Warn,
4325 aggregate_errors: false,
4326 validate_responses: true,
4327 overrides: {
4328 let mut map = HashMap::new();
4329 map.insert("getUsers".to_string(), ValidationMode::Disabled);
4330 map
4331 },
4332 admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
4333 response_template_expand: true,
4334 validation_status: Some(422),
4335 };
4336
4337 assert!(matches!(options.request_mode, ValidationMode::Warn));
4338 assert!(!options.aggregate_errors);
4339 assert!(options.validate_responses);
4340 assert_eq!(options.overrides.len(), 1);
4341 assert_eq!(options.admin_skip_prefixes.len(), 2);
4342 assert!(options.response_template_expand);
4343 assert_eq!(options.validation_status, Some(422));
4344 }
4345
4346 #[test]
4347 fn test_validation_mode_default_standalone() {
4348 let mode = ValidationMode::default();
4349 assert!(matches!(mode, ValidationMode::Warn));
4350 }
4351
4352 #[test]
4353 fn test_validation_mode_clone() {
4354 let mode1 = ValidationMode::Enforce;
4355 let mode2 = mode1.clone();
4356 assert!(matches!(mode1, ValidationMode::Enforce));
4357 assert!(matches!(mode2, ValidationMode::Enforce));
4358 }
4359
4360 #[test]
4361 fn test_validation_mode_debug() {
4362 let mode = ValidationMode::Disabled;
4363 let debug_str = format!("{:?}", mode);
4364 assert!(debug_str.contains("Disabled") || debug_str.contains("ValidationMode"));
4365 }
4366
4367 #[test]
4368 fn test_validation_options_clone() {
4369 let options1 = ValidationOptions {
4370 request_mode: ValidationMode::Warn,
4371 aggregate_errors: true,
4372 validate_responses: false,
4373 overrides: HashMap::new(),
4374 admin_skip_prefixes: vec![],
4375 response_template_expand: false,
4376 validation_status: None,
4377 };
4378 let options2 = options1.clone();
4379 assert!(matches!(options2.request_mode, ValidationMode::Warn));
4380 assert_eq!(options1.aggregate_errors, options2.aggregate_errors);
4381 }
4382
4383 #[test]
4384 fn test_validation_options_debug() {
4385 let options = ValidationOptions::default();
4386 let debug_str = format!("{:?}", options);
4387 assert!(debug_str.contains("ValidationOptions"));
4388 }
4389
4390 #[test]
4391 fn test_validation_options_with_all_fields() {
4392 let mut overrides = HashMap::new();
4393 overrides.insert("op1".to_string(), ValidationMode::Disabled);
4394 overrides.insert("op2".to_string(), ValidationMode::Warn);
4395
4396 let options = ValidationOptions {
4397 request_mode: ValidationMode::Enforce,
4398 aggregate_errors: false,
4399 validate_responses: true,
4400 overrides: overrides.clone(),
4401 admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
4402 response_template_expand: true,
4403 validation_status: Some(422),
4404 };
4405
4406 assert!(matches!(options.request_mode, ValidationMode::Enforce));
4407 assert!(!options.aggregate_errors);
4408 assert!(options.validate_responses);
4409 assert_eq!(options.overrides.len(), 2);
4410 assert_eq!(options.admin_skip_prefixes.len(), 2);
4411 assert!(options.response_template_expand);
4412 assert_eq!(options.validation_status, Some(422));
4413 }
4414
4415 #[test]
4416 fn test_openapi_route_registry_clone() {
4417 let spec_json = json!({
4418 "openapi": "3.0.0",
4419 "info": { "title": "Test API", "version": "1.0.0" },
4420 "paths": {}
4421 });
4422 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4423 let registry1 = OpenApiRouteRegistry::new(spec);
4424 let registry2 = registry1.clone();
4425 assert_eq!(registry1.spec().title(), registry2.spec().title());
4426 }
4427
4428 #[test]
4429 fn test_validation_mode_serialization() {
4430 let mode = ValidationMode::Enforce;
4431 let json = serde_json::to_string(&mode).unwrap();
4432 assert!(json.contains("Enforce") || json.contains("enforce"));
4433 }
4434
4435 #[test]
4436 fn test_validation_mode_deserialization() {
4437 let json = r#""Disabled""#;
4438 let mode: ValidationMode = serde_json::from_str(json).unwrap();
4439 assert!(matches!(mode, ValidationMode::Disabled));
4440 }
4441
4442 #[test]
4443 fn test_validation_options_default_values() {
4444 let options = ValidationOptions::default();
4445 assert!(matches!(options.request_mode, ValidationMode::Enforce));
4446 assert!(options.aggregate_errors);
4447 assert!(!options.validate_responses);
4448 assert!(options.overrides.is_empty());
4449 assert!(options.admin_skip_prefixes.is_empty());
4450 assert!(!options.response_template_expand);
4451 assert_eq!(options.validation_status, None);
4452 }
4453
4454 #[test]
4455 fn test_validation_mode_all_variants() {
4456 let disabled = ValidationMode::Disabled;
4457 let warn = ValidationMode::Warn;
4458 let enforce = ValidationMode::Enforce;
4459
4460 assert!(matches!(disabled, ValidationMode::Disabled));
4461 assert!(matches!(warn, ValidationMode::Warn));
4462 assert!(matches!(enforce, ValidationMode::Enforce));
4463 }
4464
4465 #[test]
4466 fn test_validation_options_with_overrides() {
4467 let mut overrides = HashMap::new();
4468 overrides.insert("operation1".to_string(), ValidationMode::Disabled);
4469 overrides.insert("operation2".to_string(), ValidationMode::Warn);
4470
4471 let options = ValidationOptions {
4472 request_mode: ValidationMode::Enforce,
4473 aggregate_errors: true,
4474 validate_responses: false,
4475 overrides,
4476 admin_skip_prefixes: vec![],
4477 response_template_expand: false,
4478 validation_status: None,
4479 };
4480
4481 assert_eq!(options.overrides.len(), 2);
4482 assert!(matches!(options.overrides.get("operation1"), Some(ValidationMode::Disabled)));
4483 assert!(matches!(options.overrides.get("operation2"), Some(ValidationMode::Warn)));
4484 }
4485
4486 #[test]
4487 fn test_validation_options_with_admin_skip_prefixes() {
4488 let options = ValidationOptions {
4489 request_mode: ValidationMode::Enforce,
4490 aggregate_errors: true,
4491 validate_responses: false,
4492 overrides: HashMap::new(),
4493 admin_skip_prefixes: vec![
4494 "/admin".to_string(),
4495 "/internal".to_string(),
4496 "/debug".to_string(),
4497 ],
4498 response_template_expand: false,
4499 validation_status: None,
4500 };
4501
4502 assert_eq!(options.admin_skip_prefixes.len(), 3);
4503 assert!(options.admin_skip_prefixes.contains(&"/admin".to_string()));
4504 assert!(options.admin_skip_prefixes.contains(&"/internal".to_string()));
4505 assert!(options.admin_skip_prefixes.contains(&"/debug".to_string()));
4506 }
4507
4508 #[test]
4509 fn test_validation_options_with_validation_status() {
4510 let options1 = ValidationOptions {
4511 request_mode: ValidationMode::Enforce,
4512 aggregate_errors: true,
4513 validate_responses: false,
4514 overrides: HashMap::new(),
4515 admin_skip_prefixes: vec![],
4516 response_template_expand: false,
4517 validation_status: Some(400),
4518 };
4519
4520 let options2 = ValidationOptions {
4521 request_mode: ValidationMode::Enforce,
4522 aggregate_errors: true,
4523 validate_responses: false,
4524 overrides: HashMap::new(),
4525 admin_skip_prefixes: vec![],
4526 response_template_expand: false,
4527 validation_status: Some(422),
4528 };
4529
4530 assert_eq!(options1.validation_status, Some(400));
4531 assert_eq!(options2.validation_status, Some(422));
4532 }
4533
4534 #[test]
4535 fn test_validate_request_with_disabled_mode() {
4536 let spec_json = json!({
4538 "openapi": "3.0.0",
4539 "info": {"title": "Test API", "version": "1.0.0"},
4540 "paths": {
4541 "/users": {
4542 "get": {
4543 "responses": {"200": {"description": "OK"}}
4544 }
4545 }
4546 }
4547 });
4548 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4549 let options = ValidationOptions {
4550 request_mode: ValidationMode::Disabled,
4551 ..Default::default()
4552 };
4553 let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4554
4555 let result = registry.validate_request_with_all(
4557 "/users",
4558 "GET",
4559 &Map::new(),
4560 &Map::new(),
4561 &Map::new(),
4562 &Map::new(),
4563 None,
4564 );
4565 assert!(result.is_ok());
4566 }
4567
4568 #[test]
4569 fn test_validate_request_with_warn_mode() {
4570 let spec_json = json!({
4572 "openapi": "3.0.0",
4573 "info": {"title": "Test API", "version": "1.0.0"},
4574 "paths": {
4575 "/users": {
4576 "post": {
4577 "requestBody": {
4578 "required": true,
4579 "content": {
4580 "application/json": {
4581 "schema": {
4582 "type": "object",
4583 "required": ["name"],
4584 "properties": {
4585 "name": {"type": "string"}
4586 }
4587 }
4588 }
4589 }
4590 },
4591 "responses": {"200": {"description": "OK"}}
4592 }
4593 }
4594 }
4595 });
4596 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4597 let options = ValidationOptions {
4598 request_mode: ValidationMode::Warn,
4599 ..Default::default()
4600 };
4601 let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4602
4603 let result = registry.validate_request_with_all(
4605 "/users",
4606 "POST",
4607 &Map::new(),
4608 &Map::new(),
4609 &Map::new(),
4610 &Map::new(),
4611 None, );
4613 assert!(result.is_ok()); }
4615
4616 #[test]
4617 fn test_validate_request_body_validation_error() {
4618 let spec_json = json!({
4620 "openapi": "3.0.0",
4621 "info": {"title": "Test API", "version": "1.0.0"},
4622 "paths": {
4623 "/users": {
4624 "post": {
4625 "requestBody": {
4626 "required": true,
4627 "content": {
4628 "application/json": {
4629 "schema": {
4630 "type": "object",
4631 "required": ["name"],
4632 "properties": {
4633 "name": {"type": "string"}
4634 }
4635 }
4636 }
4637 }
4638 },
4639 "responses": {"200": {"description": "OK"}}
4640 }
4641 }
4642 }
4643 });
4644 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4645 let registry = OpenApiRouteRegistry::new(spec);
4646
4647 let result = registry.validate_request_with_all(
4649 "/users",
4650 "POST",
4651 &Map::new(),
4652 &Map::new(),
4653 &Map::new(),
4654 &Map::new(),
4655 None, );
4657 assert!(result.is_err());
4658 }
4659
4660 #[test]
4661 fn test_validate_request_body_schema_validation_error() {
4662 let spec_json = json!({
4664 "openapi": "3.0.0",
4665 "info": {"title": "Test API", "version": "1.0.0"},
4666 "paths": {
4667 "/users": {
4668 "post": {
4669 "requestBody": {
4670 "required": true,
4671 "content": {
4672 "application/json": {
4673 "schema": {
4674 "type": "object",
4675 "required": ["name"],
4676 "properties": {
4677 "name": {"type": "string"}
4678 }
4679 }
4680 }
4681 }
4682 },
4683 "responses": {"200": {"description": "OK"}}
4684 }
4685 }
4686 }
4687 });
4688 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4689 let registry = OpenApiRouteRegistry::new(spec);
4690
4691 let invalid_body = json!({}); let result = registry.validate_request_with_all(
4694 "/users",
4695 "POST",
4696 &Map::new(),
4697 &Map::new(),
4698 &Map::new(),
4699 &Map::new(),
4700 Some(&invalid_body),
4701 );
4702 assert!(result.is_err());
4703 }
4704
4705 #[test]
4706 fn test_validate_request_body_referenced_schema_error() {
4707 let spec_json = json!({
4709 "openapi": "3.0.0",
4710 "info": {"title": "Test API", "version": "1.0.0"},
4711 "paths": {
4712 "/users": {
4713 "post": {
4714 "requestBody": {
4715 "required": true,
4716 "content": {
4717 "application/json": {
4718 "schema": {
4719 "$ref": "#/components/schemas/NonExistentSchema"
4720 }
4721 }
4722 }
4723 },
4724 "responses": {"200": {"description": "OK"}}
4725 }
4726 }
4727 },
4728 "components": {}
4729 });
4730 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4731 let registry = OpenApiRouteRegistry::new(spec);
4732
4733 let body = json!({"name": "test"});
4735 let result = registry.validate_request_with_all(
4736 "/users",
4737 "POST",
4738 &Map::new(),
4739 &Map::new(),
4740 &Map::new(),
4741 &Map::new(),
4742 Some(&body),
4743 );
4744 assert!(result.is_err());
4745 }
4746
4747 #[test]
4748 fn test_validate_request_body_referenced_request_body_error() {
4749 let spec_json = json!({
4751 "openapi": "3.0.0",
4752 "info": {"title": "Test API", "version": "1.0.0"},
4753 "paths": {
4754 "/users": {
4755 "post": {
4756 "requestBody": {
4757 "$ref": "#/components/requestBodies/NonExistentRequestBody"
4758 },
4759 "responses": {"200": {"description": "OK"}}
4760 }
4761 }
4762 },
4763 "components": {}
4764 });
4765 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4766 let registry = OpenApiRouteRegistry::new(spec);
4767
4768 let body = json!({"name": "test"});
4770 let result = registry.validate_request_with_all(
4771 "/users",
4772 "POST",
4773 &Map::new(),
4774 &Map::new(),
4775 &Map::new(),
4776 &Map::new(),
4777 Some(&body),
4778 );
4779 assert!(result.is_err());
4780 }
4781
4782 #[test]
4783 fn test_validate_request_body_provided_when_not_expected() {
4784 let spec_json = json!({
4786 "openapi": "3.0.0",
4787 "info": {"title": "Test API", "version": "1.0.0"},
4788 "paths": {
4789 "/users": {
4790 "get": {
4791 "responses": {"200": {"description": "OK"}}
4792 }
4793 }
4794 }
4795 });
4796 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4797 let registry = OpenApiRouteRegistry::new(spec);
4798
4799 let body = json!({"extra": "data"});
4801 let result = registry.validate_request_with_all(
4802 "/users",
4803 "GET",
4804 &Map::new(),
4805 &Map::new(),
4806 &Map::new(),
4807 &Map::new(),
4808 Some(&body),
4809 );
4810 assert!(result.is_ok());
4812 }
4813
4814 #[test]
4815 fn test_get_operation() {
4816 let spec_json = json!({
4818 "openapi": "3.0.0",
4819 "info": {"title": "Test API", "version": "1.0.0"},
4820 "paths": {
4821 "/users": {
4822 "get": {
4823 "operationId": "getUsers",
4824 "summary": "Get users",
4825 "responses": {"200": {"description": "OK"}}
4826 }
4827 }
4828 }
4829 });
4830 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4831 let registry = OpenApiRouteRegistry::new(spec);
4832
4833 let operation = registry.get_operation("/users", "GET");
4835 assert!(operation.is_some());
4836 assert_eq!(operation.unwrap().method, "GET");
4837
4838 assert!(registry.get_operation("/nonexistent", "GET").is_none());
4840 }
4841
4842 #[test]
4843 fn test_extract_path_parameters() {
4844 let spec_json = json!({
4846 "openapi": "3.0.0",
4847 "info": {"title": "Test API", "version": "1.0.0"},
4848 "paths": {
4849 "/users/{id}": {
4850 "get": {
4851 "parameters": [
4852 {
4853 "name": "id",
4854 "in": "path",
4855 "required": true,
4856 "schema": {"type": "string"}
4857 }
4858 ],
4859 "responses": {"200": {"description": "OK"}}
4860 }
4861 }
4862 }
4863 });
4864 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4865 let registry = OpenApiRouteRegistry::new(spec);
4866
4867 let params = registry.extract_path_parameters("/users/123", "GET");
4869 assert_eq!(params.get("id"), Some(&"123".to_string()));
4870
4871 let empty_params = registry.extract_path_parameters("/users", "GET");
4873 assert!(empty_params.is_empty());
4874 }
4875
4876 #[test]
4877 fn extract_path_parameters_prefers_static_route_and_rejects_empty() {
4878 let spec_json = json!({
4881 "openapi": "3.0.0",
4882 "info": {"title": "Test API", "version": "1.0.0"},
4883 "paths": {
4884 "/users/{id}": { "get": { "responses": {"200": {"description": "OK"}} } },
4885 "/users/me": { "get": { "responses": {"200": {"description": "OK"}} } }
4886 }
4887 });
4888 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4889 let registry = OpenApiRouteRegistry::new(spec);
4890
4891 let me = registry.extract_path_parameters("/users/me", "GET");
4893 assert!(!me.contains_key("id"), "literal route should win, got {me:?}");
4894
4895 let by_id = registry.extract_path_parameters("/users/123", "GET");
4897 assert_eq!(by_id.get("id"), Some(&"123".to_string()));
4898
4899 let trailing = registry.extract_path_parameters("/users/", "GET");
4901 assert!(
4902 trailing.is_empty(),
4903 "empty trailing segment should not bind id, got {trailing:?}"
4904 );
4905 }
4906
4907 #[test]
4908 fn test_extract_path_parameters_multiple_params() {
4909 let spec_json = json!({
4911 "openapi": "3.0.0",
4912 "info": {"title": "Test API", "version": "1.0.0"},
4913 "paths": {
4914 "/users/{userId}/posts/{postId}": {
4915 "get": {
4916 "parameters": [
4917 {
4918 "name": "userId",
4919 "in": "path",
4920 "required": true,
4921 "schema": {"type": "string"}
4922 },
4923 {
4924 "name": "postId",
4925 "in": "path",
4926 "required": true,
4927 "schema": {"type": "string"}
4928 }
4929 ],
4930 "responses": {"200": {"description": "OK"}}
4931 }
4932 }
4933 }
4934 });
4935 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4936 let registry = OpenApiRouteRegistry::new(spec);
4937
4938 let params = registry.extract_path_parameters("/users/123/posts/456", "GET");
4940 assert_eq!(params.get("userId"), Some(&"123".to_string()));
4941 assert_eq!(params.get("postId"), Some(&"456".to_string()));
4942 }
4943
4944 #[test]
4945 fn test_validate_request_route_not_found() {
4946 let spec_json = json!({
4948 "openapi": "3.0.0",
4949 "info": {"title": "Test API", "version": "1.0.0"},
4950 "paths": {
4951 "/users": {
4952 "get": {
4953 "responses": {"200": {"description": "OK"}}
4954 }
4955 }
4956 }
4957 });
4958 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4959 let registry = OpenApiRouteRegistry::new(spec);
4960
4961 let result = registry.validate_request_with_all(
4963 "/nonexistent",
4964 "GET",
4965 &Map::new(),
4966 &Map::new(),
4967 &Map::new(),
4968 &Map::new(),
4969 None,
4970 );
4971 assert!(result.is_err());
4972 assert!(result.unwrap_err().to_string().contains("not found"));
4973 }
4974
4975 #[test]
4976 fn test_validate_request_with_path_parameters() {
4977 let spec_json = json!({
4979 "openapi": "3.0.0",
4980 "info": {"title": "Test API", "version": "1.0.0"},
4981 "paths": {
4982 "/users/{id}": {
4983 "get": {
4984 "parameters": [
4985 {
4986 "name": "id",
4987 "in": "path",
4988 "required": true,
4989 "schema": {"type": "string", "minLength": 1}
4990 }
4991 ],
4992 "responses": {"200": {"description": "OK"}}
4993 }
4994 }
4995 }
4996 });
4997 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4998 let registry = OpenApiRouteRegistry::new(spec);
4999
5000 let mut path_params = Map::new();
5002 path_params.insert("id".to_string(), json!("123"));
5003 let result = registry.validate_request_with_all(
5004 "/users/{id}",
5005 "GET",
5006 &path_params,
5007 &Map::new(),
5008 &Map::new(),
5009 &Map::new(),
5010 None,
5011 );
5012 assert!(result.is_ok());
5013 }
5014
5015 #[test]
5016 fn test_validate_request_with_query_parameters() {
5017 let spec_json = json!({
5019 "openapi": "3.0.0",
5020 "info": {"title": "Test API", "version": "1.0.0"},
5021 "paths": {
5022 "/users": {
5023 "get": {
5024 "parameters": [
5025 {
5026 "name": "page",
5027 "in": "query",
5028 "required": true,
5029 "schema": {"type": "integer", "minimum": 1}
5030 }
5031 ],
5032 "responses": {"200": {"description": "OK"}}
5033 }
5034 }
5035 }
5036 });
5037 let spec = OpenApiSpec::from_json(spec_json).unwrap();
5038 let registry = OpenApiRouteRegistry::new(spec);
5039
5040 let mut query_params = Map::new();
5042 query_params.insert("page".to_string(), json!(1));
5043 let result = registry.validate_request_with_all(
5044 "/users",
5045 "GET",
5046 &Map::new(),
5047 &query_params,
5048 &Map::new(),
5049 &Map::new(),
5050 None,
5051 );
5052 assert!(result.is_ok());
5053 }
5054
5055 #[test]
5056 fn test_validate_request_with_header_parameters() {
5057 let spec_json = json!({
5059 "openapi": "3.0.0",
5060 "info": {"title": "Test API", "version": "1.0.0"},
5061 "paths": {
5062 "/users": {
5063 "get": {
5064 "parameters": [
5065 {
5066 "name": "X-API-Key",
5067 "in": "header",
5068 "required": true,
5069 "schema": {"type": "string"}
5070 }
5071 ],
5072 "responses": {"200": {"description": "OK"}}
5073 }
5074 }
5075 }
5076 });
5077 let spec = OpenApiSpec::from_json(spec_json).unwrap();
5078 let registry = OpenApiRouteRegistry::new(spec);
5079
5080 let mut header_params = Map::new();
5082 header_params.insert("X-API-Key".to_string(), json!("secret-key"));
5083 let result = registry.validate_request_with_all(
5084 "/users",
5085 "GET",
5086 &Map::new(),
5087 &Map::new(),
5088 &header_params,
5089 &Map::new(),
5090 None,
5091 );
5092 assert!(result.is_ok());
5093 }
5094
5095 #[test]
5096 fn test_validate_request_with_cookie_parameters() {
5097 let spec_json = json!({
5099 "openapi": "3.0.0",
5100 "info": {"title": "Test API", "version": "1.0.0"},
5101 "paths": {
5102 "/users": {
5103 "get": {
5104 "parameters": [
5105 {
5106 "name": "sessionId",
5107 "in": "cookie",
5108 "required": true,
5109 "schema": {"type": "string"}
5110 }
5111 ],
5112 "responses": {"200": {"description": "OK"}}
5113 }
5114 }
5115 }
5116 });
5117 let spec = OpenApiSpec::from_json(spec_json).unwrap();
5118 let registry = OpenApiRouteRegistry::new(spec);
5119
5120 let mut cookie_params = Map::new();
5122 cookie_params.insert("sessionId".to_string(), json!("abc123"));
5123 let result = registry.validate_request_with_all(
5124 "/users",
5125 "GET",
5126 &Map::new(),
5127 &Map::new(),
5128 &Map::new(),
5129 &cookie_params,
5130 None,
5131 );
5132 assert!(result.is_ok());
5133 }
5134
5135 #[test]
5136 fn test_validate_request_no_errors_early_return() {
5137 let spec_json = json!({
5139 "openapi": "3.0.0",
5140 "info": {"title": "Test API", "version": "1.0.0"},
5141 "paths": {
5142 "/users": {
5143 "get": {
5144 "responses": {"200": {"description": "OK"}}
5145 }
5146 }
5147 }
5148 });
5149 let spec = OpenApiSpec::from_json(spec_json).unwrap();
5150 let registry = OpenApiRouteRegistry::new(spec);
5151
5152 let result = registry.validate_request_with_all(
5154 "/users",
5155 "GET",
5156 &Map::new(),
5157 &Map::new(),
5158 &Map::new(),
5159 &Map::new(),
5160 None,
5161 );
5162 assert!(result.is_ok());
5163 }
5164
5165 #[test]
5166 fn test_validate_request_query_parameter_different_styles() {
5167 let spec_json = json!({
5169 "openapi": "3.0.0",
5170 "info": {"title": "Test API", "version": "1.0.0"},
5171 "paths": {
5172 "/users": {
5173 "get": {
5174 "parameters": [
5175 {
5176 "name": "tags",
5177 "in": "query",
5178 "style": "pipeDelimited",
5179 "schema": {
5180 "type": "array",
5181 "items": {"type": "string"}
5182 }
5183 }
5184 ],
5185 "responses": {"200": {"description": "OK"}}
5186 }
5187 }
5188 }
5189 });
5190 let spec = OpenApiSpec::from_json(spec_json).unwrap();
5191 let registry = OpenApiRouteRegistry::new(spec);
5192
5193 let mut query_params = Map::new();
5195 query_params.insert("tags".to_string(), json!(["tag1", "tag2"]));
5196 let result = registry.validate_request_with_all(
5197 "/users",
5198 "GET",
5199 &Map::new(),
5200 &query_params,
5201 &Map::new(),
5202 &Map::new(),
5203 None,
5204 );
5205 assert!(result.is_ok() || result.is_err()); }
5208
5209 fn octet_stream_put_spec(required: bool) -> Value {
5211 json!({
5212 "openapi": "3.0.0",
5213 "info": { "title": "Files API", "version": "1.0.0" },
5214 "paths": {
5215 "/files/{name}": {
5216 "put": {
5217 "parameters": [
5218 {"name": "name", "in": "path", "required": true,
5219 "schema": {"type": "string"}}
5220 ],
5221 "requestBody": {
5222 "required": required,
5223 "content": {
5224 "application/octet-stream": {
5225 "schema": {"type": "string", "format": "binary"}
5226 }
5227 }
5228 },
5229 "responses": {"200": {"description": "ok"}}
5230 }
5231 }
5232 }
5233 })
5234 }
5235
5236 #[tokio::test]
5241 async fn non_json_request_body_is_not_reported_missing() {
5242 let registry = create_registry_from_json(octet_stream_put_spec(true)).unwrap();
5243 let mut path_params = Map::new();
5244 path_params.insert("name".to_string(), json!("test.mod"));
5245
5246 let res = registry.validate_request_with_all_ex(
5248 "/files/{name}",
5249 "PUT",
5250 &path_params,
5251 &Map::new(),
5252 &Map::new(),
5253 &Map::new(),
5254 None,
5255 true,
5256 );
5257 assert!(res.is_ok(), "non-JSON PUT body must pass, got {res:?}");
5258 }
5259
5260 #[tokio::test]
5262 async fn absent_body_against_required_still_errors() {
5263 let registry = create_registry_from_json(octet_stream_put_spec(true)).unwrap();
5264 let mut path_params = Map::new();
5265 path_params.insert("name".to_string(), json!("test.mod"));
5266
5267 let res = registry.validate_request_with_all_ex(
5268 "/files/{name}",
5269 "PUT",
5270 &path_params,
5271 &Map::new(),
5272 &Map::new(),
5273 &Map::new(),
5274 None,
5275 false,
5276 );
5277 let err = res.expect_err("absent required body must error");
5278 assert!(err.to_string().contains("Request body is required"), "unexpected error: {err}");
5279 }
5280
5281 #[tokio::test]
5285 async fn absent_body_against_optional_request_body_is_ok() {
5286 let registry = create_registry_from_json(octet_stream_put_spec(false)).unwrap();
5287 let mut path_params = Map::new();
5288 path_params.insert("name".to_string(), json!("test.mod"));
5289
5290 let res = registry.validate_request_with_all_ex(
5291 "/files/{name}",
5292 "PUT",
5293 &path_params,
5294 &Map::new(),
5295 &Map::new(),
5296 &Map::new(),
5297 None,
5298 false,
5299 );
5300 assert!(res.is_ok(), "optional body may be omitted, got {res:?}");
5301 }
5302
5303 #[tokio::test]
5305 async fn json_request_body_still_schema_validates() {
5306 let spec = json!({
5307 "openapi": "3.0.0",
5308 "info": { "title": "Users API", "version": "1.0.0" },
5309 "paths": {
5310 "/users": {
5311 "post": {
5312 "requestBody": {
5313 "required": true,
5314 "content": {
5315 "application/json": {
5316 "schema": {
5317 "type": "object",
5318 "properties": {"age": {"type": "integer"}},
5319 "required": ["age"]
5320 }
5321 }
5322 }
5323 },
5324 "responses": {"200": {"description": "ok"}}
5325 }
5326 }
5327 }
5328 });
5329 let registry = create_registry_from_json(spec).unwrap();
5330
5331 let good = json!({"age": 30});
5332 assert!(registry
5333 .validate_request_with_all_ex(
5334 "/users",
5335 "POST",
5336 &Map::new(),
5337 &Map::new(),
5338 &Map::new(),
5339 &Map::new(),
5340 Some(&good),
5341 true
5342 )
5343 .is_ok());
5344
5345 let bad = json!({"age": "thirty"});
5346 assert!(registry
5347 .validate_request_with_all_ex(
5348 "/users",
5349 "POST",
5350 &Map::new(),
5351 &Map::new(),
5352 &Map::new(),
5353 &Map::new(),
5354 Some(&bad),
5355 true
5356 )
5357 .is_err());
5358 }
5359}