1pub mod builder;
13pub mod generation;
14mod parameter_schema;
15#[doc(hidden)]
16pub mod registry;
17pub mod validation;
18mod validation_policy;
19
20use crate::response::AiGenerator;
21use crate::response_rewriter::{OverrideHook, OverrideRequest, ResponseRewriter};
22use crate::{OpenApiOperation, OpenApiRoute, OpenApiSpec};
23use axum::extract::{DefaultBodyLimit, Path as AxumPath, RawQuery};
24use axum::http::HeaderMap;
25use axum::response::IntoResponse;
26use axum::routing::*;
27use axum::{Json, Router};
28pub use builder::*;
29use chrono::Utc;
30pub use generation::*;
31use mockforge_foundation::ai_response::RequestContext;
32use mockforge_foundation::error::{Error, Result};
33use mockforge_foundation::latency::LatencyInjector;
34use mockforge_foundation::response_generation_trace::ResponseGenerationTrace;
35use mockforge_foundation::schema_diff::validation_diff;
36use once_cell::sync::Lazy;
37use serde_json::{json, Map, Value};
38use std::collections::{HashMap, HashSet, VecDeque};
39use std::sync::{Arc, Mutex};
40use tracing;
41pub use validation::*;
42
43#[derive(Clone)]
45pub struct OpenApiRouteRegistry {
46 spec: Arc<OpenApiSpec>,
48 routes: Vec<OpenApiRoute>,
50 options: ValidationOptions,
52 honor_serve_validation_policy: bool,
54 custom_fixture_loader: Option<Arc<crate::custom_fixture::CustomFixtureLoader>>,
56 overrides: Option<Arc<dyn OverrideHook>>,
58 isolated: bool,
59}
60
61#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)]
63pub enum ValidationMode {
64 Disabled,
66 #[default]
68 Warn,
69 Enforce,
71}
72
73#[derive(Debug, Clone)]
75pub struct ValidationOptions {
76 pub request_mode: ValidationMode,
78 pub aggregate_errors: bool,
80 pub validate_responses: bool,
82 pub overrides: HashMap<String, ValidationMode>,
84 pub admin_skip_prefixes: Vec<String>,
86 pub response_template_expand: bool,
88 pub validation_status: Option<u16>,
90}
91
92impl Default for ValidationOptions {
93 fn default() -> Self {
94 Self {
95 request_mode: ValidationMode::Enforce,
96 aggregate_errors: true,
97 validate_responses: false,
98 overrides: HashMap::new(),
99 admin_skip_prefixes: Vec::new(),
100 response_template_expand: false,
101 validation_status: None,
102 }
103 }
104}
105
106#[derive(Clone)]
111pub struct RouterContext {
112 pub custom_fixture_loader: Option<Arc<crate::custom_fixture::CustomFixtureLoader>>,
114 pub latency_injector: Option<LatencyInjector>,
116 pub failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
118 pub response_rewriter: Option<Arc<dyn ResponseRewriter>>,
121 pub overrides: Option<Arc<dyn OverrideHook>>,
123 pub ai_generator: Option<Arc<dyn AiGenerator + Send + Sync>>,
125 pub mockai: Option<
129 Arc<
130 tokio::sync::RwLock<
131 dyn mockforge_foundation::intelligent_behavior::MockAiBehavior + Send + Sync,
132 >,
133 >,
134 >,
135 pub enable_full_validation: bool,
137 pub enable_template_expand: bool,
139 pub add_spec_endpoint: bool,
141}
142
143impl Default for RouterContext {
144 fn default() -> Self {
145 Self {
146 custom_fixture_loader: None,
147 latency_injector: None,
148 failure_injector: None,
149 response_rewriter: None,
150 overrides: None,
151 ai_generator: None,
152 mockai: None,
153 enable_full_validation: false,
154 enable_template_expand: false,
155 add_spec_endpoint: true,
156 }
157 }
158}
159
160fn openapi_body_limit_bytes() -> usize {
175 const DEFAULT_MB: usize = 50;
176 std::env::var("MOCKFORGE_HTTP_BODY_LIMIT_MB")
177 .ok()
178 .and_then(|v| v.parse::<usize>().ok())
179 .unwrap_or(DEFAULT_MB)
180 .saturating_mul(1024 * 1024)
181}
182
183impl OpenApiRouteRegistry {
184 pub fn new(spec: OpenApiSpec) -> Self {
186 Self::new_with_env(spec)
187 }
188
189 pub fn new_with_env(spec: OpenApiSpec) -> Self {
198 Self::new_with_env_and_persona(spec, None)
199 }
200
201 pub fn new_with_env_and_persona(
203 spec: OpenApiSpec,
204 persona: Option<Arc<mockforge_foundation::intelligent_behavior::Persona>>,
205 ) -> Self {
206 tracing::debug!("Creating OpenAPI route registry");
207 let spec = Arc::new(spec);
208 let routes = Self::generate_routes_with_persona(&spec, persona);
209 let options = ValidationOptions {
210 request_mode: match std::env::var("MOCKFORGE_REQUEST_VALIDATION")
211 .unwrap_or_else(|_| "enforce".into())
212 .to_ascii_lowercase()
213 .as_str()
214 {
215 "off" | "disable" | "disabled" => ValidationMode::Disabled,
216 "warn" | "warning" => ValidationMode::Warn,
217 _ => ValidationMode::Enforce,
218 },
219 aggregate_errors: std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
220 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
221 .unwrap_or(true),
222 validate_responses: std::env::var("MOCKFORGE_RESPONSE_VALIDATION")
223 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
224 .unwrap_or(false),
225 overrides: HashMap::new(),
226 admin_skip_prefixes: Vec::new(),
227 response_template_expand: std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
228 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
229 .unwrap_or(false),
230 validation_status: std::env::var("MOCKFORGE_VALIDATION_STATUS")
231 .ok()
232 .and_then(|s| s.parse::<u16>().ok()),
233 };
234 Self {
235 spec,
236 routes,
237 options,
238 honor_serve_validation_policy: true,
239 custom_fixture_loader: None,
240 overrides: None,
241 isolated: false,
242 }
243 }
244
245 pub fn new_for_contract_verification(spec: OpenApiSpec) -> Self {
251 let options = ValidationOptions {
252 validation_status: Some(400),
253 ..Default::default()
254 };
255 let mut registry = Self::new_with_options(spec, options);
256 registry.honor_serve_validation_policy = false;
257 registry
258 }
259
260 pub fn new_with_options(spec: OpenApiSpec, options: ValidationOptions) -> Self {
262 Self::new_with_options_and_persona(spec, options, None)
263 }
264
265 pub fn new_with_options_and_persona(
267 spec: OpenApiSpec,
268 options: ValidationOptions,
269 persona: Option<Arc<mockforge_foundation::intelligent_behavior::Persona>>,
270 ) -> Self {
271 tracing::debug!("Creating OpenAPI route registry with custom options");
272 let spec = Arc::new(spec);
273 let routes = Self::generate_routes_with_persona(&spec, persona);
274 Self {
275 spec,
276 routes,
277 options,
278 honor_serve_validation_policy: true,
279 custom_fixture_loader: None,
280 overrides: None,
281 isolated: false,
282 }
283 }
284
285 pub fn with_custom_fixture_loader(
287 mut self,
288 loader: Arc<crate::custom_fixture::CustomFixtureLoader>,
289 ) -> Self {
290 self.custom_fixture_loader = Some(loader);
291 self
292 }
293
294 pub fn with_overrides(mut self, overrides: Arc<dyn OverrideHook>) -> Self {
296 self.overrides = Some(overrides);
297 self
298 }
299
300 pub fn clone_for_validation(&self) -> Self {
305 OpenApiRouteRegistry {
306 spec: self.spec.clone(),
307 routes: self.routes.clone(),
308 options: self.options.clone(),
309 honor_serve_validation_policy: self.honor_serve_validation_policy,
310 custom_fixture_loader: self.custom_fixture_loader.clone(),
311 overrides: self.overrides.clone(),
312 isolated: self.isolated,
313 }
314 }
315
316 fn generate_routes_with_persona(
318 spec: &Arc<OpenApiSpec>,
319 persona: Option<Arc<mockforge_foundation::intelligent_behavior::Persona>>,
320 ) -> Vec<OpenApiRoute> {
321 let mut routes = Vec::new();
322
323 let all_paths_ops = spec.all_paths_and_operations();
324 tracing::debug!("Generating routes from OpenAPI spec with {} paths", all_paths_ops.len());
325
326 for (path, operations) in all_paths_ops {
327 tracing::debug!("Processing path: {}", path);
328 for (method, operation) in operations {
329 routes.push(OpenApiRoute::from_operation_with_persona(
330 &method,
331 path.clone(),
332 &operation,
333 spec.clone(),
334 persona.clone(),
335 ));
336 }
337 }
338
339 tracing::debug!("Generated {} total routes from OpenAPI spec", routes.len());
340 routes
341 }
342
343 pub fn routes(&self) -> &[OpenApiRoute] {
345 &self.routes
346 }
347
348 pub fn spec(&self) -> &OpenApiSpec {
350 &self.spec
351 }
352
353 fn normalize_path_for_dedup(path: &str) -> String {
357 let mut result = String::with_capacity(path.len());
358 let mut in_brace = false;
359 for ch in path.chars() {
360 if ch == '{' {
361 in_brace = true;
362 result.push_str("{_}");
363 } else if ch == '}' {
364 in_brace = false;
365 } else if !in_brace {
366 result.push(ch);
367 }
368 }
369 result
370 }
371
372 fn deduplicated_routes(&self) -> Vec<(String, &OpenApiRoute)> {
378 let mut result = Vec::new();
379 let mut registered_routes: HashSet<(String, String)> = HashSet::new();
380 let mut canonical_paths: HashMap<String, String> = HashMap::new();
381
382 for route in &self.routes {
383 if !route.is_valid_axum_path() {
384 tracing::warn!(
385 "Skipping route with unsupported path syntax: {} {}",
386 route.method,
387 route.path
388 );
389 continue;
390 }
391 let axum_path = route.axum_path();
392 let normalized = Self::normalize_path_for_dedup(&axum_path);
393 let axum_path = canonical_paths
394 .entry(normalized.clone())
395 .or_insert_with(|| axum_path.clone())
396 .clone();
397 let route_key = (route.method.clone(), normalized);
398 if !registered_routes.insert(route_key) {
399 tracing::debug!(
400 "Skipping duplicate route: {} {} (axum path: {})",
401 route.method,
402 route.path,
403 axum_path
404 );
405 continue;
406 }
407 result.push((axum_path, route));
408 }
409 result
410 }
411
412 fn route_for_method<H, T>(router: Router, path: &str, method: &str, handler: H) -> Router
416 where
417 H: axum::handler::Handler<T, ()>,
418 T: 'static,
419 {
420 match method {
421 "GET" => router.route(path, get(handler)),
422 "POST" => router.route(path, post(handler)),
423 "PUT" => router.route(path, put(handler)),
424 "DELETE" => router.route(path, delete(handler)),
425 "PATCH" => router.route(path, patch(handler)),
426 "HEAD" => router.route(path, head(handler)),
427 "OPTIONS" => router.route(path, options(handler)),
428 _ => router,
429 }
430 }
431
432 pub fn build_router(self) -> Router {
434 let ctx = RouterContext {
435 custom_fixture_loader: self.custom_fixture_loader.clone(),
436 overrides: self.overrides.clone(),
437 enable_full_validation: true,
438 enable_template_expand: true,
439 add_spec_endpoint: true,
440 ..Default::default()
441 };
442 self.build_router_with_context(ctx)
443 }
444
445 pub fn build_hosted_router(mut self) -> Router {
449 self.isolated = true;
450 self.options = ValidationOptions::default();
451 self.custom_fixture_loader = None;
452 self.overrides = None;
453 let ctx = RouterContext {
454 enable_full_validation: true,
455 add_spec_endpoint: false,
456 ..Default::default()
457 };
458 self.build_router_with_context(ctx)
459 }
460
461 fn environment_value(&self, name: &str) -> Option<String> {
462 if self.isolated {
463 None
464 } else {
465 std::env::var(name).ok()
466 }
467 }
468
469 fn build_router_with_context(self, ctx: RouterContext) -> Router {
474 let mut router = Router::new();
475 tracing::debug!("Building router from {} routes", self.routes.len());
476
477 let deduped = self.deduplicated_routes();
478 let ctx = Arc::new(ctx);
479 let validator = Arc::new(self.clone_for_validation());
487 for (axum_path, route) in &deduped {
488 tracing::debug!("Adding route: {} {}", route.method, route.path);
489 let operation = route.operation.clone();
490 let method = route.method.clone();
491 let path_template = route.path.clone();
492 let validator = validator.clone();
493 let route_clone = (*route).clone();
494 let ctx = ctx.clone();
495
496 let mut operation_tags = operation.tags.clone();
498 if let Some(operation_id) = &operation.operation_id {
499 operation_tags.push(operation_id.clone());
500 }
501
502 let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
504 RawQuery(raw_query): RawQuery,
505 headers: HeaderMap,
506 body: axum::body::Bytes| async move {
507 tracing::debug!("Handling OpenAPI request: {} {}", method, path_template);
508
509 if let Some(ref loader) = ctx.custom_fixture_loader {
511 use crate::request_fingerprint::RequestFingerprint;
512 use axum::http::{Method, Uri};
513
514 let mut request_path = path_template.clone();
516 for (key, value) in &path_params {
517 request_path = request_path.replace(&format!("{{{}}}", key), value);
518 }
519
520 let normalized_request_path =
522 crate::custom_fixture::CustomFixtureLoader::normalize_path(&request_path);
523
524 let query_string =
526 raw_query.as_ref().map(|q| q.to_string()).unwrap_or_default();
527
528 let uri_str = if query_string.is_empty() {
531 normalized_request_path.clone()
532 } else {
533 format!("{}?{}", normalized_request_path, query_string)
534 };
535
536 if let Ok(uri) = uri_str.parse::<Uri>() {
537 let http_method =
538 Method::from_bytes(method.as_bytes()).unwrap_or(Method::GET);
539 let body_slice = if body.is_empty() {
540 None
541 } else {
542 Some(body.as_ref())
543 };
544 let fingerprint =
545 RequestFingerprint::new(http_method, &uri, &headers, body_slice);
546
547 tracing::debug!(
549 "Checking fixture for {} {} (template: '{}', request_path: '{}', normalized: '{}', fingerprint.path: '{}')",
550 method,
551 path_template,
552 path_template,
553 request_path,
554 normalized_request_path,
555 fingerprint.path
556 );
557
558 if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
559 tracing::debug!(
560 "Using custom fixture for {} {}",
561 method,
562 path_template
563 );
564
565 if custom_fixture.delay_ms > 0 {
567 tokio::time::sleep(tokio::time::Duration::from_millis(
568 custom_fixture.delay_ms,
569 ))
570 .await;
571 }
572
573 let response_body = if custom_fixture.response.is_string() {
575 custom_fixture.response.as_str().unwrap().to_string()
576 } else {
577 serde_json::to_string(&custom_fixture.response)
578 .unwrap_or_else(|_| "{}".to_string())
579 };
580
581 let json_value: Value = serde_json::from_str(&response_body)
583 .unwrap_or_else(|_| serde_json::json!({}));
584
585 let status = axum::http::StatusCode::from_u16(custom_fixture.status)
587 .unwrap_or(axum::http::StatusCode::OK);
588
589 let mut response = (status, Json(json_value)).into_response();
590
591 let response_headers = response.headers_mut();
593 for (key, value) in &custom_fixture.headers {
594 if let (Ok(header_name), Ok(header_value)) = (
595 axum::http::HeaderName::from_bytes(key.as_bytes()),
596 axum::http::HeaderValue::from_str(value),
597 ) {
598 response_headers.insert(header_name, header_value);
599 }
600 }
601
602 if !custom_fixture.headers.contains_key("content-type") {
604 response_headers.insert(
605 axum::http::header::CONTENT_TYPE,
606 axum::http::HeaderValue::from_static("application/json"),
607 );
608 }
609
610 return response;
611 }
612 }
613 }
614
615 if let Some(ref failure_injector) = ctx.failure_injector {
617 if let Some((status_code, error_message)) =
618 failure_injector.process_request(&operation_tags)
619 {
620 let payload = serde_json::json!({
621 "error": error_message,
622 "injected_failure": true
623 });
624 let body_bytes = serde_json::to_vec(&payload)
625 .unwrap_or_else(|_| br#"{"error":"injected failure"}"#.to_vec());
626 return axum::http::Response::builder()
627 .status(
628 axum::http::StatusCode::from_u16(status_code)
629 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
630 )
631 .header(axum::http::header::CONTENT_TYPE, "application/json")
632 .body(axum::body::Body::from(body_bytes))
633 .expect("Response builder should create valid response");
634 }
635 }
636
637 if let Some(ref injector) = ctx.latency_injector {
639 if let Err(e) = injector.inject_latency(&operation_tags).await {
640 tracing::warn!("Failed to inject latency: {}", e);
641 }
642 }
643
644 let scenario = headers
646 .get("X-Mockforge-Scenario")
647 .and_then(|v| v.to_str().ok())
648 .map(|s| s.to_string())
649 .or_else(|| validator.environment_value("MOCKFORGE_HTTP_SCENARIO"));
650
651 let status_override = headers
652 .get("X-Mockforge-Response-Status")
653 .and_then(|v| v.to_str().ok())
654 .and_then(|s| s.parse::<u16>().ok());
655
656 let (selected_status, mock_response) = if validator.isolated {
658 route_clone.hosted_response(scenario.as_deref(), status_override)
659 } else {
660 route_clone.mock_response_with_status_and_scenario_and_override(
661 scenario.as_deref(),
662 status_override,
663 )
664 };
665
666 if ctx.enable_full_validation {
668 let mut path_map = Map::new();
670 for (k, v) in &path_params {
671 path_map.insert(k.clone(), Value::String(v.clone()));
672 }
673
674 let mut query_map = Map::new();
676 if let Some(ref q) = raw_query {
677 for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
678 query_map.insert(k.to_string(), Value::String(v.to_string()));
679 }
680 }
681
682 let mut header_map = Map::new();
684 for p_ref in &operation.parameters {
685 if let Some(openapiv3::Parameter::Header { parameter_data, .. }) =
686 p_ref.as_item()
687 {
688 let name_lc = parameter_data.name.to_ascii_lowercase();
689 if let Ok(hn) = axum::http::HeaderName::from_bytes(name_lc.as_bytes()) {
690 if let Some(val) = headers.get(hn) {
691 if let Ok(s) = val.to_str() {
692 header_map.insert(
693 parameter_data.name.clone(),
694 Value::String(s.to_string()),
695 );
696 }
697 }
698 }
699 }
700 }
701
702 let mut cookie_map = Map::new();
704 if let Some(val) = headers.get(axum::http::header::COOKIE) {
705 if let Ok(s) = val.to_str() {
706 for part in s.split(';') {
707 let part = part.trim();
708 if let Some((k, v)) = part.split_once('=') {
709 cookie_map.insert(k.to_string(), Value::String(v.to_string()));
710 }
711 }
712 }
713 }
714
715 let is_multipart = headers
717 .get(axum::http::header::CONTENT_TYPE)
718 .and_then(|v| v.to_str().ok())
719 .map(|ct| ct.starts_with("multipart/form-data"))
720 .unwrap_or(false);
721
722 #[allow(unused_assignments)]
724 let mut multipart_fields = HashMap::new();
725 let mut _multipart_files = HashMap::new();
726 let mut body_json: Option<Value> = None;
727
728 if is_multipart {
729 match extract_multipart_from_bytes(&body, &headers).await {
731 Ok((fields, files)) => {
732 multipart_fields = fields;
733 _multipart_files = files;
734 let mut body_obj = Map::new();
736 for (k, v) in &multipart_fields {
737 body_obj.insert(k.clone(), v.clone());
738 }
739 if !body_obj.is_empty() {
740 body_json = Some(Value::Object(body_obj));
741 }
742 }
743 Err(e) => {
744 tracing::warn!("Failed to parse multipart data: {}", e);
745 }
746 }
747 } else {
748 body_json = if !body.is_empty() {
750 serde_json::from_slice(&body).ok()
751 } else {
752 None
753 };
754 }
755
756 let actual_ct =
769 headers.get(axum::http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok());
770 if let Err(ct_err) =
771 validator.check_request_content_type(&path_template, &method, actual_ct)
772 {
773 let status_code =
774 validator.options.validation_status.unwrap_or_else(|| {
775 validator
776 .environment_value("MOCKFORGE_VALIDATION_STATUS")
777 .and_then(|s| s.parse::<u16>().ok())
778 .unwrap_or(415)
779 });
780 let (client_mockforge_version, client_sent_at) =
781 mockforge_foundation::conformance_violations::read_client_stamps(
782 |name| {
783 headers
784 .get(name)
785 .and_then(|v| v.to_str().ok())
786 .map(|s| s.to_string())
787 },
788 );
789 if !validator.isolated {
790 mockforge_foundation::conformance_violations::record(
791 mockforge_foundation::conformance_violations::ServerConformanceViolation {
792 timestamp: Utc::now(),
793 method: method.to_string(),
794 path: path_template.clone(),
795 client_ip: "unknown".to_string(),
796 status: status_code,
797 reason: ct_err.clone(),
798 category: "content-types".to_string(),
799 occurrences: 1,
800 client_mockforge_version,
801 client_sent_at,
802 summary: String::new(),
803 categories: Vec::new(),
804 },
805 );
806 }
807 let status = axum::http::StatusCode::from_u16(status_code)
808 .unwrap_or(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
809 let payload = serde_json::json!({
810 "error": "content_type_not_allowed",
811 "message": ct_err,
812 });
813 let body_bytes = serde_json::to_vec(&payload)
814 .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
815 return axum::response::Response::builder()
816 .status(status)
817 .header("content-type", "application/json")
818 .body(axum::body::Body::from(body_bytes))
819 .unwrap_or_else(|_| {
820 axum::response::Response::builder()
821 .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
822 .body(axum::body::Body::empty())
823 .unwrap()
824 });
825 }
826
827 if let Err(e) = validator.validate_request_with_all_ex(
831 &path_template,
832 &method,
833 &path_map,
834 &query_map,
835 &header_map,
836 &cookie_map,
837 body_json.as_ref(),
838 !body.is_empty(),
839 ) {
840 let status_code =
842 validator.options.validation_status.unwrap_or_else(|| {
843 validator
844 .environment_value("MOCKFORGE_VALIDATION_STATUS")
845 .and_then(|s| s.parse::<u16>().ok())
846 .unwrap_or(400)
847 });
848
849 let payload = if status_code == 422 {
850 generate_enhanced_422_response(
852 &validator,
853 &path_template,
854 &method,
855 body_json.as_ref(),
856 &path_map,
857 &query_map,
858 &header_map,
859 &cookie_map,
860 )
861 } else {
862 let msg = format!("{}", e);
864 let detail_val = serde_json::from_str::<Value>(&msg)
865 .unwrap_or(serde_json::json!(msg));
866 json!({
867 "error": "request validation failed",
868 "detail": detail_val,
869 "method": method,
870 "path": path_template,
871 "timestamp": Utc::now().to_rfc3339(),
872 })
873 };
874
875 if !validator.isolated {
876 record_validation_error(&payload);
877 }
878
879 let reason = payload
886 .get("detail")
887 .and_then(|d| {
888 if d.is_string() {
889 d.as_str().map(|s| s.to_string())
890 } else {
891 serde_json::to_string(d).ok()
892 }
893 })
894 .unwrap_or_else(|| {
895 payload
896 .get("error")
897 .and_then(|v| v.as_str())
898 .unwrap_or("request validation failed")
899 .to_string()
900 });
901 let category = classify_validation_reason(&reason);
902 let (client_mockforge_version, client_sent_at) =
903 mockforge_foundation::conformance_violations::read_client_stamps(
904 |name| {
905 headers
906 .get(name)
907 .and_then(|v| v.to_str().ok())
908 .map(|s| s.to_string())
909 },
910 );
911 if !validator.isolated {
912 mockforge_foundation::conformance_violations::record(
913 mockforge_foundation::conformance_violations::ServerConformanceViolation {
914 timestamp: Utc::now(),
915 method: method.to_string(),
916 path: path_template.clone(),
917 client_ip: "unknown".to_string(),
918 status: status_code,
919 reason,
920 category,
921 occurrences: 1,
922 client_mockforge_version,
923 client_sent_at,
924 summary: String::new(),
925 categories: Vec::new(),
926 },
927 );
928 }
929
930 let status = axum::http::StatusCode::from_u16(status_code)
931 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
932
933 let body_bytes = serde_json::to_vec(&payload)
935 .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
936
937 return axum::http::Response::builder()
938 .status(status)
939 .header(axum::http::header::CONTENT_TYPE, "application/json")
940 .body(axum::body::Body::from(body_bytes))
941 .expect("Response builder should create valid response with valid headers and body");
942 }
943 }
944
945 let mut final_response = mock_response.clone();
954 let env_expand: Option<bool> = validator
955 .environment_value("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
956 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"));
957 let expand = match env_expand {
958 Some(v) => v,
959 None => {
960 ctx.enable_template_expand || validator.options.response_template_expand
961 }
962 };
963 if expand {
964 if let Some(ref rewriter) = ctx.response_rewriter {
965 rewriter.expand_tokens(&mut final_response);
966 }
967 }
968
969 if let Some(ref overrides) = ctx.overrides {
971 let request = OverrideRequest {
972 method: &method,
973 path_template: &path_template,
974 operation_id: operation.operation_id.as_deref().unwrap_or_default(),
975 tags: &operation_tags,
976 headers: &headers,
977 query: raw_query.as_deref(),
978 body: &body,
979 };
980 overrides.apply(&request, &mut final_response);
981 }
982
983 if ctx.enable_full_validation {
985 if validator.options.validate_responses {
987 if let Some((status_code, _response)) = operation
989 .responses
990 .responses
991 .iter()
992 .filter_map(|(status, resp)| match status {
993 openapiv3::StatusCode::Code(code)
994 if *code >= 200 && *code < 300 =>
995 {
996 resp.as_item().map(|r| ((*code), r))
997 }
998 openapiv3::StatusCode::Range(range)
999 if *range >= 200 && *range < 300 =>
1000 {
1001 resp.as_item().map(|r| (200, r))
1002 }
1003 _ => None,
1004 })
1005 .next()
1006 {
1007 if serde_json::from_value::<Value>(final_response.clone()).is_err() {
1009 tracing::warn!(
1010 "Response validation failed: invalid JSON for status {}",
1011 status_code
1012 );
1013 }
1014 }
1015 }
1016
1017 let mut trace = ResponseGenerationTrace::new();
1019 trace.set_final_payload(final_response.clone());
1020
1021 if let Some((_status_code, response_ref)) = operation
1023 .responses
1024 .responses
1025 .iter()
1026 .filter_map(|(status, resp)| match status {
1027 openapiv3::StatusCode::Code(code) if *code == selected_status => {
1028 resp.as_item().map(|r| ((*code), r))
1029 }
1030 openapiv3::StatusCode::Range(range)
1031 if *range >= 200 && *range < 300 =>
1032 {
1033 resp.as_item().map(|r| (200, r))
1034 }
1035 _ => None,
1036 })
1037 .next()
1038 .or_else(|| {
1039 operation
1041 .responses
1042 .responses
1043 .iter()
1044 .filter_map(|(status, resp)| match status {
1045 openapiv3::StatusCode::Code(code)
1046 if *code >= 200 && *code < 300 =>
1047 {
1048 resp.as_item().map(|r| ((*code), r))
1049 }
1050 _ => None,
1051 })
1052 .next()
1053 })
1054 {
1055 let response_item = response_ref;
1057 if let Some(content) = response_item.content.get("application/json") {
1059 if let Some(schema_ref) = &content.schema {
1060 if let Some(schema) = schema_ref.as_item() {
1062 if let Ok(schema_json) = serde_json::to_value(schema) {
1063 let validation_errors =
1065 validation_diff(&schema_json, &final_response);
1066 trace.set_schema_validation_diff(validation_errors);
1067 }
1068 }
1069 }
1070 }
1071 }
1072
1073 let mut response = Json(final_response).into_response();
1075 response.extensions_mut().insert(trace);
1076 *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
1077 .unwrap_or(axum::http::StatusCode::OK);
1078 inject_spec_response_headers(&mut response, &route_clone, selected_status);
1079 return response;
1080 }
1081
1082 let mut response = Json(final_response).into_response();
1084 *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
1085 .unwrap_or(axum::http::StatusCode::OK);
1086 inject_spec_response_headers(&mut response, &route_clone, selected_status);
1087 response
1088 };
1089
1090 router = Self::route_for_method(router, axum_path, &route.method, handler);
1091 }
1092
1093 if ctx.add_spec_endpoint {
1095 let spec_json = serde_json::to_value(&self.spec.spec).unwrap_or(Value::Null);
1096 router = router.route("/openapi.json", get(move || async move { Json(spec_json) }));
1097 }
1098
1099 let body_limit = if self.isolated {
1103 10 * 1024 * 1024
1104 } else {
1105 openapi_body_limit_bytes()
1106 };
1107 router.layer(DefaultBodyLimit::max(body_limit))
1108 }
1109
1110 pub fn build_router_with_latency(self, latency_injector: LatencyInjector) -> Router {
1112 self.build_router_with_injectors(latency_injector, None)
1113 }
1114
1115 pub fn build_router_with_injectors(
1117 self,
1118 latency_injector: LatencyInjector,
1119 failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1120 ) -> Router {
1121 self.build_router_with_injectors_and_rewriter(latency_injector, failure_injector, None)
1122 }
1123
1124 pub fn build_router_with_injectors_and_rewriter(
1128 self,
1129 latency_injector: LatencyInjector,
1130 failure_injector: Option<mockforge_foundation::failure_injection::FailureInjector>,
1131 response_rewriter: Option<Arc<dyn ResponseRewriter>>,
1132 ) -> Router {
1133 let ctx = RouterContext {
1134 custom_fixture_loader: self.custom_fixture_loader.clone(),
1135 overrides: self.overrides.clone(),
1136 latency_injector: Some(latency_injector),
1137 failure_injector,
1138 response_rewriter,
1139 enable_full_validation: true,
1140 enable_template_expand: true,
1141 add_spec_endpoint: true,
1142 ..Default::default()
1143 };
1144 self.build_router_with_context(ctx)
1145 }
1146
1147 pub fn get_route(&self, path: &str, method: &str) -> Option<&OpenApiRoute> {
1149 self.routes.iter().find(|route| route.path == path && route.method == method)
1150 }
1151
1152 pub fn get_routes_for_path(&self, path: &str) -> Vec<&OpenApiRoute> {
1154 self.routes.iter().filter(|route| route.path == path).collect()
1155 }
1156
1157 pub fn validate_request(&self, path: &str, method: &str, body: Option<&Value>) -> Result<()> {
1159 self.validate_request_with(path, method, &Map::new(), &Map::new(), body)
1160 }
1161
1162 pub fn check_request_content_type(
1178 &self,
1179 path: &str,
1180 method: &str,
1181 actual_content_type: Option<&str>,
1182 ) -> std::result::Result<(), String> {
1183 let Some(route) = self.get_route(path, method) else {
1184 return Ok(());
1185 };
1186 let Some(rb_ref) = &route.operation.request_body else {
1187 return Ok(());
1188 };
1189 let request_body = match rb_ref {
1190 openapiv3::ReferenceOr::Item(rb) => rb,
1191 openapiv3::ReferenceOr::Reference { reference } => {
1192 let resolved = self
1193 .spec
1194 .spec
1195 .components
1196 .as_ref()
1197 .and_then(|components| {
1198 components
1199 .request_bodies
1200 .get(reference.trim_start_matches("#/components/requestBodies/"))
1201 })
1202 .and_then(|rb_ref| rb_ref.as_item());
1203 let Some(rb) = resolved else { return Ok(()) };
1204 rb
1205 }
1206 };
1207 if request_body.content.is_empty() {
1208 return Ok(());
1209 }
1210 let actual = actual_content_type
1211 .and_then(|s| s.split(';').next())
1212 .map(|s| s.trim().to_ascii_lowercase());
1213 let Some(actual) = actual else {
1214 return Ok(());
1219 };
1220 let allowed: Vec<String> = request_body
1221 .content
1222 .keys()
1223 .map(|k| k.split(';').next().unwrap_or(k).trim().to_ascii_lowercase())
1224 .collect();
1225 if allowed.iter().any(|a| a == &actual) {
1226 return Ok(());
1227 }
1228 Err(format!(
1229 "Content-Type '{actual}' not allowed; spec declares: [{}]",
1230 allowed.join(", ")
1231 ))
1232 }
1233
1234 pub fn validate_request_with(
1236 &self,
1237 path: &str,
1238 method: &str,
1239 path_params: &Map<String, Value>,
1240 query_params: &Map<String, Value>,
1241 body: Option<&Value>,
1242 ) -> Result<()> {
1243 self.validate_request_with_all(
1244 path,
1245 method,
1246 path_params,
1247 query_params,
1248 &Map::new(),
1249 &Map::new(),
1250 body,
1251 )
1252 }
1253
1254 #[allow(clippy::too_many_arguments)]
1267 pub fn run_validation_with_recording(
1268 &self,
1269 path_template: &str,
1270 method: &str,
1271 path_params: &Map<String, Value>,
1272 query_params: &Map<String, Value>,
1273 header_map: &Map<String, Value>,
1274 cookie_map: &Map<String, Value>,
1275 body: Option<&Value>,
1276 ) -> std::result::Result<(), (u16, Value)> {
1277 let body_present = body.is_some();
1278 self.run_validation_with_recording_ex(
1279 path_template,
1280 method,
1281 path_params,
1282 query_params,
1283 header_map,
1284 cookie_map,
1285 body,
1286 body_present,
1287 )
1288 }
1289
1290 #[allow(clippy::too_many_arguments)]
1294 pub fn run_validation_with_recording_ex(
1295 &self,
1296 path_template: &str,
1297 method: &str,
1298 path_params: &Map<String, Value>,
1299 query_params: &Map<String, Value>,
1300 header_map: &Map<String, Value>,
1301 cookie_map: &Map<String, Value>,
1302 body: Option<&Value>,
1303 body_present: bool,
1304 ) -> std::result::Result<(), (u16, Value)> {
1305 let e = match self.validate_request_with_all_ex(
1306 path_template,
1307 method,
1308 path_params,
1309 query_params,
1310 header_map,
1311 cookie_map,
1312 body,
1313 body_present,
1314 ) {
1315 Ok(()) => {
1316 mockforge_foundation::conformance_violations::record_ok();
1320 return Ok(());
1321 }
1322 Err(e) => e,
1323 };
1324
1325 let status_code = self.options.validation_status.unwrap_or_else(|| {
1326 std::env::var("MOCKFORGE_VALIDATION_STATUS")
1327 .ok()
1328 .and_then(|s| s.parse::<u16>().ok())
1329 .unwrap_or(400)
1330 });
1331
1332 let payload = if status_code == 422 {
1333 generate_enhanced_422_response(
1334 self,
1335 path_template,
1336 method,
1337 body,
1338 path_params,
1339 query_params,
1340 header_map,
1341 cookie_map,
1342 )
1343 } else {
1344 let msg = format!("{}", e);
1345 let detail_val = serde_json::from_str::<Value>(&msg).unwrap_or(serde_json::json!(msg));
1346 json!({
1347 "error": "request validation failed",
1348 "detail": detail_val,
1349 "method": method,
1350 "path": path_template,
1351 "timestamp": Utc::now().to_rfc3339(),
1352 })
1353 };
1354
1355 record_validation_error(&payload);
1356
1357 let reason = payload
1358 .get("detail")
1359 .and_then(|d| {
1360 if d.is_string() {
1361 d.as_str().map(|s| s.to_string())
1362 } else {
1363 serde_json::to_string(d).ok()
1364 }
1365 })
1366 .unwrap_or_else(|| {
1367 payload
1368 .get("error")
1369 .and_then(|v| v.as_str())
1370 .unwrap_or("request validation failed")
1371 .to_string()
1372 });
1373 let category = classify_validation_reason(&reason);
1374 tracing::debug!(
1382 target: "mockforge::conformance",
1383 method = %method,
1384 path = %path_template,
1385 status = status_code,
1386 category = %category,
1387 reason = %reason,
1388 "request conformance violation"
1389 );
1390 let (client_mockforge_version, client_sent_at) =
1391 mockforge_foundation::conformance_violations::read_client_stamps(|name| {
1392 header_map
1393 .iter()
1394 .find(|(k, _)| k.eq_ignore_ascii_case(name))
1395 .and_then(|(_, v)| v.as_str().map(|s| s.to_string()))
1396 });
1397
1398 let per_location: Vec<(String, String)> =
1405 validation_details(&payload).iter().filter_map(split_detail_entry).collect();
1406
1407 if per_location.is_empty() {
1408 mockforge_foundation::conformance_violations::record(
1409 mockforge_foundation::conformance_violations::ServerConformanceViolation {
1410 timestamp: Utc::now(),
1411 method: method.to_string(),
1412 path: path_template.to_string(),
1413 client_ip: "unknown".to_string(),
1414 status: status_code,
1415 reason,
1416 category,
1417 occurrences: 1,
1418 client_mockforge_version,
1419 client_sent_at,
1420 summary: String::new(),
1421 categories: Vec::new(),
1422 },
1423 );
1424 } else {
1425 for (loc_category, loc_reason) in per_location {
1426 tracing::debug!(
1427 target: "mockforge::conformance",
1428 method = %method,
1429 path = %path_template,
1430 status = status_code,
1431 category = %loc_category,
1432 reason = %loc_reason,
1433 "request conformance violation (per-location)"
1434 );
1435 mockforge_foundation::conformance_violations::record(
1436 mockforge_foundation::conformance_violations::ServerConformanceViolation {
1437 timestamp: Utc::now(),
1438 method: method.to_string(),
1439 path: path_template.to_string(),
1440 client_ip: "unknown".to_string(),
1441 status: status_code,
1442 reason: loc_reason,
1443 category: loc_category.clone(),
1444 occurrences: 1,
1445 client_mockforge_version: client_mockforge_version.clone(),
1446 client_sent_at,
1447 summary: String::new(),
1448 categories: vec![loc_category],
1449 },
1450 );
1451 }
1452 }
1453
1454 if self.honor_serve_validation_policy
1461 && mockforge_foundation::unknown_paths::shadow_mode_enabled()
1462 {
1463 return Ok(());
1464 }
1465
1466 Err((status_code, payload))
1467 }
1468
1469 #[allow(clippy::too_many_arguments)]
1476 pub fn validate_request_with_all(
1477 &self,
1478 path: &str,
1479 method: &str,
1480 path_params: &Map<String, Value>,
1481 query_params: &Map<String, Value>,
1482 header_params: &Map<String, Value>,
1483 cookie_params: &Map<String, Value>,
1484 body: Option<&Value>,
1485 ) -> Result<()> {
1486 let body_present = body.is_some();
1487 self.validate_request_with_all_ex(
1488 path,
1489 method,
1490 path_params,
1491 query_params,
1492 header_params,
1493 cookie_params,
1494 body,
1495 body_present,
1496 )
1497 }
1498
1499 #[allow(clippy::too_many_arguments)]
1511 pub fn validate_request_with_all_ex(
1512 &self,
1513 path: &str,
1514 method: &str,
1515 path_params: &Map<String, Value>,
1516 query_params: &Map<String, Value>,
1517 header_params: &Map<String, Value>,
1518 cookie_params: &Map<String, Value>,
1519 body: Option<&Value>,
1520 body_present: bool,
1521 ) -> Result<()> {
1522 let Some((aggregate, effective_mode)) = self.request_validation_policy(path, method) else {
1523 return Ok(());
1524 };
1525 if matches!(effective_mode, ValidationMode::Disabled) {
1526 return Ok(());
1527 }
1528 if let Some(route) = self.get_route(path, method) {
1529 if matches!(effective_mode, ValidationMode::Disabled) {
1530 return Ok(());
1531 }
1532 let mut errors: Vec<String> = Vec::new();
1533 let mut details: Vec<Value> = Vec::new();
1534 if let Some(schema) = &route.operation.request_body {
1536 let request_body = match schema {
1539 openapiv3::ReferenceOr::Item(rb) => Some(rb),
1540 openapiv3::ReferenceOr::Reference { reference } => {
1541 self.spec
1543 .spec
1544 .components
1545 .as_ref()
1546 .and_then(|components| {
1547 components.request_bodies.get(
1548 reference.trim_start_matches("#/components/requestBodies/"),
1549 )
1550 })
1551 .and_then(|rb_ref| rb_ref.as_item())
1552 }
1553 };
1554
1555 if let Some(value) = body {
1556 if let Some(rb) = request_body {
1557 if let Some(content) = rb.content.get("application/json") {
1558 if let Some(schema_ref) = &content.schema {
1559 let root_schema = match schema_ref {
1572 openapiv3::ReferenceOr::Item(s) => Some((*s).clone()),
1573 openapiv3::ReferenceOr::Reference { reference } => {
1574 self.spec.get_schema(reference).map(|s| s.schema.clone())
1575 }
1576 };
1577 if let Some(root_schema) = root_schema {
1578 let result = crate::schema_ref_resolver::build_validator(
1579 &root_schema,
1580 &self.spec.spec,
1581 )
1582 .and_then(|validator| {
1583 let errs: Vec<String> = validator
1584 .iter_errors(value)
1585 .map(|e| e.to_string())
1586 .collect();
1587 if errs.is_empty() {
1588 Ok(())
1589 } else {
1590 Err(errs.join("; "))
1591 }
1592 });
1593 if let Err(error_msg) = result {
1594 errors
1595 .push(format!("body validation failed: {}", error_msg));
1596 if aggregate {
1597 details.push(serde_json::json!({"path":"body","code":"schema_validation","message":error_msg}));
1598 }
1599 }
1600 } else if let openapiv3::ReferenceOr::Reference { reference } =
1601 schema_ref
1602 {
1603 errors.push(format!("body validation failed: could not resolve schema reference {}", reference));
1605 if aggregate {
1606 details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve schema reference"}));
1607 }
1608 }
1609 }
1610 }
1611 } else {
1612 errors.push("body validation failed: could not resolve request body or no application/json content".to_string());
1614 if aggregate {
1615 details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve request body reference"}));
1616 }
1617 }
1618 } else if body_present {
1619 tracing::debug!(
1626 "Non-JSON request body present; skipping JSON schema validation"
1627 );
1628 } else if request_body.map(|rb| rb.required).unwrap_or(false) {
1629 errors.push("body: Request body is required but not provided".to_string());
1634 details.push(serde_json::json!({"path":"body","code":"required","message":"Request body is required"}));
1635 }
1636 } else if body_present {
1637 tracing::debug!("Body provided for operation without requestBody; accepting");
1639 }
1640
1641 for p_ref in &route.operation.parameters {
1643 if let Some(p) = p_ref.as_item() {
1644 match p {
1645 openapiv3::Parameter::Path { parameter_data, .. } => {
1646 validate_parameter(
1647 parameter_data,
1648 path_params,
1649 "path",
1650 aggregate,
1651 &mut errors,
1652 &mut details,
1653 &self.spec,
1654 );
1655 }
1656 openapiv3::Parameter::Query {
1657 parameter_data,
1658 style,
1659 ..
1660 } => {
1661 let deep_value = if matches!(style, openapiv3::QueryStyle::DeepObject) {
1664 let prefix_bracket = format!("{}[", parameter_data.name);
1665 let mut obj = Map::new();
1666 for (key, val) in query_params.iter() {
1667 if let Some(rest) = key.strip_prefix(&prefix_bracket) {
1668 if let Some(prop) = rest.strip_suffix(']') {
1669 obj.insert(prop.to_string(), val.clone());
1670 }
1671 }
1672 }
1673 if obj.is_empty() {
1674 None
1675 } else {
1676 Some(Value::Object(obj))
1677 }
1678 } else {
1679 None
1680 };
1681 let style_str = match style {
1682 openapiv3::QueryStyle::Form => Some("form"),
1683 openapiv3::QueryStyle::SpaceDelimited => Some("spaceDelimited"),
1684 openapiv3::QueryStyle::PipeDelimited => Some("pipeDelimited"),
1685 openapiv3::QueryStyle::DeepObject => Some("deepObject"),
1686 };
1687 validate_parameter_with_deep_object(
1688 parameter_data,
1689 query_params,
1690 "query",
1691 deep_value,
1692 style_str,
1693 aggregate,
1694 &mut errors,
1695 &mut details,
1696 &self.spec,
1697 );
1698 }
1699 openapiv3::Parameter::Header { parameter_data, .. } => {
1700 validate_parameter(
1701 parameter_data,
1702 header_params,
1703 "header",
1704 aggregate,
1705 &mut errors,
1706 &mut details,
1707 &self.spec,
1708 );
1709 }
1710 openapiv3::Parameter::Cookie { parameter_data, .. } => {
1711 validate_parameter(
1712 parameter_data,
1713 cookie_params,
1714 "cookie",
1715 aggregate,
1716 &mut errors,
1717 &mut details,
1718 &self.spec,
1719 );
1720 }
1721 }
1722 }
1723 }
1724 if errors.is_empty() {
1725 return Ok(());
1726 }
1727 match effective_mode {
1728 ValidationMode::Disabled => Ok(()),
1729 ValidationMode::Warn => {
1730 tracing::warn!("Request validation warnings: {:?}", errors);
1731 Ok(())
1732 }
1733 ValidationMode::Enforce => Err(Error::validation(
1734 serde_json::json!({"errors": errors, "details": details}).to_string(),
1735 )),
1736 }
1737 } else {
1738 Err(Error::internal(format!("Route {} {} not found in OpenAPI spec", method, path)))
1739 }
1740 }
1741
1742 pub fn paths(&self) -> Vec<String> {
1746 let mut paths: Vec<String> = self.routes.iter().map(|route| route.path.clone()).collect();
1747 paths.sort();
1748 paths.dedup();
1749 paths
1750 }
1751
1752 pub fn methods(&self) -> Vec<String> {
1754 let mut methods: Vec<String> =
1755 self.routes.iter().map(|route| route.method.clone()).collect();
1756 methods.sort();
1757 methods.dedup();
1758 methods
1759 }
1760
1761 pub fn get_operation(&self, path: &str, method: &str) -> Option<OpenApiOperation> {
1763 self.get_route(path, method).map(|route| {
1764 OpenApiOperation::from_operation(
1765 &route.method,
1766 route.path.clone(),
1767 &route.operation,
1768 &self.spec,
1769 )
1770 })
1771 }
1772
1773 pub fn extract_path_parameters(&self, path: &str, method: &str) -> HashMap<String, String> {
1775 let mut best: Option<(usize, HashMap<String, String>)> = None;
1781 for route in &self.routes {
1782 if route.method != method {
1783 continue;
1784 }
1785
1786 if let Some(params) = self.match_path_to_route(path, &route.path) {
1787 let static_segments = route
1788 .path
1789 .trim_start_matches('/')
1790 .split('/')
1791 .filter(|s| !(s.starts_with('{') && s.ends_with('}')))
1792 .count();
1793 let is_more_specific = match &best {
1794 None => true,
1795 Some((score, _)) => static_segments > *score,
1796 };
1797 if is_more_specific {
1798 best = Some((static_segments, params));
1799 }
1800 }
1801 }
1802 best.map(|(_, params)| params).unwrap_or_default()
1803 }
1804
1805 fn match_path_to_route(
1807 &self,
1808 request_path: &str,
1809 route_pattern: &str,
1810 ) -> Option<HashMap<String, String>> {
1811 let mut params = HashMap::new();
1812
1813 let request_segments: Vec<&str> = request_path.trim_start_matches('/').split('/').collect();
1815 let pattern_segments: Vec<&str> =
1816 route_pattern.trim_start_matches('/').split('/').collect();
1817
1818 if request_segments.len() != pattern_segments.len() {
1819 return None;
1820 }
1821
1822 for (req_seg, pat_seg) in request_segments.iter().zip(pattern_segments.iter()) {
1823 if pat_seg.starts_with('{') && pat_seg.ends_with('}') {
1824 if req_seg.is_empty() {
1829 return None;
1830 }
1831 let param_name = &pat_seg[1..pat_seg.len() - 1];
1832 params.insert(param_name.to_string(), req_seg.to_string());
1833 } else if req_seg != pat_seg {
1834 return None;
1836 }
1837 }
1838
1839 Some(params)
1840 }
1841
1842 pub fn convert_path_to_axum(openapi_path: &str) -> String {
1845 openapi_path.to_string()
1847 }
1848
1849 pub fn build_router_with_ai(
1851 &self,
1852 ai_generator: Option<Arc<dyn AiGenerator + Send + Sync>>,
1853 ) -> Router {
1854 let mut router = Router::new();
1855 let deduped = self.deduplicated_routes();
1856 tracing::debug!("Building router with AI support from {} routes", self.routes.len());
1857
1858 let validator = Arc::new(self.clone_for_validation());
1862 for (axum_path, route) in &deduped {
1863 tracing::debug!("Adding AI-enabled route: {} {}", route.method, route.path);
1864
1865 let route_clone = (*route).clone();
1866 let ai_generator_clone = ai_generator.clone();
1867 let validator_clone = validator.clone();
1871
1872 let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
1874 axum::extract::Query(query_params): axum::extract::Query<
1875 HashMap<String, String>,
1876 >,
1877 headers: HeaderMap,
1878 body_bytes: axum::body::Bytes| {
1879 let route = route_clone.clone();
1880 let ai_generator = ai_generator_clone.clone();
1881 let validator = validator_clone.clone();
1882
1883 async move {
1884 let body_present = !body_bytes.is_empty();
1893 let body: Option<Json<Value>> = if body_bytes.is_empty() {
1894 None
1895 } else {
1896 serde_json::from_slice::<Value>(&body_bytes).ok().map(Json)
1897 };
1898 let mut path_map = Map::new();
1903 for (k, v) in &path_params {
1904 path_map.insert(k.clone(), Value::String(v.clone()));
1905 }
1906 let mut query_map = Map::new();
1907 for (k, v) in &query_params {
1908 query_map.insert(k.clone(), Value::String(v.clone()));
1909 }
1910 let mut header_map = Map::new();
1911 for (k, v) in headers.iter() {
1912 if let Ok(s) = v.to_str() {
1913 header_map.insert(k.to_string(), Value::String(s.to_string()));
1914 }
1915 }
1916 let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
1917 if let Err((status_code, payload)) = validator.run_validation_with_recording_ex(
1918 &route.path,
1919 &route.method,
1920 &path_map,
1921 &query_map,
1922 &header_map,
1923 &Map::new(),
1924 body_val,
1925 body_present,
1926 ) {
1927 let status = axum::http::StatusCode::from_u16(status_code)
1928 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
1929 return (status, Json(payload));
1930 }
1931
1932 tracing::debug!(
1933 "Handling AI request for route: {} {}",
1934 route.method,
1935 route.path
1936 );
1937
1938 let mut context = RequestContext::new(route.method.clone(), route.path.clone());
1940
1941 context.headers = headers
1943 .iter()
1944 .map(|(k, v)| {
1945 (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string()))
1946 })
1947 .collect();
1948
1949 context.body = body.map(|Json(b)| b);
1951
1952 let (status, response) = if let (Some(generator), Some(_ai_config)) =
1954 (ai_generator, &route.ai_config)
1955 {
1956 route
1957 .mock_response_with_status_async(&context, Some(generator.as_ref()))
1958 .await
1959 } else {
1960 route.mock_response_with_status()
1962 };
1963
1964 (
1965 axum::http::StatusCode::from_u16(status)
1966 .unwrap_or(axum::http::StatusCode::OK),
1967 Json(response),
1968 )
1969 }
1970 };
1971
1972 router = Self::route_for_method(router, axum_path, &route.method, handler);
1973 }
1974
1975 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
1979 }
1980
1981 pub fn build_router_with_mockai(
1992 &self,
1993 mockai: Option<
1994 Arc<
1995 tokio::sync::RwLock<
1996 dyn mockforge_foundation::intelligent_behavior::MockAiBehavior + Send + Sync,
1997 >,
1998 >,
1999 >,
2000 ) -> Router {
2001 use mockforge_foundation::intelligent_behavior::Request as MockAIRequest;
2002
2003 let mut router = Router::new();
2004 let deduped = self.deduplicated_routes();
2005 tracing::debug!("Building router with MockAI support from {} routes", self.routes.len());
2006
2007 let custom_loader = self.custom_fixture_loader.clone();
2008 let validator = Arc::new(self.clone_for_validation());
2012 for (axum_path, route) in &deduped {
2013 tracing::debug!("Adding MockAI-enabled route: {} {}", route.method, route.path);
2014
2015 let route_clone = (*route).clone();
2016 let mockai_clone = mockai.clone();
2017 let overrides_clone = self.overrides.clone();
2018 let custom_loader_clone = custom_loader.clone();
2019 let validator_clone = validator.clone();
2025
2026 let handler = move |AxumPath(path_params): AxumPath<HashMap<String, String>>,
2042 query: axum::extract::Query<HashMap<String, String>>,
2043 headers: HeaderMap,
2044 body_bytes: axum::body::Bytes| {
2045 let route = route_clone.clone();
2046 let mockai = mockai_clone.clone();
2047 let overrides = overrides_clone.clone();
2048 let validator = validator_clone.clone();
2049
2050 async move {
2051 let mut path_map = Map::new();
2052 for (k, v) in &path_params {
2053 path_map.insert(k.clone(), Value::String(v.clone()));
2054 }
2055 let mut query_map = Map::new();
2056 for (k, v) in &query.0 {
2057 query_map.insert(k.clone(), Value::String(v.clone()));
2058 }
2059 let mut header_map = Map::new();
2060 for (k, v) in headers.iter() {
2061 if let Ok(s) = v.to_str() {
2062 header_map.insert(k.to_string(), Value::String(s.to_string()));
2063 }
2064 }
2065
2066 if !body_bytes.is_empty() {
2072 let actual_ct = headers
2073 .get(axum::http::header::CONTENT_TYPE)
2074 .and_then(|v| v.to_str().ok());
2075 if let Err(ct_err) = validator.check_request_content_type(
2076 &route.path,
2077 &route.method,
2078 actual_ct,
2079 ) {
2080 let status_code =
2081 validator.options.validation_status.unwrap_or_else(|| {
2082 std::env::var("MOCKFORGE_VALIDATION_STATUS")
2083 .ok()
2084 .and_then(|s| s.parse::<u16>().ok())
2085 .unwrap_or(415)
2086 });
2087 let (client_mockforge_version, client_sent_at) =
2088 mockforge_foundation::conformance_violations::read_client_stamps(
2089 |name| {
2090 headers
2091 .get(name)
2092 .and_then(|v| v.to_str().ok())
2093 .map(|s| s.to_string())
2094 },
2095 );
2096 mockforge_foundation::conformance_violations::record(
2097 mockforge_foundation::conformance_violations::ServerConformanceViolation {
2098 timestamp: Utc::now(),
2099 method: route.method.clone(),
2100 path: route.path.clone(),
2101 client_ip: "unknown".to_string(),
2102 status: status_code,
2103 reason: ct_err.clone(),
2104 category: "content-types".to_string(),
2105 occurrences: 1,
2106 client_mockforge_version,
2107 client_sent_at,
2108 summary: String::new(),
2109 categories: Vec::new(),
2110 },
2111 );
2112 let status = axum::http::StatusCode::from_u16(status_code)
2113 .unwrap_or(axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE);
2114 return (
2115 status,
2116 Json(serde_json::json!({
2117 "error": "content_type_not_allowed",
2118 "message": ct_err,
2119 })),
2120 )
2121 .into_response();
2122 }
2123 }
2124
2125 let is_multipart_req = headers
2144 .get(axum::http::header::CONTENT_TYPE)
2145 .and_then(|v| v.to_str().ok())
2146 .map(|ct| ct.starts_with("multipart/form-data"))
2147 .unwrap_or(false);
2148 let body: Option<Json<Value>> = if body_bytes.is_empty() {
2149 None
2150 } else if is_multipart_req {
2151 match extract_multipart_from_bytes(&body_bytes, &headers).await {
2152 Ok((fields, _files)) => {
2153 let mut obj = Map::new();
2154 for (k, v) in fields {
2155 obj.insert(k, v);
2156 }
2157 if obj.is_empty() {
2158 Some(Json(Value::Object(Map::new())))
2166 } else {
2167 Some(Json(Value::Object(obj)))
2168 }
2169 }
2170 Err(e) => {
2171 tracing::warn!(
2172 "multipart parse failed for {} {}: {}",
2173 route.method,
2174 route.path,
2175 e
2176 );
2177 Some(Json(Value::Object(Map::new())))
2178 }
2179 }
2180 } else {
2181 serde_json::from_slice::<Value>(&body_bytes).ok().map(Json)
2182 };
2183
2184 let body_val: Option<&Value> = body.as_ref().map(|Json(b)| b);
2189 if let Err((status_code, payload)) = validator.run_validation_with_recording_ex(
2191 &route.path,
2192 &route.method,
2193 &path_map,
2194 &query_map,
2195 &header_map,
2196 &Map::new(),
2197 body_val,
2198 !body_bytes.is_empty(),
2199 ) {
2200 let status = axum::http::StatusCode::from_u16(status_code)
2201 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
2202 return (status, Json(payload)).into_response();
2203 }
2204
2205 tracing::debug!(
2206 "[FIXTURE DEBUG] Starting fixture check for {} {} (custom_loader available: {})",
2207 route.method,
2208 route.path,
2209 custom_loader_clone.is_some()
2210 );
2211
2212 if let Some(ref loader) = custom_loader_clone {
2214 use crate::request_fingerprint::RequestFingerprint;
2215 use axum::http::{Method, Uri};
2216
2217 let query_string = if query.0.is_empty() {
2219 String::new()
2220 } else {
2221 query
2222 .0
2223 .iter()
2224 .map(|(k, v)| format!("{}={}", k, v))
2225 .collect::<Vec<_>>()
2226 .join("&")
2227 };
2228
2229 let normalized_request_path =
2231 crate::custom_fixture::CustomFixtureLoader::normalize_path(&route.path);
2232
2233 tracing::debug!(
2234 "[FIXTURE DEBUG] Path normalization: original='{}', normalized='{}'",
2235 route.path,
2236 normalized_request_path
2237 );
2238
2239 let uri_str = if query_string.is_empty() {
2241 normalized_request_path.clone()
2242 } else {
2243 format!("{}?{}", normalized_request_path, query_string)
2244 };
2245
2246 tracing::debug!(
2247 "[FIXTURE DEBUG] URI construction: uri_str='{}', query_string='{}'",
2248 uri_str,
2249 query_string
2250 );
2251
2252 if let Ok(uri) = uri_str.parse::<Uri>() {
2253 let http_method =
2254 Method::from_bytes(route.method.as_bytes()).unwrap_or(Method::GET);
2255
2256 let body_bytes =
2258 body.as_ref().and_then(|Json(b)| serde_json::to_vec(b).ok());
2259 let body_slice = body_bytes.as_deref();
2260
2261 let fingerprint =
2262 RequestFingerprint::new(http_method, &uri, &headers, body_slice);
2263
2264 tracing::debug!(
2265 "[FIXTURE DEBUG] RequestFingerprint created: method='{}', path='{}', query='{}', body_hash={:?}",
2266 fingerprint.method,
2267 fingerprint.path,
2268 fingerprint.query,
2269 fingerprint.body_hash
2270 );
2271
2272 let available_fixtures = loader.has_fixture(&fingerprint);
2274 tracing::debug!(
2275 "[FIXTURE DEBUG] Fixture check result: has_fixture={}",
2276 available_fixtures
2277 );
2278
2279 if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
2280 tracing::debug!(
2281 "[FIXTURE DEBUG] ✅ FIXTURE MATCHED! Using custom fixture for {} {} (status: {}, path: '{}')",
2282 route.method,
2283 route.path,
2284 custom_fixture.status,
2285 custom_fixture.path
2286 );
2287
2288 if custom_fixture.delay_ms > 0 {
2290 tokio::time::sleep(tokio::time::Duration::from_millis(
2291 custom_fixture.delay_ms,
2292 ))
2293 .await;
2294 }
2295
2296 let response_body = if custom_fixture.response.is_string() {
2298 custom_fixture.response.as_str().unwrap().to_string()
2299 } else {
2300 serde_json::to_string(&custom_fixture.response)
2301 .unwrap_or_else(|_| "{}".to_string())
2302 };
2303
2304 let json_value: Value = serde_json::from_str(&response_body)
2306 .unwrap_or_else(|_| serde_json::json!({}));
2307
2308 let status =
2310 axum::http::StatusCode::from_u16(custom_fixture.status)
2311 .unwrap_or(axum::http::StatusCode::OK);
2312
2313 return (status, Json(json_value)).into_response();
2315 } else {
2316 tracing::debug!(
2317 "[FIXTURE DEBUG] ❌ No fixture match found for {} {} (fingerprint.path='{}', normalized='{}')",
2318 route.method,
2319 route.path,
2320 fingerprint.path,
2321 normalized_request_path
2322 );
2323 }
2324 } else {
2325 tracing::warn!("[FIXTURE DEBUG] Failed to parse URI: '{}'", uri_str);
2326 }
2327 } else {
2328 tracing::debug!(
2329 "[FIXTURE DEBUG] Custom fixture loader not available for {} {}",
2330 route.method,
2331 route.path
2332 );
2333 }
2334
2335 tracing::debug!(
2336 "Handling MockAI request for route: {} {}",
2337 route.method,
2338 route.path
2339 );
2340
2341 let mockai_query = query.0;
2343
2344 let method_upper = route.method.to_uppercase();
2349 let should_use_mockai =
2350 matches!(method_upper.as_str(), "POST" | "PUT" | "PATCH" | "DELETE");
2351
2352 if should_use_mockai {
2353 if let Some(mockai_arc) = mockai {
2354 let mockai_guard = mockai_arc.read().await;
2355
2356 let mut mockai_headers = HashMap::new();
2358 for (k, v) in headers.iter() {
2359 mockai_headers
2360 .insert(k.to_string(), v.to_str().unwrap_or("").to_string());
2361 }
2362
2363 let mockai_request = MockAIRequest {
2364 method: route.method.clone(),
2365 path: route.path.clone(),
2366 body: body.as_ref().map(|Json(b)| b.clone()),
2367 query_params: mockai_query.clone(),
2368 headers: mockai_headers,
2369 };
2370
2371 match mockai_guard.process_request(&mockai_request).await {
2373 Ok(mockai_response) => {
2374 let is_empty = mockai_response.body.is_object()
2376 && mockai_response
2377 .body
2378 .as_object()
2379 .map(|obj| obj.is_empty())
2380 .unwrap_or(false);
2381
2382 if is_empty {
2383 tracing::debug!(
2384 "MockAI returned empty object for {} {}, falling back to OpenAPI response generation",
2385 route.method,
2386 route.path
2387 );
2388 } else {
2390 let spec_status = route.find_first_available_status_code();
2394 tracing::debug!(
2395 "MockAI generated response for {} {}, using spec status: {} (MockAI suggested: {})",
2396 route.method,
2397 route.path,
2398 spec_status,
2399 mockai_response.status_code
2400 );
2401 let status = axum::http::StatusCode::from_u16(spec_status)
2402 .unwrap_or(axum::http::StatusCode::OK);
2403 let mut mockai_body = mockai_response.body;
2404 apply_route_overrides(
2405 overrides.as_deref(),
2406 &route,
2407 &headers,
2408 &mockai_query,
2409 &body_bytes,
2410 &mut mockai_body,
2411 );
2412 let mut resp = (status, Json(mockai_body)).into_response();
2413 inject_spec_response_headers(
2414 &mut resp,
2415 &route,
2416 spec_status,
2417 );
2418 return resp;
2419 }
2420 }
2421 Err(e) => {
2422 tracing::warn!(
2423 "MockAI processing failed for {} {}: {}, falling back to standard response",
2424 route.method,
2425 route.path,
2426 e
2427 );
2428 }
2430 }
2431 }
2432 } else {
2433 tracing::debug!(
2434 "Skipping MockAI for {} request {} - using OpenAPI response generation",
2435 method_upper,
2436 route.path
2437 );
2438 }
2439
2440 let status_override = headers
2442 .get("X-Mockforge-Response-Status")
2443 .and_then(|v| v.to_str().ok())
2444 .and_then(|s| s.parse::<u16>().ok());
2445
2446 let scenario = headers
2448 .get("X-Mockforge-Scenario")
2449 .and_then(|v| v.to_str().ok())
2450 .map(|s| s.to_string())
2451 .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
2452
2453 let (status, mut response) = route
2455 .mock_response_with_status_and_scenario_and_override(
2456 scenario.as_deref(),
2457 status_override,
2458 );
2459 apply_route_overrides(
2460 overrides.as_deref(),
2461 &route,
2462 &headers,
2463 &mockai_query,
2464 &body_bytes,
2465 &mut response,
2466 );
2467 let status_code = axum::http::StatusCode::from_u16(status)
2468 .unwrap_or(axum::http::StatusCode::OK);
2469 let mut resp = (status_code, Json(response)).into_response();
2470 inject_spec_response_headers(&mut resp, &route, status);
2471 resp
2472 }
2473 };
2474
2475 router = Self::route_for_method(router, axum_path, &route.method, handler);
2476 }
2477
2478 router.layer(DefaultBodyLimit::max(openapi_body_limit_bytes()))
2481 }
2482}
2483
2484fn apply_route_overrides(
2499 overrides: Option<&dyn OverrideHook>,
2500 route: &OpenApiRoute,
2501 headers: &HeaderMap,
2502 query: &HashMap<String, String>,
2503 request_body: &[u8],
2504 body: &mut Value,
2505) {
2506 let Some(overrides) = overrides else {
2507 return;
2508 };
2509 let raw_query = (!query.is_empty()).then(|| {
2510 url::form_urlencoded::Serializer::new(String::new())
2511 .extend_pairs(query.iter())
2512 .finish()
2513 });
2514 let mut tags = route.operation.tags.clone();
2515 tags.extend(route.operation.operation_id.clone());
2516 let request = OverrideRequest {
2517 method: &route.method,
2518 path_template: &route.path,
2519 operation_id: route.operation.operation_id.as_deref().unwrap_or_default(),
2520 tags: &tags,
2521 headers,
2522 query: raw_query.as_deref(),
2523 body: request_body,
2524 };
2525 overrides.apply(&request, body);
2526}
2527
2528fn inject_spec_response_headers(
2529 response: &mut axum::response::Response,
2530 route: &OpenApiRoute,
2531 status_code: u16,
2532) {
2533 let synthesized = route.mock_response_headers_for_status(status_code);
2534 if synthesized.is_empty() {
2535 return;
2536 }
2537 let response_headers = response.headers_mut();
2538 for (name, value) in synthesized {
2539 let Ok(header_name) = axum::http::HeaderName::from_bytes(name.as_bytes()) else {
2540 continue;
2541 };
2542 if response_headers.contains_key(&header_name) {
2543 continue;
2544 }
2545 let Ok(header_value) = axum::http::HeaderValue::from_str(&value) else {
2546 continue;
2547 };
2548 response_headers.insert(header_name, header_value);
2549 }
2550}
2551
2552async fn extract_multipart_from_bytes(
2557 body: &axum::body::Bytes,
2558 headers: &HeaderMap,
2559) -> Result<(HashMap<String, Value>, HashMap<String, String>)> {
2560 let boundary = headers
2562 .get(axum::http::header::CONTENT_TYPE)
2563 .and_then(|v| v.to_str().ok())
2564 .and_then(|ct| {
2565 ct.split(';').find_map(|part| {
2566 let part = part.trim();
2567 if part.starts_with("boundary=") {
2568 Some(part.strip_prefix("boundary=").unwrap_or("").trim_matches('"'))
2569 } else {
2570 None
2571 }
2572 })
2573 })
2574 .ok_or_else(|| Error::internal("Missing boundary in Content-Type header"))?;
2575
2576 let mut fields = HashMap::new();
2577 let mut files = HashMap::new();
2578
2579 let boundary_prefix = format!("--{}", boundary).into_bytes();
2582 let boundary_line = format!("\r\n--{}\r\n", boundary).into_bytes();
2583 let end_boundary = format!("\r\n--{}--\r\n", boundary).into_bytes();
2584
2585 let mut pos = 0;
2587 let mut parts = Vec::new();
2588
2589 if body.starts_with(&boundary_prefix) {
2591 if let Some(first_crlf) = body.iter().position(|&b| b == b'\r') {
2592 pos = first_crlf + 2; }
2594 }
2595
2596 while let Some(boundary_pos) = body[pos..]
2598 .windows(boundary_line.len())
2599 .position(|window| window == boundary_line.as_slice())
2600 {
2601 let actual_pos = pos + boundary_pos;
2602 if actual_pos > pos {
2603 parts.push((pos, actual_pos));
2604 }
2605 pos = actual_pos + boundary_line.len();
2606 }
2607
2608 if let Some(end_pos) = body[pos..]
2610 .windows(end_boundary.len())
2611 .position(|window| window == end_boundary.as_slice())
2612 {
2613 let actual_end = pos + end_pos;
2614 if actual_end > pos {
2615 parts.push((pos, actual_end));
2616 }
2617 } else if pos < body.len() {
2618 parts.push((pos, body.len()));
2620 }
2621
2622 for (start, end) in parts {
2624 let part_data = &body[start..end];
2625
2626 let separator = b"\r\n\r\n";
2628 if let Some(sep_pos) =
2629 part_data.windows(separator.len()).position(|window| window == separator)
2630 {
2631 let header_bytes = &part_data[..sep_pos];
2632 let body_start = sep_pos + separator.len();
2633 let body_data = &part_data[body_start..];
2634
2635 let header_str = String::from_utf8_lossy(header_bytes);
2637 let mut field_name = None;
2638 let mut filename = None;
2639
2640 for header_line in header_str.lines() {
2641 if header_line.starts_with("Content-Disposition:") {
2642 if let Some(name_start) = header_line.find("name=\"") {
2644 let name_start = name_start + 6;
2645 if let Some(name_end) = header_line[name_start..].find('"') {
2646 field_name =
2647 Some(header_line[name_start..name_start + name_end].to_string());
2648 }
2649 }
2650
2651 if let Some(file_start) = header_line.find("filename=\"") {
2653 let file_start = file_start + 10;
2654 if let Some(file_end) = header_line[file_start..].find('"') {
2655 filename =
2656 Some(header_line[file_start..file_start + file_end].to_string());
2657 }
2658 }
2659 }
2660 }
2661
2662 if let Some(name) = field_name {
2663 if let Some(file) = filename {
2664 let temp_dir = std::env::temp_dir().join("mockforge-uploads");
2666 std::fs::create_dir_all(&temp_dir)
2667 .map_err(|e| Error::io_with_context("temp directory", e.to_string()))?;
2668
2669 let file_path = temp_dir.join(format!("{}_{}", uuid::Uuid::new_v4(), file));
2670 std::fs::write(&file_path, body_data)
2671 .map_err(|e| Error::io_with_context("file", e.to_string()))?;
2672
2673 let file_path_str = file_path.to_string_lossy().to_string();
2674 files.insert(name.clone(), file_path_str.clone());
2675 fields.insert(name, Value::String(file_path_str));
2676 } else {
2677 let body_str = body_data
2680 .strip_suffix(b"\r\n")
2681 .or_else(|| body_data.strip_suffix(b"\n"))
2682 .unwrap_or(body_data);
2683
2684 if let Ok(field_value) = String::from_utf8(body_str.to_vec()) {
2685 fields.insert(name, Value::String(field_value.trim().to_string()));
2686 } else {
2687 use base64::{engine::general_purpose, Engine as _};
2689 fields.insert(
2690 name,
2691 Value::String(general_purpose::STANDARD.encode(body_str)),
2692 );
2693 }
2694 }
2695 }
2696 }
2697 }
2698
2699 Ok((fields, files))
2700}
2701
2702static LAST_ERRORS: Lazy<Mutex<VecDeque<Value>>> =
2703 Lazy::new(|| Mutex::new(VecDeque::with_capacity(20)));
2704
2705fn split_detail_entry(detail: &Value) -> Option<(String, String)> {
2717 let obj = detail.as_object()?;
2718 let path = obj.get("path")?.as_str()?;
2719 let message = obj.get("message").and_then(|m| m.as_str()).unwrap_or("validation failed");
2720 let (loc, name) = match path.split_once('.') {
2724 Some(("query", rest)) => ("query", rest),
2725 Some(("header", rest)) => ("headers", rest),
2726 Some(("cookie", rest)) => ("cookies", rest),
2727 Some(("path", rest)) => ("parameters", rest),
2728 Some(("body", rest)) => ("request-body", rest),
2729 _ => match path {
2730 "query" => ("query", ""),
2731 "header" | "headers" => ("headers", ""),
2732 "cookie" | "cookies" => ("cookies", ""),
2733 "path" => ("parameters", ""),
2734 "body" | "request-body" => ("request-body", ""),
2735 _ => return None,
2736 },
2737 };
2738 let reason = if name.is_empty() {
2739 message.to_string()
2740 } else {
2741 format!("{name}: {message}")
2742 };
2743 Some((loc.to_string(), reason))
2744}
2745
2746fn validation_details(payload: &Value) -> Vec<Value> {
2751 let Some(detail) = payload.get("detail") else {
2752 return Vec::new();
2753 };
2754 let parsed = match detail {
2755 Value::String(s) => match (s.find('{'), s.rfind('}')) {
2758 (Some(start), Some(end)) if end > start => {
2759 serde_json::from_str::<Value>(&s[start..=end]).ok()
2760 }
2761 _ => None,
2762 },
2763 v => Some(v.clone()),
2764 };
2765 let Some(parsed) = parsed else {
2766 return Vec::new();
2767 };
2768 let Some(details) = parsed.get("details").and_then(|d| d.as_array()) else {
2769 return Vec::new();
2770 };
2771 details
2772 .iter()
2773 .filter(|d| d.get("path").and_then(|p| p.as_str()).is_some())
2774 .cloned()
2775 .collect()
2776}
2777
2778pub fn classify_validation_reason(reason: &str) -> String {
2779 let r = reason.to_ascii_lowercase();
2788
2789 if r.contains("method") && (r.contains("not allowed") || r.contains("unsupported")) {
2802 return "http-methods".into();
2803 }
2804
2805 let path_starts_with = |prefix: &str| r.contains(&format!("\"path\":\"{}", prefix));
2807 if path_starts_with("query.") {
2808 return "query".into();
2809 }
2810 if path_starts_with("header.") {
2811 return "headers".into();
2812 }
2813 if path_starts_with("cookie.") {
2814 return "cookies".into();
2815 }
2816 if path_starts_with("path.") {
2817 return "parameters".into();
2818 }
2819 if path_starts_with("body") {
2820 return "request-body".into();
2821 }
2822
2823 if r.contains("content-type") || r.contains("content type") {
2826 return "content-types".into();
2827 }
2828
2829 if r.contains("required")
2833 && (r.contains("param") || r.contains("query") || r.contains("header"))
2834 {
2835 return "parameters".into();
2836 }
2837 if r.contains("auth") || r.contains("security") {
2838 return "security".into();
2839 }
2840 if r.contains("method") {
2841 return "http-methods".into();
2842 }
2843 if r.contains("schema") || r.contains("body") || r.contains("json") {
2844 return "request-body".into();
2845 }
2846 if r.contains("enum") || r.contains("min") || r.contains("max") || r.contains("pattern") {
2847 return "constraints".into();
2848 }
2849 String::new()
2850}
2851
2852pub fn record_validation_error(v: &Value) {
2854 if let Ok(mut q) = LAST_ERRORS.lock() {
2855 if q.len() >= 20 {
2856 q.pop_front();
2857 }
2858 q.push_back(v.clone());
2859 }
2860 }
2862
2863pub fn get_last_validation_error() -> Option<Value> {
2865 LAST_ERRORS.lock().ok()?.back().cloned()
2866}
2867
2868pub fn get_validation_errors() -> Vec<Value> {
2870 LAST_ERRORS.lock().map(|q| q.iter().cloned().collect()).unwrap_or_default()
2871}
2872
2873fn coerce_value_for_schema(value: &Value, schema: &openapiv3::Schema) -> Value {
2878 match value {
2880 Value::String(s) => {
2881 if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
2883 &schema.schema_kind
2884 {
2885 if s.contains(',') {
2886 let parts: Vec<&str> = s.split(',').map(|s| s.trim()).collect();
2888 let mut array_values = Vec::new();
2889
2890 for part in parts {
2891 if let Some(items_schema) = &array_type.items {
2893 if let Some(items_schema_obj) = items_schema.as_item() {
2894 let part_value = Value::String(part.to_string());
2895 let coerced_part =
2896 coerce_value_for_schema(&part_value, items_schema_obj);
2897 array_values.push(coerced_part);
2898 } else {
2899 array_values.push(Value::String(part.to_string()));
2901 }
2902 } else {
2903 array_values.push(Value::String(part.to_string()));
2905 }
2906 }
2907 return Value::Array(array_values);
2908 }
2909 }
2910
2911 match &schema.schema_kind {
2913 openapiv3::SchemaKind::Type(openapiv3::Type::String(_)) => {
2914 value.clone()
2916 }
2917 openapiv3::SchemaKind::Type(openapiv3::Type::Number(_)) => {
2918 if let Ok(n) = s.parse::<f64>() {
2920 if let Some(num) = serde_json::Number::from_f64(n) {
2921 return Value::Number(num);
2922 }
2923 }
2924 value.clone()
2925 }
2926 openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_)) => {
2927 if let Ok(n) = s.parse::<i64>() {
2929 return Value::Number(n.into());
2930 }
2931 if let Ok(n) = s.parse::<u64>() {
2932 return Value::Number(n.into());
2933 }
2934 value.clone()
2935 }
2936 openapiv3::SchemaKind::Type(openapiv3::Type::Boolean(_)) => {
2937 match s.to_lowercase().as_str() {
2939 "true" | "1" | "yes" | "on" => Value::Bool(true),
2940 "false" | "0" | "no" | "off" => Value::Bool(false),
2941 _ => value.clone(),
2942 }
2943 }
2944 _ => {
2945 value.clone()
2947 }
2948 }
2949 }
2950 _ => value.clone(),
2951 }
2952}
2953
2954fn build_deep_object(name: &str, params: &Map<String, Value>) -> Option<Value> {
2956 let prefix = format!("{}[", name);
2957 let mut obj = Map::new();
2958 for (k, v) in params.iter() {
2959 if let Some(rest) = k.strip_prefix(&prefix) {
2960 if let Some(key) = rest.strip_suffix(']') {
2961 obj.insert(key.to_string(), v.clone());
2962 }
2963 }
2964 }
2965 if obj.is_empty() {
2966 None
2967 } else {
2968 Some(Value::Object(obj))
2969 }
2970}
2971
2972#[allow(clippy::too_many_arguments)]
2978fn generate_enhanced_422_response(
2979 validator: &OpenApiRouteRegistry,
2980 path_template: &str,
2981 method: &str,
2982 body: Option<&Value>,
2983 path_params: &Map<String, Value>,
2984 query_params: &Map<String, Value>,
2985 header_params: &Map<String, Value>,
2986 cookie_params: &Map<String, Value>,
2987) -> Value {
2988 let mut field_errors = Vec::new();
2989
2990 if let Some(route) = validator.get_route(path_template, method) {
2992 if let Some(schema) = &route.operation.request_body {
2994 if let Some(value) = body {
2995 if let Some(content) =
2996 schema.as_item().and_then(|rb| rb.content.get("application/json"))
2997 {
2998 if let Some(_schema_ref) = &content.schema {
2999 if serde_json::from_value::<Value>(value.clone()).is_err() {
3001 field_errors.push(json!({
3002 "path": "body",
3003 "message": "invalid JSON"
3004 }));
3005 }
3006 }
3007 }
3008 } else {
3009 field_errors.push(json!({
3010 "path": "body",
3011 "expected": "object",
3012 "found": "missing",
3013 "message": "Request body is required but not provided"
3014 }));
3015 }
3016 }
3017
3018 for param_ref in &route.operation.parameters {
3020 if let Some(param) = param_ref.as_item() {
3021 match param {
3022 openapiv3::Parameter::Path { parameter_data, .. } => {
3023 validate_parameter_detailed(
3024 parameter_data,
3025 path_params,
3026 "path",
3027 "path parameter",
3028 &mut field_errors,
3029 &validator.spec,
3030 );
3031 }
3032 openapiv3::Parameter::Query {
3033 parameter_data,
3034 style,
3035 ..
3036 } => {
3037 let deep_value = if matches!(style, openapiv3::QueryStyle::DeepObject) {
3038 build_deep_object(¶meter_data.name, query_params)
3039 } else {
3040 None
3041 };
3042 validate_parameter_detailed_with_deep(
3043 parameter_data,
3044 query_params,
3045 "query",
3046 "query parameter",
3047 deep_value,
3048 &mut field_errors,
3049 Some(query_style_name(style)),
3050 &validator.spec,
3051 );
3052 }
3053 openapiv3::Parameter::Header { parameter_data, .. } => {
3054 validate_parameter_detailed(
3055 parameter_data,
3056 header_params,
3057 "header",
3058 "header parameter",
3059 &mut field_errors,
3060 &validator.spec,
3061 );
3062 }
3063 openapiv3::Parameter::Cookie { parameter_data, .. } => {
3064 validate_parameter_detailed(
3065 parameter_data,
3066 cookie_params,
3067 "cookie",
3068 "cookie parameter",
3069 &mut field_errors,
3070 &validator.spec,
3071 );
3072 }
3073 }
3074 }
3075 }
3076 }
3077
3078 json!({
3080 "error": "Schema validation failed",
3081 "details": field_errors,
3082 "method": method,
3083 "path": path_template,
3084 "timestamp": Utc::now().to_rfc3339(),
3085 "validation_type": "openapi_schema"
3086 })
3087}
3088
3089fn parameter_value<'a>(
3092 params_map: &'a Map<String, Value>,
3093 name: &str,
3094 location: &str,
3095) -> Option<&'a Value> {
3096 params_map.get(name).or_else(|| {
3097 if location == "header" {
3098 params_map
3099 .iter()
3100 .find(|(key, _)| key.eq_ignore_ascii_case(name))
3101 .map(|(_, value)| value)
3102 } else {
3103 None
3104 }
3105 })
3106}
3107
3108fn query_style_name(style: &openapiv3::QueryStyle) -> &'static str {
3109 match style {
3110 openapiv3::QueryStyle::Form => "form",
3111 openapiv3::QueryStyle::SpaceDelimited => "spaceDelimited",
3112 openapiv3::QueryStyle::PipeDelimited => "pipeDelimited",
3113 openapiv3::QueryStyle::DeepObject => "deepObject",
3114 }
3115}
3116
3117fn parameter_failure(
3118 data: &openapiv3::ParameterData,
3119 location: &str,
3120 message: String,
3121 aggregate: bool,
3122 errors: &mut Vec<String>,
3123 details: &mut Vec<Value>,
3124) {
3125 errors.push(format!("{} parameter '{}' validation failed: {}", location, data.name, message));
3126 if aggregate {
3127 details.push(json!({"path":format!("{}.{}",location,data.name),"code":"schema_validation","message":message}));
3128 }
3129}
3130
3131#[allow(clippy::too_many_arguments)]
3132fn validate_parameter(
3133 parameter_data: &openapiv3::ParameterData,
3134 params_map: &Map<String, Value>,
3135 prefix: &str,
3136 aggregate: bool,
3137 errors: &mut Vec<String>,
3138 details: &mut Vec<Value>,
3139 spec: &OpenApiSpec,
3140) {
3141 let schema = match parameter_schema::schema(parameter_data, spec) {
3142 Ok(schema) => schema,
3143 Err(message) => {
3144 parameter_failure(parameter_data, prefix, message, aggregate, errors, details);
3145 return;
3146 }
3147 };
3148 match parameter_value(params_map, ¶meter_data.name, prefix) {
3149 Some(value) => {
3150 if let Err((_, message)) = parameter_schema::validate_parameter_value(
3151 value,
3152 schema,
3153 parameter_data,
3154 spec,
3155 None,
3156 ) {
3157 parameter_failure(parameter_data, prefix, message, aggregate, errors, details);
3158 }
3159 }
3160 None if parameter_data.required => {
3161 errors.push(format!("missing required {} parameter '{}'", prefix, parameter_data.name));
3162 details.push(json!({"path":format!("{}.{}",prefix,parameter_data.name),"code":"required","message":"Missing required parameter"}));
3163 }
3164 None => {}
3165 }
3166}
3167
3168#[allow(clippy::too_many_arguments)]
3169fn validate_parameter_with_deep_object(
3170 parameter_data: &openapiv3::ParameterData,
3171 params_map: &Map<String, Value>,
3172 prefix: &str,
3173 deep_value: Option<Value>,
3174 style: Option<&str>,
3175 aggregate: bool,
3176 errors: &mut Vec<String>,
3177 details: &mut Vec<Value>,
3178 spec: &OpenApiSpec,
3179) {
3180 let schema = match parameter_schema::schema(parameter_data, spec) {
3181 Ok(schema) => schema,
3182 Err(message) => {
3183 parameter_failure(parameter_data, prefix, message, aggregate, errors, details);
3184 return;
3185 }
3186 };
3187 match deep_value
3188 .as_ref()
3189 .or_else(|| parameter_value(params_map, ¶meter_data.name, prefix))
3190 {
3191 Some(value) => {
3192 if let Err((_, message)) = parameter_schema::validate_parameter_value(
3193 value,
3194 schema,
3195 parameter_data,
3196 spec,
3197 style,
3198 ) {
3199 parameter_failure(parameter_data, prefix, message, aggregate, errors, details);
3200 }
3201 }
3202 None if parameter_data.required => {
3203 errors.push(format!("missing required {} parameter '{}'", prefix, parameter_data.name));
3204 details.push(json!({"path":format!("{}.{}",prefix,parameter_data.name),"code":"required","message":"Missing required parameter"}));
3205 }
3206 None => {}
3207 }
3208}
3209
3210#[allow(clippy::too_many_arguments)]
3211fn validate_parameter_detailed(
3212 parameter_data: &openapiv3::ParameterData,
3213 params_map: &Map<String, Value>,
3214 location: &str,
3215 value_type: &str,
3216 field_errors: &mut Vec<Value>,
3217 spec: &OpenApiSpec,
3218) {
3219 let path = format!("{}.{}", location, parameter_data.name);
3220 let schema = match parameter_schema::schema(parameter_data, spec) {
3221 Ok(schema) => schema,
3222 Err(message) => {
3223 field_errors.push(json!({"path":path,"expected":"resolvable schema","found":"unresolved","message":message}));
3224 return;
3225 }
3226 };
3227 match parameter_value(params_map, ¶meter_data.name, location) {
3228 Some(value) => {
3229 if let Err((found, message)) = parameter_schema::validate_parameter_value(
3230 value,
3231 schema,
3232 parameter_data,
3233 spec,
3234 None,
3235 ) {
3236 field_errors.push(json!({"path":path,"expected":"valid according to schema","found":found,"message":message}));
3237 }
3238 }
3239 None if parameter_data.required => {
3240 field_errors.push(json!({"path":path,"expected":"value","found":"missing","message":format!("Missing required {} '{}'",value_type,parameter_data.name)}));
3241 }
3242 None => {}
3243 }
3244}
3245
3246#[allow(clippy::too_many_arguments)]
3247fn validate_parameter_detailed_with_deep(
3248 parameter_data: &openapiv3::ParameterData,
3249 params_map: &Map<String, Value>,
3250 location: &str,
3251 value_type: &str,
3252 deep_value: Option<Value>,
3253 field_errors: &mut Vec<Value>,
3254 style: Option<&str>,
3255 spec: &OpenApiSpec,
3256) {
3257 let path = format!("{}.{}", location, parameter_data.name);
3258 let schema = match parameter_schema::schema(parameter_data, spec) {
3259 Ok(schema) => schema,
3260 Err(message) => {
3261 field_errors.push(json!({"path":path,"expected":"resolvable schema","found":"unresolved","message":message}));
3262 return;
3263 }
3264 };
3265 match deep_value
3266 .as_ref()
3267 .or_else(|| parameter_value(params_map, ¶meter_data.name, location))
3268 {
3269 Some(value) => {
3270 if let Err((found, message)) = parameter_schema::validate_parameter_value(
3271 value,
3272 schema,
3273 parameter_data,
3274 spec,
3275 style,
3276 ) {
3277 field_errors.push(json!({"path":path,"expected":"valid according to schema","found":found,"message":message}));
3278 }
3279 }
3280 None if parameter_data.required => {
3281 field_errors.push(json!({"path":path,"expected":"value","found":"missing","message":format!("Missing required {} '{}'",value_type,parameter_data.name)}));
3282 }
3283 None => {}
3284 }
3285}
3286
3287pub async fn create_registry_from_file<P: AsRef<std::path::Path>>(
3289 path: P,
3290) -> Result<OpenApiRouteRegistry> {
3291 let spec = OpenApiSpec::from_file(path).await?;
3292 spec.validate()?;
3293 Ok(OpenApiRouteRegistry::new(spec))
3294}
3295
3296pub fn create_registry_from_json(json: Value) -> Result<OpenApiRouteRegistry> {
3298 let spec = OpenApiSpec::from_json(json)?;
3299 spec.validate()?;
3300 Ok(OpenApiRouteRegistry::new(spec))
3301}
3302
3303#[cfg(test)]
3304mod parameter_lookup_tests;
3305#[cfg(test)]
3306mod parameter_schema_tests;
3307
3308#[cfg(test)]
3309mod tests {
3310 use super::*;
3311 use serde_json::json;
3312 use tempfile::TempDir;
3313
3314 #[test]
3323 fn classify_validation_reason_uses_structured_path_field_first() {
3324 let query_only = r#"{"details":[{"code":"schema_validation","message":"Validation error","path":"query.$.xgafv"}]}"#;
3327 assert_eq!(classify_validation_reason(query_only), "query");
3328
3329 let header_only = r#"{"details":[{"code":"schema_validation","message":"missing required X-Trace","path":"header.X-Trace"}]}"#;
3330 assert_eq!(classify_validation_reason(header_only), "headers");
3331
3332 let cookie_only = r#"{"details":[{"code":"schema_validation","message":"missing session","path":"cookie.session"}]}"#;
3333 assert_eq!(classify_validation_reason(cookie_only), "cookies");
3334
3335 let body_only = r#"{"details":[{"code":"schema_validation","message":"name required","path":"body.name"}]}"#;
3337 assert_eq!(classify_validation_reason(body_only), "request-body");
3338
3339 assert_eq!(
3341 classify_validation_reason("Content-Type application/xml not allowed"),
3342 "content-types"
3343 );
3344 }
3345
3346 #[test]
3351 fn per_location_violation_split_records_one_entry_per_location() {
3352 mockforge_foundation::conformance_violations::clear();
3353
3354 let spec_json = json!({
3355 "openapi": "3.0.0",
3356 "info": { "title": "T", "version": "1" },
3357 "paths": {
3358 "/v1/things": {
3359 "post": {
3360 "summary": "Create thing",
3361 "parameters": [
3362 {
3363 "name": "kind",
3364 "in": "query",
3365 "required": true,
3366 "schema": { "type": "string", "enum": ["a", "b"] }
3367 }
3368 ],
3369 "requestBody": {
3370 "required": true,
3371 "content": {
3372 "application/json": {
3373 "schema": {
3374 "type": "object",
3375 "required": ["email"],
3376 "properties": { "email": { "type": "string" } }
3377 }
3378 }
3379 }
3380 },
3381 "responses": { "200": { "description": "ok" } }
3382 }
3383 }
3384 }
3385 });
3386 let spec = OpenApiSpec::from_json(spec_json).expect("spec parses");
3387 let router = OpenApiRouteRegistry::new(spec);
3388
3389 let result = router.run_validation_with_recording_ex(
3391 "/v1/things",
3392 "POST",
3393 &Map::new(),
3394 &[("kind".to_string(), json!("bogus"))].into_iter().collect(),
3395 &Map::new(),
3396 &Map::new(),
3397 Some(&json!({})),
3398 true,
3399 );
3400 assert!(result.is_err(), "request must fail validation");
3401
3402 let buffer = mockforge_foundation::conformance_violations::snapshot();
3403 let mine: Vec<_> =
3404 buffer.iter().filter(|v| v.path == "/v1/things" && v.method == "POST").collect();
3405 assert!(
3406 mine.iter().any(|v| v.category == "query" && v.reason.contains("kind")),
3407 "expected a query entry naming 'kind', got {:?}",
3408 mine.iter().map(|v| (&v.category, &v.reason)).collect::<Vec<_>>()
3409 );
3410 assert!(
3411 mine.iter().any(|v| v.category == "request-body"),
3412 "expected a separate request-body entry, got {:?}",
3413 mine.iter().map(|v| (&v.category, &v.reason)).collect::<Vec<_>>()
3414 );
3415 }
3416
3417 #[tokio::test]
3418 async fn test_registry_creation() {
3419 let spec_json = json!({
3420 "openapi": "3.0.0",
3421 "info": {
3422 "title": "Test API",
3423 "version": "1.0.0"
3424 },
3425 "paths": {
3426 "/users": {
3427 "get": {
3428 "summary": "Get users",
3429 "responses": {
3430 "200": {
3431 "description": "Success",
3432 "content": {
3433 "application/json": {
3434 "schema": {
3435 "type": "array",
3436 "items": {
3437 "type": "object",
3438 "properties": {
3439 "id": {"type": "integer"},
3440 "name": {"type": "string"}
3441 }
3442 }
3443 }
3444 }
3445 }
3446 }
3447 }
3448 },
3449 "post": {
3450 "summary": "Create user",
3451 "requestBody": {
3452 "content": {
3453 "application/json": {
3454 "schema": {
3455 "type": "object",
3456 "properties": {
3457 "name": {"type": "string"}
3458 },
3459 "required": ["name"]
3460 }
3461 }
3462 }
3463 },
3464 "responses": {
3465 "201": {
3466 "description": "Created",
3467 "content": {
3468 "application/json": {
3469 "schema": {
3470 "type": "object",
3471 "properties": {
3472 "id": {"type": "integer"},
3473 "name": {"type": "string"}
3474 }
3475 }
3476 }
3477 }
3478 }
3479 }
3480 }
3481 },
3482 "/users/{id}": {
3483 "get": {
3484 "summary": "Get user by ID",
3485 "parameters": [
3486 {
3487 "name": "id",
3488 "in": "path",
3489 "required": true,
3490 "schema": {"type": "integer"}
3491 }
3492 ],
3493 "responses": {
3494 "200": {
3495 "description": "Success",
3496 "content": {
3497 "application/json": {
3498 "schema": {
3499 "type": "object",
3500 "properties": {
3501 "id": {"type": "integer"},
3502 "name": {"type": "string"}
3503 }
3504 }
3505 }
3506 }
3507 }
3508 }
3509 }
3510 }
3511 }
3512 });
3513
3514 let registry = create_registry_from_json(spec_json).unwrap();
3515
3516 assert_eq!(registry.paths().len(), 2);
3518 assert!(registry.paths().contains(&"/users".to_string()));
3519 assert!(registry.paths().contains(&"/users/{id}".to_string()));
3520
3521 assert_eq!(registry.methods().len(), 2);
3522 assert!(registry.methods().contains(&"GET".to_string()));
3523 assert!(registry.methods().contains(&"POST".to_string()));
3524
3525 let get_users_route = registry.get_route("/users", "GET").unwrap();
3527 assert_eq!(get_users_route.method, "GET");
3528 assert_eq!(get_users_route.path, "/users");
3529
3530 let post_users_route = registry.get_route("/users", "POST").unwrap();
3531 assert_eq!(post_users_route.method, "POST");
3532 assert!(post_users_route.operation.request_body.is_some());
3533
3534 let user_by_id_route = registry.get_route("/users/{id}", "GET").unwrap();
3536 assert_eq!(user_by_id_route.axum_path(), "/users/{id}");
3537 }
3538
3539 #[tokio::test]
3545 async fn check_request_content_type_flags_mismatch() {
3546 let spec_json = json!({
3547 "openapi": "3.0.0",
3548 "info": { "title": "T", "version": "1" },
3549 "paths": {
3550 "/api/appliance/access/consolecli": {
3551 "put": {
3552 "requestBody": {
3553 "required": true,
3554 "content": {
3555 "application/json": {
3556 "schema": {
3557 "type": "object",
3558 "required": ["enabled"],
3559 "properties": {"enabled": {"type": "boolean"}}
3560 }
3561 }
3562 }
3563 },
3564 "responses": { "204": { "description": "ok" } }
3565 }
3566 }
3567 }
3568 });
3569 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3570 let registry = OpenApiRouteRegistry::new(spec);
3571
3572 let r = registry.check_request_content_type(
3574 "/api/appliance/access/consolecli",
3575 "PUT",
3576 Some("application/xml"),
3577 );
3578 assert!(r.is_err(), "should flag application/xml: {:?}", r);
3579 let msg = r.unwrap_err();
3580 assert!(msg.contains("application/xml"), "{msg}");
3581 assert!(msg.contains("application/json"), "{msg}");
3582
3583 let r = registry.check_request_content_type(
3585 "/api/appliance/access/consolecli",
3586 "PUT",
3587 Some("application/json"),
3588 );
3589 assert!(r.is_ok(), "should accept application/json: {:?}", r);
3590
3591 let r = registry.check_request_content_type(
3593 "/api/appliance/access/consolecli",
3594 "PUT",
3595 Some("application/json; charset=utf-8"),
3596 );
3597 assert!(r.is_ok(), "should strip charset: {:?}", r);
3598
3599 let r = registry.check_request_content_type(
3601 "/api/appliance/access/consolecli",
3602 "GET",
3603 Some("application/xml"),
3604 );
3605 assert!(r.is_ok(), "GET has no requestBody on this op: {:?}", r);
3606
3607 let r =
3609 registry.check_request_content_type("/api/appliance/access/consolecli", "PUT", None);
3610 assert!(r.is_ok(), "no Content-Type → don't double-report: {:?}", r);
3611 }
3612
3613 #[tokio::test]
3614 async fn test_validate_request_with_params_and_formats() {
3615 let spec_json = json!({
3616 "openapi": "3.0.0",
3617 "info": { "title": "Test API", "version": "1.0.0" },
3618 "paths": {
3619 "/users/{id}": {
3620 "post": {
3621 "parameters": [
3622 { "name": "id", "in": "path", "required": true, "schema": {"type": "string"} },
3623 { "name": "q", "in": "query", "required": false, "schema": {"type": "integer"} }
3624 ],
3625 "requestBody": {
3626 "content": {
3627 "application/json": {
3628 "schema": {
3629 "type": "object",
3630 "required": ["email", "website"],
3631 "properties": {
3632 "email": {"type": "string", "format": "email"},
3633 "website": {"type": "string", "format": "uri"}
3634 }
3635 }
3636 }
3637 }
3638 },
3639 "responses": {"200": {"description": "ok"}}
3640 }
3641 }
3642 }
3643 });
3644
3645 let registry = create_registry_from_json(spec_json).unwrap();
3646 let mut path_params = Map::new();
3647 path_params.insert("id".to_string(), json!("abc"));
3648 let mut query_params = Map::new();
3649 query_params.insert("q".to_string(), json!(123));
3650
3651 let body = json!({"email":"a@b.co","website":"https://example.com"});
3653 assert!(registry
3654 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3655 .is_ok());
3656
3657 let bad_email = json!({"email":"not-an-email","website":"https://example.com"});
3659 assert!(registry
3660 .validate_request_with(
3661 "/users/{id}",
3662 "POST",
3663 &path_params,
3664 &query_params,
3665 Some(&bad_email)
3666 )
3667 .is_err());
3668
3669 let empty_path_params = Map::new();
3671 assert!(registry
3672 .validate_request_with(
3673 "/users/{id}",
3674 "POST",
3675 &empty_path_params,
3676 &query_params,
3677 Some(&body)
3678 )
3679 .is_err());
3680 }
3681
3682 #[tokio::test]
3683 async fn test_ref_resolution_for_params_and_body() {
3684 let spec_json = json!({
3685 "openapi": "3.0.0",
3686 "info": { "title": "Ref API", "version": "1.0.0" },
3687 "components": {
3688 "schemas": {
3689 "EmailWebsite": {
3690 "type": "object",
3691 "required": ["email", "website"],
3692 "properties": {
3693 "email": {"type": "string", "format": "email"},
3694 "website": {"type": "string", "format": "uri"}
3695 }
3696 }
3697 },
3698 "parameters": {
3699 "PathId": {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}},
3700 "QueryQ": {"name": "q", "in": "query", "required": false, "schema": {"type": "integer"}}
3701 },
3702 "requestBodies": {
3703 "CreateUser": {
3704 "content": {
3705 "application/json": {
3706 "schema": {"$ref": "#/components/schemas/EmailWebsite"}
3707 }
3708 }
3709 }
3710 }
3711 },
3712 "paths": {
3713 "/users/{id}": {
3714 "post": {
3715 "parameters": [
3716 {"$ref": "#/components/parameters/PathId"},
3717 {"$ref": "#/components/parameters/QueryQ"}
3718 ],
3719 "requestBody": {"$ref": "#/components/requestBodies/CreateUser"},
3720 "responses": {"200": {"description": "ok"}}
3721 }
3722 }
3723 }
3724 });
3725
3726 let registry = create_registry_from_json(spec_json).unwrap();
3727 let mut path_params = Map::new();
3728 path_params.insert("id".to_string(), json!("abc"));
3729 let mut query_params = Map::new();
3730 query_params.insert("q".to_string(), json!(7));
3731
3732 let body = json!({"email":"user@example.com","website":"https://example.com"});
3733 assert!(registry
3734 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
3735 .is_ok());
3736
3737 let bad = json!({"email":"nope","website":"https://example.com"});
3738 assert!(registry
3739 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&bad))
3740 .is_err());
3741 }
3742
3743 #[tokio::test]
3744 async fn test_header_cookie_and_query_coercion() {
3745 let spec_json = json!({
3746 "openapi": "3.0.0",
3747 "info": { "title": "Params API", "version": "1.0.0" },
3748 "paths": {
3749 "/items": {
3750 "get": {
3751 "parameters": [
3752 {"name": "X-Flag", "in": "header", "required": true, "schema": {"type": "boolean"}},
3753 {"name": "session", "in": "cookie", "required": true, "schema": {"type": "string"}},
3754 {"name": "ids", "in": "query", "required": false, "schema": {"type": "array", "items": {"type": "integer"}}}
3755 ],
3756 "responses": {"200": {"description": "ok"}}
3757 }
3758 }
3759 }
3760 });
3761
3762 let registry = create_registry_from_json(spec_json).unwrap();
3763
3764 let path_params = Map::new();
3765 let mut query_params = Map::new();
3766 query_params.insert("ids".to_string(), json!("1,2,3"));
3768 let mut header_params = Map::new();
3769 header_params.insert("X-Flag".to_string(), json!("true"));
3770 let mut cookie_params = Map::new();
3771 cookie_params.insert("session".to_string(), json!("abc123"));
3772
3773 assert!(registry
3774 .validate_request_with_all(
3775 "/items",
3776 "GET",
3777 &path_params,
3778 &query_params,
3779 &header_params,
3780 &cookie_params,
3781 None
3782 )
3783 .is_ok());
3784
3785 let empty_cookie = Map::new();
3787 assert!(registry
3788 .validate_request_with_all(
3789 "/items",
3790 "GET",
3791 &path_params,
3792 &query_params,
3793 &header_params,
3794 &empty_cookie,
3795 None
3796 )
3797 .is_err());
3798
3799 let mut bad_header = Map::new();
3801 bad_header.insert("X-Flag".to_string(), json!("notabool"));
3802 assert!(registry
3803 .validate_request_with_all(
3804 "/items",
3805 "GET",
3806 &path_params,
3807 &query_params,
3808 &bad_header,
3809 &cookie_params,
3810 None
3811 )
3812 .is_err());
3813 }
3814
3815 #[tokio::test]
3816 async fn test_query_styles_space_pipe_deepobject() {
3817 let spec_json = json!({
3818 "openapi": "3.0.0",
3819 "info": { "title": "Query Styles API", "version": "1.0.0" },
3820 "paths": {"/search": {"get": {
3821 "parameters": [
3822 {"name":"tags","in":"query","style":"spaceDelimited","schema":{"type":"array","items":{"type":"string"}}},
3823 {"name":"ids","in":"query","style":"pipeDelimited","schema":{"type":"array","items":{"type":"integer"}}},
3824 {"name":"filter","in":"query","style":"deepObject","schema":{"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}}
3825 ],
3826 "responses": {"200": {"description":"ok"}}
3827 }} }
3828 });
3829
3830 let registry = create_registry_from_json(spec_json).unwrap();
3831
3832 let path_params = Map::new();
3833 let mut query = Map::new();
3834 query.insert("tags".into(), json!("alpha beta gamma"));
3835 query.insert("ids".into(), json!("1|2|3"));
3836 query.insert("filter[color]".into(), json!("red"));
3837
3838 assert!(registry
3839 .validate_request_with("/search", "GET", &path_params, &query, None)
3840 .is_ok());
3841 }
3842
3843 #[tokio::test]
3844 async fn test_oneof_anyof_allof_validation() {
3845 let spec_json = json!({
3846 "openapi": "3.0.0",
3847 "info": { "title": "Composite API", "version": "1.0.0" },
3848 "paths": {
3849 "/composite": {
3850 "post": {
3851 "requestBody": {
3852 "content": {
3853 "application/json": {
3854 "schema": {
3855 "allOf": [
3856 {"type": "object", "required": ["base"], "properties": {"base": {"type": "string"}}}
3857 ],
3858 "oneOf": [
3859 {"type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"], "not": {"required": ["b"]}},
3860 {"type": "object", "properties": {"b": {"type": "integer"}}, "required": ["b"], "not": {"required": ["a"]}}
3861 ],
3862 "anyOf": [
3863 {"type": "object", "properties": {"flag": {"type": "boolean"}}, "required": ["flag"]},
3864 {"type": "object", "properties": {"extra": {"type": "string"}}, "required": ["extra"]}
3865 ]
3866 }
3867 }
3868 }
3869 },
3870 "responses": {"200": {"description": "ok"}}
3871 }
3872 }
3873 }
3874 });
3875
3876 let registry = create_registry_from_json(spec_json).unwrap();
3877 let ok = json!({"base": "x", "a": 1, "flag": true});
3879 assert!(registry
3880 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&ok))
3881 .is_ok());
3882
3883 let bad_oneof = json!({"base": "x", "a": 1, "b": 2, "flag": false});
3885 assert!(registry
3886 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_oneof))
3887 .is_err());
3888
3889 let bad_anyof = json!({"base": "x", "a": 1});
3891 assert!(registry
3892 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_anyof))
3893 .is_err());
3894
3895 let bad_allof = json!({"a": 1, "flag": true});
3897 assert!(registry
3898 .validate_request_with("/composite", "POST", &Map::new(), &Map::new(), Some(&bad_allof))
3899 .is_err());
3900 }
3901
3902 #[tokio::test]
3911 async fn dotted_schema_ref_resolves_in_route_validator() {
3912 let spec_json = json!({
3913 "openapi": "3.0.0",
3914 "info": { "title": "Dotted", "version": "1.0.0" },
3915 "paths": {
3916 "/x": {
3917 "post": {
3918 "requestBody": {
3919 "required": true,
3920 "content": {
3921 "application/json": {
3922 "schema": {
3923 "$ref": "#/components/schemas/Esx.Settings.Inventory.EntitySpec"
3924 }
3925 }
3926 }
3927 },
3928 "responses": {"200": {"description": "ok"}}
3929 }
3930 }
3931 },
3932 "components": {
3933 "schemas": {
3934 "Esx.Settings.Inventory.EntitySpec": {
3935 "type": "object",
3936 "required": ["type"],
3937 "properties": {"type": {"type": "string"}}
3938 }
3939 }
3940 }
3941 });
3942 let registry = create_registry_from_json(spec_json).unwrap();
3943 let good = json!({"type": "HOST"});
3946 let res =
3947 registry.validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&good));
3948 assert!(res.is_ok(), "valid body should pass; got {res:?}");
3949 let bad = json!({"unrelated": 1});
3951 let err = registry
3952 .validate_request_with("/x", "POST", &Map::new(), &Map::new(), Some(&bad))
3953 .unwrap_err();
3954 let msg = format!("{err}");
3955 assert!(
3956 !msg.contains("Pointer") || !msg.contains("does not exist"),
3957 "should not be a pointer-resolution failure; got: {msg}"
3958 );
3959 }
3960
3961 #[tokio::test]
3962 async fn test_overrides_warn_mode_allows_invalid() {
3963 let spec_json = json!({
3965 "openapi": "3.0.0",
3966 "info": { "title": "Overrides API", "version": "1.0.0" },
3967 "paths": {"/things": {"post": {
3968 "parameters": [{"name":"q","in":"query","required":true,"schema":{"type":"integer"}}],
3969 "responses": {"200": {"description":"ok"}}
3970 }}}
3971 });
3972
3973 let spec = OpenApiSpec::from_json(spec_json).unwrap();
3974 let mut overrides = HashMap::new();
3975 overrides.insert("POST /things".to_string(), ValidationMode::Warn);
3976 let registry = OpenApiRouteRegistry::new_with_options(
3977 spec,
3978 ValidationOptions {
3979 request_mode: ValidationMode::Enforce,
3980 aggregate_errors: true,
3981 validate_responses: false,
3982 overrides,
3983 admin_skip_prefixes: vec![],
3984 response_template_expand: false,
3985 validation_status: None,
3986 },
3987 );
3988
3989 let ok = registry.validate_request_with("/things", "POST", &Map::new(), &Map::new(), None);
3991 assert!(ok.is_ok());
3992 }
3993
3994 #[tokio::test]
3995 async fn test_admin_skip_prefix_short_circuit() {
3996 let spec_json = json!({
3997 "openapi": "3.0.0",
3998 "info": { "title": "Skip API", "version": "1.0.0" },
3999 "paths": {}
4000 });
4001 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4002 let registry = OpenApiRouteRegistry::new_with_options(
4003 spec,
4004 ValidationOptions {
4005 request_mode: ValidationMode::Enforce,
4006 aggregate_errors: true,
4007 validate_responses: false,
4008 overrides: HashMap::new(),
4009 admin_skip_prefixes: vec!["/admin".into()],
4010 response_template_expand: false,
4011 validation_status: None,
4012 },
4013 );
4014
4015 let res = registry.validate_request_with_all(
4017 "/admin/__mockforge/health",
4018 "GET",
4019 &Map::new(),
4020 &Map::new(),
4021 &Map::new(),
4022 &Map::new(),
4023 None,
4024 );
4025 assert!(res.is_ok());
4026 }
4027
4028 #[test]
4029 fn test_path_conversion() {
4030 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users"), "/users");
4031 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users/{id}"), "/users/{id}");
4032 assert_eq!(
4033 OpenApiRouteRegistry::convert_path_to_axum("/users/{id}/posts/{postId}"),
4034 "/users/{id}/posts/{postId}"
4035 );
4036 }
4037
4038 #[test]
4039 fn test_validation_options_default() {
4040 let options = ValidationOptions::default();
4041 assert!(matches!(options.request_mode, ValidationMode::Enforce));
4042 assert!(options.aggregate_errors);
4043 assert!(!options.validate_responses);
4044 assert!(options.overrides.is_empty());
4045 assert!(options.admin_skip_prefixes.is_empty());
4046 assert!(!options.response_template_expand);
4047 assert!(options.validation_status.is_none());
4048 }
4049
4050 #[test]
4051 fn test_validation_mode_variants() {
4052 let disabled = ValidationMode::Disabled;
4054 let warn = ValidationMode::Warn;
4055 let enforce = ValidationMode::Enforce;
4056 let default = ValidationMode::default();
4057
4058 assert!(matches!(default, ValidationMode::Warn));
4060
4061 assert!(!matches!(disabled, ValidationMode::Warn));
4063 assert!(!matches!(warn, ValidationMode::Enforce));
4064 assert!(!matches!(enforce, ValidationMode::Disabled));
4065 }
4066
4067 #[test]
4068 fn test_registry_spec_accessor() {
4069 let spec_json = json!({
4070 "openapi": "3.0.0",
4071 "info": {
4072 "title": "Test API",
4073 "version": "1.0.0"
4074 },
4075 "paths": {}
4076 });
4077 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4078 let registry = OpenApiRouteRegistry::new(spec.clone());
4079
4080 let accessed_spec = registry.spec();
4082 assert_eq!(accessed_spec.title(), "Test API");
4083 }
4084
4085 #[test]
4086 fn test_clone_for_validation() {
4087 let spec_json = json!({
4088 "openapi": "3.0.0",
4089 "info": {
4090 "title": "Test API",
4091 "version": "1.0.0"
4092 },
4093 "paths": {
4094 "/users": {
4095 "get": {
4096 "responses": {
4097 "200": {
4098 "description": "Success"
4099 }
4100 }
4101 }
4102 }
4103 }
4104 });
4105 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4106 let registry = OpenApiRouteRegistry::new(spec);
4107
4108 let cloned = registry.clone_for_validation();
4110 assert_eq!(cloned.routes().len(), registry.routes().len());
4111 assert_eq!(cloned.spec().title(), registry.spec().title());
4112 }
4113
4114 #[test]
4115 fn test_with_custom_fixture_loader() {
4116 let temp_dir = TempDir::new().unwrap();
4117 let spec_json = json!({
4118 "openapi": "3.0.0",
4119 "info": {
4120 "title": "Test API",
4121 "version": "1.0.0"
4122 },
4123 "paths": {}
4124 });
4125 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4126 let registry = OpenApiRouteRegistry::new(spec);
4127 let original_routes_len = registry.routes().len();
4128
4129 let custom_loader = Arc::new(crate::custom_fixture::CustomFixtureLoader::new(
4131 temp_dir.path().to_path_buf(),
4132 true,
4133 ));
4134 let registry_with_loader = registry.with_custom_fixture_loader(custom_loader);
4135
4136 assert_eq!(registry_with_loader.routes().len(), original_routes_len);
4138 }
4139
4140 #[test]
4141 fn test_get_route() {
4142 let spec_json = json!({
4143 "openapi": "3.0.0",
4144 "info": {
4145 "title": "Test API",
4146 "version": "1.0.0"
4147 },
4148 "paths": {
4149 "/users": {
4150 "get": {
4151 "operationId": "getUsers",
4152 "responses": {
4153 "200": {
4154 "description": "Success"
4155 }
4156 }
4157 },
4158 "post": {
4159 "operationId": "createUser",
4160 "responses": {
4161 "201": {
4162 "description": "Created"
4163 }
4164 }
4165 }
4166 }
4167 }
4168 });
4169 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4170 let registry = OpenApiRouteRegistry::new(spec);
4171
4172 let route = registry.get_route("/users", "GET");
4174 assert!(route.is_some());
4175 assert_eq!(route.unwrap().method, "GET");
4176 assert_eq!(route.unwrap().path, "/users");
4177
4178 let route = registry.get_route("/nonexistent", "GET");
4180 assert!(route.is_none());
4181
4182 let route = registry.get_route("/users", "POST");
4184 assert!(route.is_some());
4185 assert_eq!(route.unwrap().method, "POST");
4186 }
4187
4188 #[test]
4189 fn test_get_routes_for_path() {
4190 let spec_json = json!({
4191 "openapi": "3.0.0",
4192 "info": {
4193 "title": "Test API",
4194 "version": "1.0.0"
4195 },
4196 "paths": {
4197 "/users": {
4198 "get": {
4199 "responses": {
4200 "200": {
4201 "description": "Success"
4202 }
4203 }
4204 },
4205 "post": {
4206 "responses": {
4207 "201": {
4208 "description": "Created"
4209 }
4210 }
4211 },
4212 "put": {
4213 "responses": {
4214 "200": {
4215 "description": "Success"
4216 }
4217 }
4218 }
4219 },
4220 "/posts": {
4221 "get": {
4222 "responses": {
4223 "200": {
4224 "description": "Success"
4225 }
4226 }
4227 }
4228 }
4229 }
4230 });
4231 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4232 let registry = OpenApiRouteRegistry::new(spec);
4233
4234 let routes = registry.get_routes_for_path("/users");
4236 assert_eq!(routes.len(), 3);
4237 let methods: Vec<&str> = routes.iter().map(|r| r.method.as_str()).collect();
4238 assert!(methods.contains(&"GET"));
4239 assert!(methods.contains(&"POST"));
4240 assert!(methods.contains(&"PUT"));
4241
4242 let routes = registry.get_routes_for_path("/posts");
4244 assert_eq!(routes.len(), 1);
4245 assert_eq!(routes[0].method, "GET");
4246
4247 let routes = registry.get_routes_for_path("/nonexistent");
4249 assert!(routes.is_empty());
4250 }
4251
4252 #[test]
4253 fn test_new_vs_new_with_options() {
4254 let spec_json = json!({
4255 "openapi": "3.0.0",
4256 "info": {
4257 "title": "Test API",
4258 "version": "1.0.0"
4259 },
4260 "paths": {}
4261 });
4262 let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
4263 let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
4264
4265 let registry1 = OpenApiRouteRegistry::new(spec1);
4267 assert_eq!(registry1.spec().title(), "Test API");
4268
4269 let options = ValidationOptions {
4271 request_mode: ValidationMode::Disabled,
4272 aggregate_errors: false,
4273 validate_responses: true,
4274 overrides: HashMap::new(),
4275 admin_skip_prefixes: vec!["/admin".to_string()],
4276 response_template_expand: true,
4277 validation_status: Some(422),
4278 };
4279 let registry2 = OpenApiRouteRegistry::new_with_options(spec2, options);
4280 assert_eq!(registry2.spec().title(), "Test API");
4281 }
4282
4283 #[test]
4284 fn test_new_with_env_vs_new() {
4285 let spec_json = json!({
4286 "openapi": "3.0.0",
4287 "info": {
4288 "title": "Test API",
4289 "version": "1.0.0"
4290 },
4291 "paths": {}
4292 });
4293 let spec1 = OpenApiSpec::from_json(spec_json.clone()).unwrap();
4294 let spec2 = OpenApiSpec::from_json(spec_json).unwrap();
4295
4296 let registry1 = OpenApiRouteRegistry::new(spec1);
4298
4299 let registry2 = OpenApiRouteRegistry::new_with_env(spec2);
4301
4302 assert_eq!(registry1.spec().title(), "Test API");
4304 assert_eq!(registry2.spec().title(), "Test API");
4305 }
4306
4307 #[test]
4308 fn test_validation_options_custom() {
4309 let options = ValidationOptions {
4310 request_mode: ValidationMode::Warn,
4311 aggregate_errors: false,
4312 validate_responses: true,
4313 overrides: {
4314 let mut map = HashMap::new();
4315 map.insert("getUsers".to_string(), ValidationMode::Disabled);
4316 map
4317 },
4318 admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
4319 response_template_expand: true,
4320 validation_status: Some(422),
4321 };
4322
4323 assert!(matches!(options.request_mode, ValidationMode::Warn));
4324 assert!(!options.aggregate_errors);
4325 assert!(options.validate_responses);
4326 assert_eq!(options.overrides.len(), 1);
4327 assert_eq!(options.admin_skip_prefixes.len(), 2);
4328 assert!(options.response_template_expand);
4329 assert_eq!(options.validation_status, Some(422));
4330 }
4331
4332 #[test]
4333 fn test_validation_mode_default_standalone() {
4334 let mode = ValidationMode::default();
4335 assert!(matches!(mode, ValidationMode::Warn));
4336 }
4337
4338 #[test]
4339 fn test_validation_mode_clone() {
4340 let mode1 = ValidationMode::Enforce;
4341 let mode2 = mode1.clone();
4342 assert!(matches!(mode1, ValidationMode::Enforce));
4343 assert!(matches!(mode2, ValidationMode::Enforce));
4344 }
4345
4346 #[test]
4347 fn test_validation_mode_debug() {
4348 let mode = ValidationMode::Disabled;
4349 let debug_str = format!("{:?}", mode);
4350 assert!(debug_str.contains("Disabled") || debug_str.contains("ValidationMode"));
4351 }
4352
4353 #[test]
4354 fn test_validation_options_clone() {
4355 let options1 = ValidationOptions {
4356 request_mode: ValidationMode::Warn,
4357 aggregate_errors: true,
4358 validate_responses: false,
4359 overrides: HashMap::new(),
4360 admin_skip_prefixes: vec![],
4361 response_template_expand: false,
4362 validation_status: None,
4363 };
4364 let options2 = options1.clone();
4365 assert!(matches!(options2.request_mode, ValidationMode::Warn));
4366 assert_eq!(options1.aggregate_errors, options2.aggregate_errors);
4367 }
4368
4369 #[test]
4370 fn test_validation_options_debug() {
4371 let options = ValidationOptions::default();
4372 let debug_str = format!("{:?}", options);
4373 assert!(debug_str.contains("ValidationOptions"));
4374 }
4375
4376 #[test]
4377 fn test_validation_options_with_all_fields() {
4378 let mut overrides = HashMap::new();
4379 overrides.insert("op1".to_string(), ValidationMode::Disabled);
4380 overrides.insert("op2".to_string(), ValidationMode::Warn);
4381
4382 let options = ValidationOptions {
4383 request_mode: ValidationMode::Enforce,
4384 aggregate_errors: false,
4385 validate_responses: true,
4386 overrides: overrides.clone(),
4387 admin_skip_prefixes: vec!["/admin".to_string(), "/internal".to_string()],
4388 response_template_expand: true,
4389 validation_status: Some(422),
4390 };
4391
4392 assert!(matches!(options.request_mode, ValidationMode::Enforce));
4393 assert!(!options.aggregate_errors);
4394 assert!(options.validate_responses);
4395 assert_eq!(options.overrides.len(), 2);
4396 assert_eq!(options.admin_skip_prefixes.len(), 2);
4397 assert!(options.response_template_expand);
4398 assert_eq!(options.validation_status, Some(422));
4399 }
4400
4401 #[test]
4402 fn test_openapi_route_registry_clone() {
4403 let spec_json = json!({
4404 "openapi": "3.0.0",
4405 "info": { "title": "Test API", "version": "1.0.0" },
4406 "paths": {}
4407 });
4408 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4409 let registry1 = OpenApiRouteRegistry::new(spec);
4410 let registry2 = registry1.clone();
4411 assert_eq!(registry1.spec().title(), registry2.spec().title());
4412 }
4413
4414 #[test]
4415 fn test_validation_mode_serialization() {
4416 let mode = ValidationMode::Enforce;
4417 let json = serde_json::to_string(&mode).unwrap();
4418 assert!(json.contains("Enforce") || json.contains("enforce"));
4419 }
4420
4421 #[test]
4422 fn test_validation_mode_deserialization() {
4423 let json = r#""Disabled""#;
4424 let mode: ValidationMode = serde_json::from_str(json).unwrap();
4425 assert!(matches!(mode, ValidationMode::Disabled));
4426 }
4427
4428 #[test]
4429 fn test_validation_options_default_values() {
4430 let options = ValidationOptions::default();
4431 assert!(matches!(options.request_mode, ValidationMode::Enforce));
4432 assert!(options.aggregate_errors);
4433 assert!(!options.validate_responses);
4434 assert!(options.overrides.is_empty());
4435 assert!(options.admin_skip_prefixes.is_empty());
4436 assert!(!options.response_template_expand);
4437 assert_eq!(options.validation_status, None);
4438 }
4439
4440 #[test]
4441 fn test_validation_mode_all_variants() {
4442 let disabled = ValidationMode::Disabled;
4443 let warn = ValidationMode::Warn;
4444 let enforce = ValidationMode::Enforce;
4445
4446 assert!(matches!(disabled, ValidationMode::Disabled));
4447 assert!(matches!(warn, ValidationMode::Warn));
4448 assert!(matches!(enforce, ValidationMode::Enforce));
4449 }
4450
4451 #[test]
4452 fn test_validation_options_with_overrides() {
4453 let mut overrides = HashMap::new();
4454 overrides.insert("operation1".to_string(), ValidationMode::Disabled);
4455 overrides.insert("operation2".to_string(), ValidationMode::Warn);
4456
4457 let options = ValidationOptions {
4458 request_mode: ValidationMode::Enforce,
4459 aggregate_errors: true,
4460 validate_responses: false,
4461 overrides,
4462 admin_skip_prefixes: vec![],
4463 response_template_expand: false,
4464 validation_status: None,
4465 };
4466
4467 assert_eq!(options.overrides.len(), 2);
4468 assert!(matches!(options.overrides.get("operation1"), Some(ValidationMode::Disabled)));
4469 assert!(matches!(options.overrides.get("operation2"), Some(ValidationMode::Warn)));
4470 }
4471
4472 #[test]
4473 fn test_validation_options_with_admin_skip_prefixes() {
4474 let options = ValidationOptions {
4475 request_mode: ValidationMode::Enforce,
4476 aggregate_errors: true,
4477 validate_responses: false,
4478 overrides: HashMap::new(),
4479 admin_skip_prefixes: vec![
4480 "/admin".to_string(),
4481 "/internal".to_string(),
4482 "/debug".to_string(),
4483 ],
4484 response_template_expand: false,
4485 validation_status: None,
4486 };
4487
4488 assert_eq!(options.admin_skip_prefixes.len(), 3);
4489 assert!(options.admin_skip_prefixes.contains(&"/admin".to_string()));
4490 assert!(options.admin_skip_prefixes.contains(&"/internal".to_string()));
4491 assert!(options.admin_skip_prefixes.contains(&"/debug".to_string()));
4492 }
4493
4494 #[test]
4495 fn test_validation_options_with_validation_status() {
4496 let options1 = ValidationOptions {
4497 request_mode: ValidationMode::Enforce,
4498 aggregate_errors: true,
4499 validate_responses: false,
4500 overrides: HashMap::new(),
4501 admin_skip_prefixes: vec![],
4502 response_template_expand: false,
4503 validation_status: Some(400),
4504 };
4505
4506 let options2 = ValidationOptions {
4507 request_mode: ValidationMode::Enforce,
4508 aggregate_errors: true,
4509 validate_responses: false,
4510 overrides: HashMap::new(),
4511 admin_skip_prefixes: vec![],
4512 response_template_expand: false,
4513 validation_status: Some(422),
4514 };
4515
4516 assert_eq!(options1.validation_status, Some(400));
4517 assert_eq!(options2.validation_status, Some(422));
4518 }
4519
4520 #[test]
4521 fn test_validate_request_with_disabled_mode() {
4522 let spec_json = json!({
4524 "openapi": "3.0.0",
4525 "info": {"title": "Test API", "version": "1.0.0"},
4526 "paths": {
4527 "/users": {
4528 "get": {
4529 "responses": {"200": {"description": "OK"}}
4530 }
4531 }
4532 }
4533 });
4534 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4535 let options = ValidationOptions {
4536 request_mode: ValidationMode::Disabled,
4537 ..Default::default()
4538 };
4539 let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4540
4541 let result = registry.validate_request_with_all(
4543 "/users",
4544 "GET",
4545 &Map::new(),
4546 &Map::new(),
4547 &Map::new(),
4548 &Map::new(),
4549 None,
4550 );
4551 assert!(result.is_ok());
4552 }
4553
4554 #[test]
4555 fn test_validate_request_with_warn_mode() {
4556 let spec_json = json!({
4558 "openapi": "3.0.0",
4559 "info": {"title": "Test API", "version": "1.0.0"},
4560 "paths": {
4561 "/users": {
4562 "post": {
4563 "requestBody": {
4564 "required": true,
4565 "content": {
4566 "application/json": {
4567 "schema": {
4568 "type": "object",
4569 "required": ["name"],
4570 "properties": {
4571 "name": {"type": "string"}
4572 }
4573 }
4574 }
4575 }
4576 },
4577 "responses": {"200": {"description": "OK"}}
4578 }
4579 }
4580 }
4581 });
4582 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4583 let options = ValidationOptions {
4584 request_mode: ValidationMode::Warn,
4585 ..Default::default()
4586 };
4587 let registry = OpenApiRouteRegistry::new_with_options(spec, options);
4588
4589 let result = registry.validate_request_with_all(
4591 "/users",
4592 "POST",
4593 &Map::new(),
4594 &Map::new(),
4595 &Map::new(),
4596 &Map::new(),
4597 None, );
4599 assert!(result.is_ok()); }
4601
4602 #[test]
4603 fn test_validate_request_body_validation_error() {
4604 let spec_json = json!({
4606 "openapi": "3.0.0",
4607 "info": {"title": "Test API", "version": "1.0.0"},
4608 "paths": {
4609 "/users": {
4610 "post": {
4611 "requestBody": {
4612 "required": true,
4613 "content": {
4614 "application/json": {
4615 "schema": {
4616 "type": "object",
4617 "required": ["name"],
4618 "properties": {
4619 "name": {"type": "string"}
4620 }
4621 }
4622 }
4623 }
4624 },
4625 "responses": {"200": {"description": "OK"}}
4626 }
4627 }
4628 }
4629 });
4630 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4631 let registry = OpenApiRouteRegistry::new(spec);
4632
4633 let result = registry.validate_request_with_all(
4635 "/users",
4636 "POST",
4637 &Map::new(),
4638 &Map::new(),
4639 &Map::new(),
4640 &Map::new(),
4641 None, );
4643 assert!(result.is_err());
4644 }
4645
4646 #[test]
4647 fn test_validate_request_body_schema_validation_error() {
4648 let spec_json = json!({
4650 "openapi": "3.0.0",
4651 "info": {"title": "Test API", "version": "1.0.0"},
4652 "paths": {
4653 "/users": {
4654 "post": {
4655 "requestBody": {
4656 "required": true,
4657 "content": {
4658 "application/json": {
4659 "schema": {
4660 "type": "object",
4661 "required": ["name"],
4662 "properties": {
4663 "name": {"type": "string"}
4664 }
4665 }
4666 }
4667 }
4668 },
4669 "responses": {"200": {"description": "OK"}}
4670 }
4671 }
4672 }
4673 });
4674 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4675 let registry = OpenApiRouteRegistry::new(spec);
4676
4677 let invalid_body = json!({}); let result = registry.validate_request_with_all(
4680 "/users",
4681 "POST",
4682 &Map::new(),
4683 &Map::new(),
4684 &Map::new(),
4685 &Map::new(),
4686 Some(&invalid_body),
4687 );
4688 assert!(result.is_err());
4689 }
4690
4691 #[test]
4692 fn test_validate_request_body_referenced_schema_error() {
4693 let spec_json = json!({
4695 "openapi": "3.0.0",
4696 "info": {"title": "Test API", "version": "1.0.0"},
4697 "paths": {
4698 "/users": {
4699 "post": {
4700 "requestBody": {
4701 "required": true,
4702 "content": {
4703 "application/json": {
4704 "schema": {
4705 "$ref": "#/components/schemas/NonExistentSchema"
4706 }
4707 }
4708 }
4709 },
4710 "responses": {"200": {"description": "OK"}}
4711 }
4712 }
4713 },
4714 "components": {}
4715 });
4716 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4717 let registry = OpenApiRouteRegistry::new(spec);
4718
4719 let body = json!({"name": "test"});
4721 let result = registry.validate_request_with_all(
4722 "/users",
4723 "POST",
4724 &Map::new(),
4725 &Map::new(),
4726 &Map::new(),
4727 &Map::new(),
4728 Some(&body),
4729 );
4730 assert!(result.is_err());
4731 }
4732
4733 #[test]
4734 fn test_validate_request_body_referenced_request_body_error() {
4735 let spec_json = json!({
4737 "openapi": "3.0.0",
4738 "info": {"title": "Test API", "version": "1.0.0"},
4739 "paths": {
4740 "/users": {
4741 "post": {
4742 "requestBody": {
4743 "$ref": "#/components/requestBodies/NonExistentRequestBody"
4744 },
4745 "responses": {"200": {"description": "OK"}}
4746 }
4747 }
4748 },
4749 "components": {}
4750 });
4751 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4752 let registry = OpenApiRouteRegistry::new(spec);
4753
4754 let body = json!({"name": "test"});
4756 let result = registry.validate_request_with_all(
4757 "/users",
4758 "POST",
4759 &Map::new(),
4760 &Map::new(),
4761 &Map::new(),
4762 &Map::new(),
4763 Some(&body),
4764 );
4765 assert!(result.is_err());
4766 }
4767
4768 #[test]
4769 fn test_validate_request_body_provided_when_not_expected() {
4770 let spec_json = json!({
4772 "openapi": "3.0.0",
4773 "info": {"title": "Test API", "version": "1.0.0"},
4774 "paths": {
4775 "/users": {
4776 "get": {
4777 "responses": {"200": {"description": "OK"}}
4778 }
4779 }
4780 }
4781 });
4782 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4783 let registry = OpenApiRouteRegistry::new(spec);
4784
4785 let body = json!({"extra": "data"});
4787 let result = registry.validate_request_with_all(
4788 "/users",
4789 "GET",
4790 &Map::new(),
4791 &Map::new(),
4792 &Map::new(),
4793 &Map::new(),
4794 Some(&body),
4795 );
4796 assert!(result.is_ok());
4798 }
4799
4800 #[test]
4801 fn test_get_operation() {
4802 let spec_json = json!({
4804 "openapi": "3.0.0",
4805 "info": {"title": "Test API", "version": "1.0.0"},
4806 "paths": {
4807 "/users": {
4808 "get": {
4809 "operationId": "getUsers",
4810 "summary": "Get users",
4811 "responses": {"200": {"description": "OK"}}
4812 }
4813 }
4814 }
4815 });
4816 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4817 let registry = OpenApiRouteRegistry::new(spec);
4818
4819 let operation = registry.get_operation("/users", "GET");
4821 assert!(operation.is_some());
4822 assert_eq!(operation.unwrap().method, "GET");
4823
4824 assert!(registry.get_operation("/nonexistent", "GET").is_none());
4826 }
4827
4828 #[test]
4829 fn test_extract_path_parameters() {
4830 let spec_json = json!({
4832 "openapi": "3.0.0",
4833 "info": {"title": "Test API", "version": "1.0.0"},
4834 "paths": {
4835 "/users/{id}": {
4836 "get": {
4837 "parameters": [
4838 {
4839 "name": "id",
4840 "in": "path",
4841 "required": true,
4842 "schema": {"type": "string"}
4843 }
4844 ],
4845 "responses": {"200": {"description": "OK"}}
4846 }
4847 }
4848 }
4849 });
4850 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4851 let registry = OpenApiRouteRegistry::new(spec);
4852
4853 let params = registry.extract_path_parameters("/users/123", "GET");
4855 assert_eq!(params.get("id"), Some(&"123".to_string()));
4856
4857 let empty_params = registry.extract_path_parameters("/users", "GET");
4859 assert!(empty_params.is_empty());
4860 }
4861
4862 #[test]
4863 fn extract_path_parameters_prefers_static_route_and_rejects_empty() {
4864 let spec_json = json!({
4867 "openapi": "3.0.0",
4868 "info": {"title": "Test API", "version": "1.0.0"},
4869 "paths": {
4870 "/users/{id}": { "get": { "responses": {"200": {"description": "OK"}} } },
4871 "/users/me": { "get": { "responses": {"200": {"description": "OK"}} } }
4872 }
4873 });
4874 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4875 let registry = OpenApiRouteRegistry::new(spec);
4876
4877 let me = registry.extract_path_parameters("/users/me", "GET");
4879 assert!(!me.contains_key("id"), "literal route should win, got {me:?}");
4880
4881 let by_id = registry.extract_path_parameters("/users/123", "GET");
4883 assert_eq!(by_id.get("id"), Some(&"123".to_string()));
4884
4885 let trailing = registry.extract_path_parameters("/users/", "GET");
4887 assert!(
4888 trailing.is_empty(),
4889 "empty trailing segment should not bind id, got {trailing:?}"
4890 );
4891 }
4892
4893 #[test]
4894 fn test_extract_path_parameters_multiple_params() {
4895 let spec_json = json!({
4897 "openapi": "3.0.0",
4898 "info": {"title": "Test API", "version": "1.0.0"},
4899 "paths": {
4900 "/users/{userId}/posts/{postId}": {
4901 "get": {
4902 "parameters": [
4903 {
4904 "name": "userId",
4905 "in": "path",
4906 "required": true,
4907 "schema": {"type": "string"}
4908 },
4909 {
4910 "name": "postId",
4911 "in": "path",
4912 "required": true,
4913 "schema": {"type": "string"}
4914 }
4915 ],
4916 "responses": {"200": {"description": "OK"}}
4917 }
4918 }
4919 }
4920 });
4921 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4922 let registry = OpenApiRouteRegistry::new(spec);
4923
4924 let params = registry.extract_path_parameters("/users/123/posts/456", "GET");
4926 assert_eq!(params.get("userId"), Some(&"123".to_string()));
4927 assert_eq!(params.get("postId"), Some(&"456".to_string()));
4928 }
4929
4930 #[test]
4931 fn test_validate_request_route_not_found() {
4932 let spec_json = json!({
4934 "openapi": "3.0.0",
4935 "info": {"title": "Test API", "version": "1.0.0"},
4936 "paths": {
4937 "/users": {
4938 "get": {
4939 "responses": {"200": {"description": "OK"}}
4940 }
4941 }
4942 }
4943 });
4944 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4945 let registry = OpenApiRouteRegistry::new(spec);
4946
4947 let result = registry.validate_request_with_all(
4949 "/nonexistent",
4950 "GET",
4951 &Map::new(),
4952 &Map::new(),
4953 &Map::new(),
4954 &Map::new(),
4955 None,
4956 );
4957 assert!(result.is_err());
4958 assert!(result.unwrap_err().to_string().contains("not found"));
4959 }
4960
4961 #[test]
4962 fn test_validate_request_with_path_parameters() {
4963 let spec_json = json!({
4965 "openapi": "3.0.0",
4966 "info": {"title": "Test API", "version": "1.0.0"},
4967 "paths": {
4968 "/users/{id}": {
4969 "get": {
4970 "parameters": [
4971 {
4972 "name": "id",
4973 "in": "path",
4974 "required": true,
4975 "schema": {"type": "string", "minLength": 1}
4976 }
4977 ],
4978 "responses": {"200": {"description": "OK"}}
4979 }
4980 }
4981 }
4982 });
4983 let spec = OpenApiSpec::from_json(spec_json).unwrap();
4984 let registry = OpenApiRouteRegistry::new(spec);
4985
4986 let mut path_params = Map::new();
4988 path_params.insert("id".to_string(), json!("123"));
4989 let result = registry.validate_request_with_all(
4990 "/users/{id}",
4991 "GET",
4992 &path_params,
4993 &Map::new(),
4994 &Map::new(),
4995 &Map::new(),
4996 None,
4997 );
4998 assert!(result.is_ok());
4999 }
5000
5001 #[test]
5002 fn test_validate_request_with_query_parameters() {
5003 let spec_json = json!({
5005 "openapi": "3.0.0",
5006 "info": {"title": "Test API", "version": "1.0.0"},
5007 "paths": {
5008 "/users": {
5009 "get": {
5010 "parameters": [
5011 {
5012 "name": "page",
5013 "in": "query",
5014 "required": true,
5015 "schema": {"type": "integer", "minimum": 1}
5016 }
5017 ],
5018 "responses": {"200": {"description": "OK"}}
5019 }
5020 }
5021 }
5022 });
5023 let spec = OpenApiSpec::from_json(spec_json).unwrap();
5024 let registry = OpenApiRouteRegistry::new(spec);
5025
5026 let mut query_params = Map::new();
5028 query_params.insert("page".to_string(), json!(1));
5029 let result = registry.validate_request_with_all(
5030 "/users",
5031 "GET",
5032 &Map::new(),
5033 &query_params,
5034 &Map::new(),
5035 &Map::new(),
5036 None,
5037 );
5038 assert!(result.is_ok());
5039 }
5040
5041 #[test]
5042 fn test_validate_request_with_header_parameters() {
5043 let spec_json = json!({
5045 "openapi": "3.0.0",
5046 "info": {"title": "Test API", "version": "1.0.0"},
5047 "paths": {
5048 "/users": {
5049 "get": {
5050 "parameters": [
5051 {
5052 "name": "X-API-Key",
5053 "in": "header",
5054 "required": true,
5055 "schema": {"type": "string"}
5056 }
5057 ],
5058 "responses": {"200": {"description": "OK"}}
5059 }
5060 }
5061 }
5062 });
5063 let spec = OpenApiSpec::from_json(spec_json).unwrap();
5064 let registry = OpenApiRouteRegistry::new(spec);
5065
5066 let mut header_params = Map::new();
5068 header_params.insert("X-API-Key".to_string(), json!("secret-key"));
5069 let result = registry.validate_request_with_all(
5070 "/users",
5071 "GET",
5072 &Map::new(),
5073 &Map::new(),
5074 &header_params,
5075 &Map::new(),
5076 None,
5077 );
5078 assert!(result.is_ok());
5079 }
5080
5081 #[test]
5082 fn test_validate_request_with_cookie_parameters() {
5083 let spec_json = json!({
5085 "openapi": "3.0.0",
5086 "info": {"title": "Test API", "version": "1.0.0"},
5087 "paths": {
5088 "/users": {
5089 "get": {
5090 "parameters": [
5091 {
5092 "name": "sessionId",
5093 "in": "cookie",
5094 "required": true,
5095 "schema": {"type": "string"}
5096 }
5097 ],
5098 "responses": {"200": {"description": "OK"}}
5099 }
5100 }
5101 }
5102 });
5103 let spec = OpenApiSpec::from_json(spec_json).unwrap();
5104 let registry = OpenApiRouteRegistry::new(spec);
5105
5106 let mut cookie_params = Map::new();
5108 cookie_params.insert("sessionId".to_string(), json!("abc123"));
5109 let result = registry.validate_request_with_all(
5110 "/users",
5111 "GET",
5112 &Map::new(),
5113 &Map::new(),
5114 &Map::new(),
5115 &cookie_params,
5116 None,
5117 );
5118 assert!(result.is_ok());
5119 }
5120
5121 #[test]
5122 fn test_validate_request_no_errors_early_return() {
5123 let spec_json = json!({
5125 "openapi": "3.0.0",
5126 "info": {"title": "Test API", "version": "1.0.0"},
5127 "paths": {
5128 "/users": {
5129 "get": {
5130 "responses": {"200": {"description": "OK"}}
5131 }
5132 }
5133 }
5134 });
5135 let spec = OpenApiSpec::from_json(spec_json).unwrap();
5136 let registry = OpenApiRouteRegistry::new(spec);
5137
5138 let result = registry.validate_request_with_all(
5140 "/users",
5141 "GET",
5142 &Map::new(),
5143 &Map::new(),
5144 &Map::new(),
5145 &Map::new(),
5146 None,
5147 );
5148 assert!(result.is_ok());
5149 }
5150
5151 #[test]
5152 fn test_validate_request_query_parameter_different_styles() {
5153 let spec_json = json!({
5155 "openapi": "3.0.0",
5156 "info": {"title": "Test API", "version": "1.0.0"},
5157 "paths": {
5158 "/users": {
5159 "get": {
5160 "parameters": [
5161 {
5162 "name": "tags",
5163 "in": "query",
5164 "style": "pipeDelimited",
5165 "schema": {
5166 "type": "array",
5167 "items": {"type": "string"}
5168 }
5169 }
5170 ],
5171 "responses": {"200": {"description": "OK"}}
5172 }
5173 }
5174 }
5175 });
5176 let spec = OpenApiSpec::from_json(spec_json).unwrap();
5177 let registry = OpenApiRouteRegistry::new(spec);
5178
5179 let mut query_params = Map::new();
5181 query_params.insert("tags".to_string(), json!(["tag1", "tag2"]));
5182 let result = registry.validate_request_with_all(
5183 "/users",
5184 "GET",
5185 &Map::new(),
5186 &query_params,
5187 &Map::new(),
5188 &Map::new(),
5189 None,
5190 );
5191 assert!(result.is_ok() || result.is_err()); }
5194
5195 fn octet_stream_put_spec(required: bool) -> Value {
5197 json!({
5198 "openapi": "3.0.0",
5199 "info": { "title": "Files API", "version": "1.0.0" },
5200 "paths": {
5201 "/files/{name}": {
5202 "put": {
5203 "parameters": [
5204 {"name": "name", "in": "path", "required": true,
5205 "schema": {"type": "string"}}
5206 ],
5207 "requestBody": {
5208 "required": required,
5209 "content": {
5210 "application/octet-stream": {
5211 "schema": {"type": "string", "format": "binary"}
5212 }
5213 }
5214 },
5215 "responses": {"200": {"description": "ok"}}
5216 }
5217 }
5218 }
5219 })
5220 }
5221
5222 #[tokio::test]
5227 async fn non_json_request_body_is_not_reported_missing() {
5228 let registry = create_registry_from_json(octet_stream_put_spec(true)).unwrap();
5229 let mut path_params = Map::new();
5230 path_params.insert("name".to_string(), json!("test.mod"));
5231
5232 let res = registry.validate_request_with_all_ex(
5234 "/files/{name}",
5235 "PUT",
5236 &path_params,
5237 &Map::new(),
5238 &Map::new(),
5239 &Map::new(),
5240 None,
5241 true,
5242 );
5243 assert!(res.is_ok(), "non-JSON PUT body must pass, got {res:?}");
5244 }
5245
5246 #[tokio::test]
5248 async fn absent_body_against_required_still_errors() {
5249 let registry = create_registry_from_json(octet_stream_put_spec(true)).unwrap();
5250 let mut path_params = Map::new();
5251 path_params.insert("name".to_string(), json!("test.mod"));
5252
5253 let res = registry.validate_request_with_all_ex(
5254 "/files/{name}",
5255 "PUT",
5256 &path_params,
5257 &Map::new(),
5258 &Map::new(),
5259 &Map::new(),
5260 None,
5261 false,
5262 );
5263 let err = res.expect_err("absent required body must error");
5264 assert!(err.to_string().contains("Request body is required"), "unexpected error: {err}");
5265 }
5266
5267 #[tokio::test]
5271 async fn absent_body_against_optional_request_body_is_ok() {
5272 let registry = create_registry_from_json(octet_stream_put_spec(false)).unwrap();
5273 let mut path_params = Map::new();
5274 path_params.insert("name".to_string(), json!("test.mod"));
5275
5276 let res = registry.validate_request_with_all_ex(
5277 "/files/{name}",
5278 "PUT",
5279 &path_params,
5280 &Map::new(),
5281 &Map::new(),
5282 &Map::new(),
5283 None,
5284 false,
5285 );
5286 assert!(res.is_ok(), "optional body may be omitted, got {res:?}");
5287 }
5288
5289 #[tokio::test]
5291 async fn json_request_body_still_schema_validates() {
5292 let spec = json!({
5293 "openapi": "3.0.0",
5294 "info": { "title": "Users API", "version": "1.0.0" },
5295 "paths": {
5296 "/users": {
5297 "post": {
5298 "requestBody": {
5299 "required": true,
5300 "content": {
5301 "application/json": {
5302 "schema": {
5303 "type": "object",
5304 "properties": {"age": {"type": "integer"}},
5305 "required": ["age"]
5306 }
5307 }
5308 }
5309 },
5310 "responses": {"200": {"description": "ok"}}
5311 }
5312 }
5313 }
5314 });
5315 let registry = create_registry_from_json(spec).unwrap();
5316
5317 let good = json!({"age": 30});
5318 assert!(registry
5319 .validate_request_with_all_ex(
5320 "/users",
5321 "POST",
5322 &Map::new(),
5323 &Map::new(),
5324 &Map::new(),
5325 &Map::new(),
5326 Some(&good),
5327 true
5328 )
5329 .is_ok());
5330
5331 let bad = json!({"age": "thirty"});
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(&bad),
5341 true
5342 )
5343 .is_err());
5344 }
5345}