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