1pub mod builder;
11pub mod generation;
12pub mod registry;
13pub mod validation;
14
15pub use builder::*;
17pub use generation::*;
18pub use validation::*;
19
20use crate::ai_response::RequestContext;
22use crate::openapi::response::AiGenerator;
23use crate::openapi::{OpenApiOperation, OpenApiRoute, OpenApiSchema, OpenApiSpec};
24use crate::templating::expand_tokens as core_expand_tokens;
25use crate::{latency::LatencyInjector, overrides::Overrides, Error, Result};
26use axum::extract::{Path as AxumPath, RawQuery};
27use axum::http::HeaderMap;
28use axum::response::IntoResponse;
29use axum::routing::*;
30use axum::{Json, Router};
31use chrono::Utc;
32use once_cell::sync::Lazy;
33use openapiv3::ParameterSchemaOrContent;
34use serde_json::{json, Map, Value};
35use std::collections::{HashMap, VecDeque};
36use std::sync::{Arc, Mutex};
37use tracing;
38
39#[derive(Clone)]
41pub struct OpenApiRouteRegistry {
42 spec: Arc<OpenApiSpec>,
44 routes: Vec<OpenApiRoute>,
46 options: ValidationOptions,
48 custom_fixture_loader: Option<Arc<crate::CustomFixtureLoader>>,
50}
51
52#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, Default)]
54pub enum ValidationMode {
55 Disabled,
57 #[default]
59 Warn,
60 Enforce,
62}
63
64#[derive(Debug, Clone)]
66pub struct ValidationOptions {
67 pub request_mode: ValidationMode,
69 pub aggregate_errors: bool,
71 pub validate_responses: bool,
73 pub overrides: std::collections::HashMap<String, ValidationMode>,
75 pub admin_skip_prefixes: Vec<String>,
77 pub response_template_expand: bool,
79 pub validation_status: Option<u16>,
81}
82
83impl Default for ValidationOptions {
84 fn default() -> Self {
85 Self {
86 request_mode: ValidationMode::Enforce,
87 aggregate_errors: true,
88 validate_responses: false,
89 overrides: std::collections::HashMap::new(),
90 admin_skip_prefixes: Vec::new(),
91 response_template_expand: false,
92 validation_status: None,
93 }
94 }
95}
96
97impl OpenApiRouteRegistry {
98 pub fn new(spec: OpenApiSpec) -> Self {
100 Self::new_with_env(spec)
101 }
102
103 pub fn new_with_env(spec: OpenApiSpec) -> Self {
112 Self::new_with_env_and_persona(spec, None)
113 }
114
115 pub fn new_with_env_and_persona(
117 spec: OpenApiSpec,
118 persona: Option<Arc<crate::intelligent_behavior::config::Persona>>,
119 ) -> Self {
120 tracing::debug!("Creating OpenAPI route registry");
121 let spec = Arc::new(spec);
122 let routes = Self::generate_routes_with_persona(&spec, persona);
123 let options = ValidationOptions {
124 request_mode: match std::env::var("MOCKFORGE_REQUEST_VALIDATION")
125 .unwrap_or_else(|_| "enforce".into())
126 .to_ascii_lowercase()
127 .as_str()
128 {
129 "off" | "disable" | "disabled" => ValidationMode::Disabled,
130 "warn" | "warning" => ValidationMode::Warn,
131 _ => ValidationMode::Enforce,
132 },
133 aggregate_errors: std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
134 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
135 .unwrap_or(true),
136 validate_responses: std::env::var("MOCKFORGE_RESPONSE_VALIDATION")
137 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
138 .unwrap_or(false),
139 overrides: std::collections::HashMap::new(),
140 admin_skip_prefixes: Vec::new(),
141 response_template_expand: std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
142 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
143 .unwrap_or(false),
144 validation_status: std::env::var("MOCKFORGE_VALIDATION_STATUS")
145 .ok()
146 .and_then(|s| s.parse::<u16>().ok()),
147 };
148 Self {
149 spec,
150 routes,
151 options,
152 custom_fixture_loader: None,
153 }
154 }
155
156 pub fn new_with_options(spec: OpenApiSpec, options: ValidationOptions) -> Self {
158 Self::new_with_options_and_persona(spec, options, None)
159 }
160
161 pub fn new_with_options_and_persona(
163 spec: OpenApiSpec,
164 options: ValidationOptions,
165 persona: Option<Arc<crate::intelligent_behavior::config::Persona>>,
166 ) -> Self {
167 tracing::debug!("Creating OpenAPI route registry with custom options");
168 let spec = Arc::new(spec);
169 let routes = Self::generate_routes_with_persona(&spec, persona);
170 Self {
171 spec,
172 routes,
173 options,
174 custom_fixture_loader: None,
175 }
176 }
177
178 pub fn with_custom_fixture_loader(mut self, loader: Arc<crate::CustomFixtureLoader>) -> Self {
180 self.custom_fixture_loader = Some(loader);
181 self
182 }
183
184 pub fn clone_for_validation(&self) -> Self {
189 OpenApiRouteRegistry {
190 spec: self.spec.clone(),
191 routes: self.routes.clone(),
192 options: self.options.clone(),
193 custom_fixture_loader: self.custom_fixture_loader.clone(),
194 }
195 }
196
197 fn generate_routes(spec: &Arc<OpenApiSpec>) -> Vec<OpenApiRoute> {
199 Self::generate_routes_with_persona(spec, None)
200 }
201
202 fn generate_routes_with_persona(
204 spec: &Arc<OpenApiSpec>,
205 persona: Option<Arc<crate::intelligent_behavior::config::Persona>>,
206 ) -> Vec<OpenApiRoute> {
207 let mut routes = Vec::new();
208
209 let all_paths_ops = spec.all_paths_and_operations();
210 tracing::debug!("Generating routes from OpenAPI spec with {} paths", all_paths_ops.len());
211
212 for (path, operations) in all_paths_ops {
213 tracing::debug!("Processing path: {}", path);
214 for (method, operation) in operations {
215 routes.push(OpenApiRoute::from_operation_with_persona(
216 &method,
217 path.clone(),
218 &operation,
219 spec.clone(),
220 persona.clone(),
221 ));
222 }
223 }
224
225 tracing::debug!("Generated {} total routes from OpenAPI spec", routes.len());
226 routes
227 }
228
229 pub fn routes(&self) -> &[OpenApiRoute] {
231 &self.routes
232 }
233
234 pub fn spec(&self) -> &OpenApiSpec {
236 &self.spec
237 }
238
239 pub fn build_router(self) -> Router {
241 let mut router = Router::new();
242 tracing::debug!("Building router from {} routes", self.routes.len());
243
244 let custom_loader = self.custom_fixture_loader.clone();
246 for route in &self.routes {
247 tracing::debug!("Adding route: {} {}", route.method, route.path);
248 let axum_path = route.axum_path();
249 let operation = route.operation.clone();
250 let method = route.method.clone();
251 let path_template = route.path.clone();
252 let validator = self.clone_for_validation();
253 let route_clone = route.clone();
254 let custom_loader_clone = custom_loader.clone();
255
256 let handler = move |AxumPath(path_params): AxumPath<
258 std::collections::HashMap<String, String>,
259 >,
260 RawQuery(raw_query): RawQuery,
261 headers: HeaderMap,
262 body: axum::body::Bytes| async move {
263 tracing::debug!("Handling OpenAPI request: {} {}", method, path_template);
264
265 if let Some(ref loader) = custom_loader_clone {
267 use crate::RequestFingerprint;
268 use axum::http::{Method, Uri};
269
270 let mut request_path = path_template.clone();
272 for (key, value) in &path_params {
273 request_path = request_path.replace(&format!("{{{}}}", key), value);
274 }
275
276 let query_string =
278 raw_query.as_ref().map(|q| q.to_string()).unwrap_or_default();
279
280 let uri_str = if query_string.is_empty() {
284 request_path.clone()
285 } else {
286 format!("{}?{}", request_path, query_string)
287 };
288
289 if let Ok(uri) = uri_str.parse::<Uri>() {
290 let http_method =
291 Method::from_bytes(method.as_bytes()).unwrap_or(Method::GET);
292 let body_slice = if body.is_empty() {
293 None
294 } else {
295 Some(body.as_ref())
296 };
297 let fingerprint =
298 RequestFingerprint::new(http_method, &uri, &headers, body_slice);
299
300 if let Some(custom_fixture) = loader.load_fixture(&fingerprint) {
301 tracing::debug!(
302 "Using custom fixture for {} {}",
303 method,
304 path_template
305 );
306
307 if custom_fixture.delay_ms > 0 {
309 tokio::time::sleep(tokio::time::Duration::from_millis(
310 custom_fixture.delay_ms,
311 ))
312 .await;
313 }
314
315 let response_body = if custom_fixture.response.is_string() {
317 custom_fixture.response.as_str().unwrap().to_string()
318 } else {
319 serde_json::to_string(&custom_fixture.response)
320 .unwrap_or_else(|_| "{}".to_string())
321 };
322
323 let json_value: serde_json::Value =
325 serde_json::from_str(&response_body)
326 .unwrap_or_else(|_| serde_json::json!({}));
327
328 let status = axum::http::StatusCode::from_u16(custom_fixture.status)
330 .unwrap_or(axum::http::StatusCode::OK);
331
332 let mut response =
333 (status, axum::response::Json(json_value)).into_response();
334
335 let response_headers = response.headers_mut();
337 for (key, value) in &custom_fixture.headers {
338 if let (Ok(header_name), Ok(header_value)) = (
339 axum::http::HeaderName::from_bytes(key.as_bytes()),
340 axum::http::HeaderValue::from_str(value),
341 ) {
342 response_headers.insert(header_name, header_value);
343 }
344 }
345
346 if !custom_fixture.headers.contains_key("content-type") {
348 response_headers.insert(
349 axum::http::header::CONTENT_TYPE,
350 axum::http::HeaderValue::from_static("application/json"),
351 );
352 }
353
354 return response;
355 }
356 }
357 }
358
359 let scenario = headers
362 .get("X-Mockforge-Scenario")
363 .and_then(|v| v.to_str().ok())
364 .map(|s| s.to_string())
365 .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
366
367 let (selected_status, mock_response) =
369 route_clone.mock_response_with_status_and_scenario(scenario.as_deref());
370 let mut path_map = serde_json::Map::new();
373 for (k, v) in path_params {
374 path_map.insert(k, Value::String(v));
375 }
376
377 let mut query_map = Map::new();
379 if let Some(q) = raw_query {
380 for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
381 query_map.insert(k.to_string(), Value::String(v.to_string()));
382 }
383 }
384
385 let mut header_map = Map::new();
387 for p_ref in &operation.parameters {
388 if let Some(openapiv3::Parameter::Header { parameter_data, .. }) =
389 p_ref.as_item()
390 {
391 let name_lc = parameter_data.name.to_ascii_lowercase();
392 if let Ok(hn) = axum::http::HeaderName::from_bytes(name_lc.as_bytes()) {
393 if let Some(val) = headers.get(hn) {
394 if let Ok(s) = val.to_str() {
395 header_map.insert(
396 parameter_data.name.clone(),
397 Value::String(s.to_string()),
398 );
399 }
400 }
401 }
402 }
403 }
404
405 let mut cookie_map = Map::new();
407 if let Some(val) = headers.get(axum::http::header::COOKIE) {
408 if let Ok(s) = val.to_str() {
409 for part in s.split(';') {
410 let part = part.trim();
411 if let Some((k, v)) = part.split_once('=') {
412 cookie_map.insert(k.to_string(), Value::String(v.to_string()));
413 }
414 }
415 }
416 }
417
418 let is_multipart = headers
420 .get(axum::http::header::CONTENT_TYPE)
421 .and_then(|v| v.to_str().ok())
422 .map(|ct| ct.starts_with("multipart/form-data"))
423 .unwrap_or(false);
424
425 let mut multipart_fields = std::collections::HashMap::new();
427 let mut multipart_files = std::collections::HashMap::new();
428 let mut body_json: Option<Value> = None;
429
430 if is_multipart {
431 match extract_multipart_from_bytes(&body, &headers).await {
433 Ok((fields, files)) => {
434 multipart_fields = fields;
435 multipart_files = files;
436 let mut body_obj = serde_json::Map::new();
438 for (k, v) in &multipart_fields {
439 body_obj.insert(k.clone(), v.clone());
440 }
441 if !body_obj.is_empty() {
442 body_json = Some(Value::Object(body_obj));
443 }
444 }
445 Err(e) => {
446 tracing::warn!("Failed to parse multipart data: {}", e);
447 }
448 }
449 } else {
450 body_json = if !body.is_empty() {
452 serde_json::from_slice(&body).ok()
453 } else {
454 None
455 };
456 }
457
458 if let Err(e) = validator.validate_request_with_all(
459 &path_template,
460 &method,
461 &path_map,
462 &query_map,
463 &header_map,
464 &cookie_map,
465 body_json.as_ref(),
466 ) {
467 let status_code = validator.options.validation_status.unwrap_or_else(|| {
469 std::env::var("MOCKFORGE_VALIDATION_STATUS")
470 .ok()
471 .and_then(|s| s.parse::<u16>().ok())
472 .unwrap_or(400)
473 });
474
475 let payload = if status_code == 422 {
476 let empty_params = serde_json::Map::new();
480 generate_enhanced_422_response(
481 &validator,
482 &path_template,
483 &method,
484 body_json.as_ref(),
485 &empty_params, &empty_params, &empty_params, &empty_params, )
490 } else {
491 let msg = format!("{}", e);
493 let detail_val = serde_json::from_str::<serde_json::Value>(&msg)
494 .unwrap_or(serde_json::json!(msg));
495 json!({
496 "error": "request validation failed",
497 "detail": detail_val,
498 "method": method,
499 "path": path_template,
500 "timestamp": Utc::now().to_rfc3339(),
501 })
502 };
503
504 record_validation_error(&payload);
505 let status = axum::http::StatusCode::from_u16(status_code)
506 .unwrap_or(axum::http::StatusCode::BAD_REQUEST);
507
508 let body_bytes = serde_json::to_vec(&payload)
510 .unwrap_or_else(|_| br#"{"error":"Serialization failed"}"#.to_vec());
511
512 return axum::http::Response::builder()
513 .status(status)
514 .header(axum::http::header::CONTENT_TYPE, "application/json")
515 .body(axum::body::Body::from(body_bytes))
516 .expect("Response builder should create valid response with valid headers and body");
517 }
518
519 let mut final_response = mock_response.clone();
521 let env_expand = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
522 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
523 .unwrap_or(false);
524 let expand = validator.options.response_template_expand || env_expand;
525 if expand {
526 final_response = core_expand_tokens(&final_response);
527 }
528
529 if validator.options.validate_responses {
531 if let Some((status_code, _response)) = operation
533 .responses
534 .responses
535 .iter()
536 .filter_map(|(status, resp)| match status {
537 openapiv3::StatusCode::Code(code) if *code >= 200 && *code < 300 => {
538 resp.as_item().map(|r| ((*code), r))
539 }
540 openapiv3::StatusCode::Range(range)
541 if *range >= 200 && *range < 300 =>
542 {
543 resp.as_item().map(|r| (200, r))
544 }
545 _ => None,
546 })
547 .next()
548 {
549 if serde_json::from_value::<serde_json::Value>(final_response.clone())
551 .is_err()
552 {
553 tracing::warn!(
554 "Response validation failed: invalid JSON for status {}",
555 status_code
556 );
557 }
558 }
559 }
560
561 let mut response = Json(final_response).into_response();
563 *response.status_mut() = axum::http::StatusCode::from_u16(selected_status)
564 .unwrap_or(axum::http::StatusCode::OK);
565 response
566 };
567
568 router = match route.method.as_str() {
570 "GET" => router.route(&axum_path, get(handler)),
571 "POST" => router.route(&axum_path, post(handler)),
572 "PUT" => router.route(&axum_path, put(handler)),
573 "DELETE" => router.route(&axum_path, delete(handler)),
574 "PATCH" => router.route(&axum_path, patch(handler)),
575 "HEAD" => router.route(&axum_path, head(handler)),
576 "OPTIONS" => router.route(&axum_path, options(handler)),
577 _ => router, };
579 }
580
581 let spec_json = serde_json::to_value(&self.spec.spec).unwrap_or(Value::Null);
583 router = router.route("/openapi.json", get(move || async move { Json(spec_json) }));
584
585 router
586 }
587
588 pub fn build_router_with_latency(self, latency_injector: LatencyInjector) -> Router {
590 self.build_router_with_injectors(latency_injector, None)
591 }
592
593 pub fn build_router_with_injectors(
595 self,
596 latency_injector: LatencyInjector,
597 failure_injector: Option<crate::FailureInjector>,
598 ) -> Router {
599 self.build_router_with_injectors_and_overrides(
600 latency_injector,
601 failure_injector,
602 None,
603 false,
604 )
605 }
606
607 pub fn build_router_with_injectors_and_overrides(
609 self,
610 latency_injector: LatencyInjector,
611 failure_injector: Option<crate::FailureInjector>,
612 overrides: Option<Overrides>,
613 overrides_enabled: bool,
614 ) -> Router {
615 let mut router = Router::new();
616
617 for route in &self.routes {
619 let axum_path = route.axum_path();
620 let operation = route.operation.clone();
621 let method = route.method.clone();
622 let method_str = method.clone();
623 let method_for_router = method_str.clone();
624 let path_template = route.path.clone();
625 let validator = self.clone_for_validation();
626 let route_clone = route.clone();
627 let injector = latency_injector.clone();
628 let failure_injector = failure_injector.clone();
629 let route_overrides = overrides.clone();
630
631 let mut operation_tags = operation.tags.clone();
633 if let Some(operation_id) = &operation.operation_id {
634 operation_tags.push(operation_id.clone());
635 }
636
637 let handler = move |AxumPath(path_params): AxumPath<
639 std::collections::HashMap<String, String>,
640 >,
641 RawQuery(raw_query): RawQuery,
642 headers: HeaderMap,
643 body: axum::body::Bytes| async move {
644 if let Some(ref failure_injector) = failure_injector {
646 if let Some((status_code, error_message)) =
647 failure_injector.process_request(&operation_tags)
648 {
649 return (
650 axum::http::StatusCode::from_u16(status_code)
651 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
652 axum::Json(serde_json::json!({
653 "error": error_message,
654 "injected_failure": true
655 })),
656 );
657 }
658 }
659
660 if let Err(e) = injector.inject_latency(&operation_tags).await {
662 tracing::warn!("Failed to inject latency: {}", e);
663 }
664
665 let scenario = headers
668 .get("X-Mockforge-Scenario")
669 .and_then(|v| v.to_str().ok())
670 .map(|s| s.to_string())
671 .or_else(|| std::env::var("MOCKFORGE_HTTP_SCENARIO").ok());
672
673 let mut path_map = Map::new();
676 for (k, v) in path_params {
677 path_map.insert(k, Value::String(v));
678 }
679
680 let mut query_map = Map::new();
682 if let Some(q) = raw_query {
683 for (k, v) in url::form_urlencoded::parse(q.as_bytes()) {
684 query_map.insert(k.to_string(), Value::String(v.to_string()));
685 }
686 }
687
688 let mut header_map = Map::new();
690 for p_ref in &operation.parameters {
691 if let Some(openapiv3::Parameter::Header { parameter_data, .. }) =
692 p_ref.as_item()
693 {
694 let name_lc = parameter_data.name.to_ascii_lowercase();
695 if let Ok(hn) = axum::http::HeaderName::from_bytes(name_lc.as_bytes()) {
696 if let Some(val) = headers.get(hn) {
697 if let Ok(s) = val.to_str() {
698 header_map.insert(
699 parameter_data.name.clone(),
700 Value::String(s.to_string()),
701 );
702 }
703 }
704 }
705 }
706 }
707
708 let mut cookie_map = Map::new();
710 if let Some(val) = headers.get(axum::http::header::COOKIE) {
711 if let Ok(s) = val.to_str() {
712 for part in s.split(';') {
713 let part = part.trim();
714 if let Some((k, v)) = part.split_once('=') {
715 cookie_map.insert(k.to_string(), Value::String(v.to_string()));
716 }
717 }
718 }
719 }
720
721 let is_multipart = headers
723 .get(axum::http::header::CONTENT_TYPE)
724 .and_then(|v| v.to_str().ok())
725 .map(|ct| ct.starts_with("multipart/form-data"))
726 .unwrap_or(false);
727
728 let mut multipart_fields = std::collections::HashMap::new();
730 let mut multipart_files = std::collections::HashMap::new();
731 let mut body_json: Option<Value> = None;
732
733 if is_multipart {
734 match extract_multipart_from_bytes(&body, &headers).await {
736 Ok((fields, files)) => {
737 multipart_fields = fields;
738 multipart_files = files;
739 let mut body_obj = serde_json::Map::new();
741 for (k, v) in &multipart_fields {
742 body_obj.insert(k.clone(), v.clone());
743 }
744 if !body_obj.is_empty() {
745 body_json = Some(Value::Object(body_obj));
746 }
747 }
748 Err(e) => {
749 tracing::warn!("Failed to parse multipart data: {}", e);
750 }
751 }
752 } else {
753 body_json = if !body.is_empty() {
755 serde_json::from_slice(&body).ok()
756 } else {
757 None
758 };
759 }
760
761 if let Err(e) = validator.validate_request_with_all(
762 &path_template,
763 &method_str,
764 &path_map,
765 &query_map,
766 &header_map,
767 &cookie_map,
768 body_json.as_ref(),
769 ) {
770 let msg = format!("{}", e);
771 let detail_val = serde_json::from_str::<serde_json::Value>(&msg)
772 .unwrap_or(serde_json::json!(msg));
773 let payload = serde_json::json!({
774 "error": "request validation failed",
775 "detail": detail_val,
776 "method": method_str,
777 "path": path_template,
778 "timestamp": Utc::now().to_rfc3339(),
779 });
780 record_validation_error(&payload);
781 let status_code = validator.options.validation_status.unwrap_or_else(|| {
783 std::env::var("MOCKFORGE_VALIDATION_STATUS")
784 .ok()
785 .and_then(|s| s.parse::<u16>().ok())
786 .unwrap_or(400)
787 });
788 return (
789 axum::http::StatusCode::from_u16(status_code)
790 .unwrap_or(axum::http::StatusCode::BAD_REQUEST),
791 Json(payload),
792 );
793 }
794
795 let (selected_status, mock_response) =
797 route_clone.mock_response_with_status_and_scenario(scenario.as_deref());
798
799 let mut response = mock_response.clone();
801 let env_expand = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
802 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
803 .unwrap_or(false);
804 let expand = validator.options.response_template_expand || env_expand;
805 if expand {
806 response = core_expand_tokens(&response);
807 }
808
809 if let Some(ref overrides) = route_overrides {
811 if overrides_enabled {
812 let operation_tags =
814 operation.operation_id.clone().map(|id| vec![id]).unwrap_or_default();
815 overrides.apply(
816 &operation.operation_id.unwrap_or_default(),
817 &operation_tags,
818 &path_template,
819 &mut response,
820 );
821 }
822 }
823
824 (
826 axum::http::StatusCode::from_u16(selected_status)
827 .unwrap_or(axum::http::StatusCode::OK),
828 Json(response),
829 )
830 };
831
832 router = match method_for_router.as_str() {
834 "GET" => router.route(&axum_path, get(handler)),
835 "POST" => router.route(&axum_path, post(handler)),
836 "PUT" => router.route(&axum_path, put(handler)),
837 "PATCH" => router.route(&axum_path, patch(handler)),
838 "DELETE" => router.route(&axum_path, delete(handler)),
839 "HEAD" => router.route(&axum_path, head(handler)),
840 "OPTIONS" => router.route(&axum_path, options(handler)),
841 _ => router.route(&axum_path, get(handler)), };
843 }
844
845 let spec_json = serde_json::to_value(&self.spec.spec).unwrap_or(Value::Null);
847 router = router.route("/openapi.json", get(move || async move { Json(spec_json) }));
848
849 router
850 }
851
852 pub fn get_route(&self, path: &str, method: &str) -> Option<&OpenApiRoute> {
854 self.routes.iter().find(|route| route.path == path && route.method == method)
855 }
856
857 pub fn get_routes_for_path(&self, path: &str) -> Vec<&OpenApiRoute> {
859 self.routes.iter().filter(|route| route.path == path).collect()
860 }
861
862 pub fn validate_request(&self, path: &str, method: &str, body: Option<&Value>) -> Result<()> {
864 self.validate_request_with(path, method, &Map::new(), &Map::new(), body)
865 }
866
867 pub fn validate_request_with(
869 &self,
870 path: &str,
871 method: &str,
872 path_params: &Map<String, Value>,
873 query_params: &Map<String, Value>,
874 body: Option<&Value>,
875 ) -> Result<()> {
876 self.validate_request_with_all(
877 path,
878 method,
879 path_params,
880 query_params,
881 &Map::new(),
882 &Map::new(),
883 body,
884 )
885 }
886
887 #[allow(clippy::too_many_arguments)]
889 pub fn validate_request_with_all(
890 &self,
891 path: &str,
892 method: &str,
893 path_params: &Map<String, Value>,
894 query_params: &Map<String, Value>,
895 header_params: &Map<String, Value>,
896 cookie_params: &Map<String, Value>,
897 body: Option<&Value>,
898 ) -> Result<()> {
899 for pref in &self.options.admin_skip_prefixes {
901 if !pref.is_empty() && path.starts_with(pref) {
902 return Ok(());
903 }
904 }
905 let env_mode = std::env::var("MOCKFORGE_REQUEST_VALIDATION").ok().map(|v| {
907 match v.to_ascii_lowercase().as_str() {
908 "off" | "disable" | "disabled" => ValidationMode::Disabled,
909 "warn" | "warning" => ValidationMode::Warn,
910 _ => ValidationMode::Enforce,
911 }
912 });
913 let aggregate = std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
914 .ok()
915 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
916 .unwrap_or(self.options.aggregate_errors);
917 let env_overrides: Option<serde_json::Map<String, serde_json::Value>> =
919 std::env::var("MOCKFORGE_VALIDATION_OVERRIDES_JSON")
920 .ok()
921 .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
922 .and_then(|v| v.as_object().cloned());
923 let mut effective_mode = env_mode.unwrap_or(self.options.request_mode.clone());
925 if let Some(map) = &env_overrides {
927 if let Some(v) = map.get(&format!("{} {}", method, path)) {
928 if let Some(m) = v.as_str() {
929 effective_mode = match m {
930 "off" => ValidationMode::Disabled,
931 "warn" => ValidationMode::Warn,
932 _ => ValidationMode::Enforce,
933 };
934 }
935 }
936 }
937 if let Some(override_mode) = self.options.overrides.get(&format!("{} {}", method, path)) {
939 effective_mode = override_mode.clone();
940 }
941 if matches!(effective_mode, ValidationMode::Disabled) {
942 return Ok(());
943 }
944 if let Some(route) = self.get_route(path, method) {
945 if matches!(effective_mode, ValidationMode::Disabled) {
946 return Ok(());
947 }
948 let mut errors: Vec<String> = Vec::new();
949 let mut details: Vec<serde_json::Value> = Vec::new();
950 if let Some(schema) = &route.operation.request_body {
952 if let Some(value) = body {
953 let request_body = match schema {
955 openapiv3::ReferenceOr::Item(rb) => Some(rb),
956 openapiv3::ReferenceOr::Reference { reference } => {
957 self.spec
959 .spec
960 .components
961 .as_ref()
962 .and_then(|components| {
963 components.request_bodies.get(
964 reference.trim_start_matches("#/components/requestBodies/"),
965 )
966 })
967 .and_then(|rb_ref| rb_ref.as_item())
968 }
969 };
970
971 if let Some(rb) = request_body {
972 if let Some(content) = rb.content.get("application/json") {
973 if let Some(schema_ref) = &content.schema {
974 match schema_ref {
976 openapiv3::ReferenceOr::Item(schema) => {
977 if let Err(validation_error) =
979 OpenApiSchema::new(schema.clone()).validate(value)
980 {
981 let error_msg = validation_error.to_string();
982 errors.push(format!(
983 "body validation failed: {}",
984 error_msg
985 ));
986 if aggregate {
987 details.push(serde_json::json!({"path":"body","code":"schema_validation","message":error_msg}));
988 }
989 }
990 }
991 openapiv3::ReferenceOr::Reference { reference } => {
992 if let Some(resolved_schema_ref) =
994 self.spec.get_schema(reference)
995 {
996 if let Err(validation_error) = OpenApiSchema::new(
997 resolved_schema_ref.schema.clone(),
998 )
999 .validate(value)
1000 {
1001 let error_msg = validation_error.to_string();
1002 errors.push(format!(
1003 "body validation failed: {}",
1004 error_msg
1005 ));
1006 if aggregate {
1007 details.push(serde_json::json!({"path":"body","code":"schema_validation","message":error_msg}));
1008 }
1009 }
1010 } else {
1011 errors.push(format!("body validation failed: could not resolve schema reference {}", reference));
1013 if aggregate {
1014 details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve schema reference"}));
1015 }
1016 }
1017 }
1018 }
1019 }
1020 }
1021 } else {
1022 errors.push("body validation failed: could not resolve request body or no application/json content".to_string());
1024 if aggregate {
1025 details.push(serde_json::json!({"path":"body","code":"reference_error","message":"Could not resolve request body reference"}));
1026 }
1027 }
1028 } else {
1029 errors.push("body: Request body is required but not provided".to_string());
1030 details.push(serde_json::json!({"path":"body","code":"required","message":"Request body is required"}));
1031 }
1032 } else if body.is_some() {
1033 tracing::debug!("Body provided for operation without requestBody; accepting");
1035 }
1036
1037 for p_ref in &route.operation.parameters {
1039 if let Some(p) = p_ref.as_item() {
1040 match p {
1041 openapiv3::Parameter::Path { parameter_data, .. } => {
1042 validate_parameter(
1043 parameter_data,
1044 path_params,
1045 "path",
1046 aggregate,
1047 &mut errors,
1048 &mut details,
1049 );
1050 }
1051 openapiv3::Parameter::Query {
1052 parameter_data,
1053 style,
1054 ..
1055 } => {
1056 let deep_value = None; let style_str = match style {
1059 openapiv3::QueryStyle::Form => Some("form"),
1060 openapiv3::QueryStyle::SpaceDelimited => Some("spaceDelimited"),
1061 openapiv3::QueryStyle::PipeDelimited => Some("pipeDelimited"),
1062 openapiv3::QueryStyle::DeepObject => Some("deepObject"),
1063 };
1064 validate_parameter_with_deep_object(
1065 parameter_data,
1066 query_params,
1067 "query",
1068 deep_value,
1069 style_str,
1070 aggregate,
1071 &mut errors,
1072 &mut details,
1073 );
1074 }
1075 openapiv3::Parameter::Header { parameter_data, .. } => {
1076 validate_parameter(
1077 parameter_data,
1078 header_params,
1079 "header",
1080 aggregate,
1081 &mut errors,
1082 &mut details,
1083 );
1084 }
1085 openapiv3::Parameter::Cookie { parameter_data, .. } => {
1086 validate_parameter(
1087 parameter_data,
1088 cookie_params,
1089 "cookie",
1090 aggregate,
1091 &mut errors,
1092 &mut details,
1093 );
1094 }
1095 }
1096 }
1097 }
1098 if errors.is_empty() {
1099 return Ok(());
1100 }
1101 match effective_mode {
1102 ValidationMode::Disabled => Ok(()),
1103 ValidationMode::Warn => {
1104 tracing::warn!("Request validation warnings: {:?}", errors);
1105 Ok(())
1106 }
1107 ValidationMode::Enforce => Err(Error::validation(
1108 serde_json::json!({"errors": errors, "details": details}).to_string(),
1109 )),
1110 }
1111 } else {
1112 Err(Error::generic(format!("Route {} {} not found in OpenAPI spec", method, path)))
1113 }
1114 }
1115
1116 pub fn paths(&self) -> Vec<String> {
1120 let mut paths: Vec<String> = self.routes.iter().map(|route| route.path.clone()).collect();
1121 paths.sort();
1122 paths.dedup();
1123 paths
1124 }
1125
1126 pub fn methods(&self) -> Vec<String> {
1128 let mut methods: Vec<String> =
1129 self.routes.iter().map(|route| route.method.clone()).collect();
1130 methods.sort();
1131 methods.dedup();
1132 methods
1133 }
1134
1135 pub fn get_operation(&self, path: &str, method: &str) -> Option<OpenApiOperation> {
1137 self.get_route(path, method).map(|route| {
1138 OpenApiOperation::from_operation(
1139 &route.method,
1140 route.path.clone(),
1141 &route.operation,
1142 &self.spec,
1143 )
1144 })
1145 }
1146
1147 pub fn extract_path_parameters(
1149 &self,
1150 path: &str,
1151 method: &str,
1152 ) -> std::collections::HashMap<String, String> {
1153 for route in &self.routes {
1154 if route.method != method {
1155 continue;
1156 }
1157
1158 if let Some(params) = self.match_path_to_route(path, &route.path) {
1159 return params;
1160 }
1161 }
1162 std::collections::HashMap::new()
1163 }
1164
1165 fn match_path_to_route(
1167 &self,
1168 request_path: &str,
1169 route_pattern: &str,
1170 ) -> Option<std::collections::HashMap<String, String>> {
1171 let mut params = std::collections::HashMap::new();
1172
1173 let request_segments: Vec<&str> = request_path.trim_start_matches('/').split('/').collect();
1175 let pattern_segments: Vec<&str> =
1176 route_pattern.trim_start_matches('/').split('/').collect();
1177
1178 if request_segments.len() != pattern_segments.len() {
1179 return None;
1180 }
1181
1182 for (req_seg, pat_seg) in request_segments.iter().zip(pattern_segments.iter()) {
1183 if pat_seg.starts_with('{') && pat_seg.ends_with('}') {
1184 let param_name = &pat_seg[1..pat_seg.len() - 1];
1186 params.insert(param_name.to_string(), req_seg.to_string());
1187 } else if req_seg != pat_seg {
1188 return None;
1190 }
1191 }
1192
1193 Some(params)
1194 }
1195
1196 pub fn convert_path_to_axum(openapi_path: &str) -> String {
1199 openapi_path.to_string()
1201 }
1202
1203 pub fn build_router_with_ai(
1205 &self,
1206 ai_generator: Option<std::sync::Arc<dyn AiGenerator + Send + Sync>>,
1207 ) -> Router {
1208 use axum::routing::{delete, get, patch, post, put};
1209
1210 let mut router = Router::new();
1211 tracing::debug!("Building router with AI support from {} routes", self.routes.len());
1212
1213 for route in &self.routes {
1214 tracing::debug!("Adding AI-enabled route: {} {}", route.method, route.path);
1215
1216 let route_clone = route.clone();
1217 let ai_generator_clone = ai_generator.clone();
1218
1219 let handler = move |headers: HeaderMap, body: Option<Json<Value>>| {
1221 let route = route_clone.clone();
1222 let ai_generator = ai_generator_clone.clone();
1223
1224 async move {
1225 tracing::debug!(
1226 "Handling AI request for route: {} {}",
1227 route.method,
1228 route.path
1229 );
1230
1231 let mut context = RequestContext::new(route.method.clone(), route.path.clone());
1233
1234 context.headers = headers
1236 .iter()
1237 .map(|(k, v)| {
1238 (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string()))
1239 })
1240 .collect();
1241
1242 context.body = body.map(|Json(b)| b);
1244
1245 let (status, response) = if let (Some(generator), Some(_ai_config)) =
1247 (ai_generator, &route.ai_config)
1248 {
1249 route
1250 .mock_response_with_status_async(&context, Some(generator.as_ref()))
1251 .await
1252 } else {
1253 route.mock_response_with_status()
1255 };
1256
1257 (
1258 axum::http::StatusCode::from_u16(status)
1259 .unwrap_or(axum::http::StatusCode::OK),
1260 axum::response::Json(response),
1261 )
1262 }
1263 };
1264
1265 match route.method.as_str() {
1266 "GET" => {
1267 router = router.route(&route.path, get(handler));
1268 }
1269 "POST" => {
1270 router = router.route(&route.path, post(handler));
1271 }
1272 "PUT" => {
1273 router = router.route(&route.path, put(handler));
1274 }
1275 "DELETE" => {
1276 router = router.route(&route.path, delete(handler));
1277 }
1278 "PATCH" => {
1279 router = router.route(&route.path, patch(handler));
1280 }
1281 _ => tracing::warn!("Unsupported HTTP method for AI: {}", route.method),
1282 }
1283 }
1284
1285 router
1286 }
1287
1288 pub fn build_router_with_mockai(
1299 &self,
1300 mockai: Option<std::sync::Arc<tokio::sync::RwLock<crate::intelligent_behavior::MockAI>>>,
1301 ) -> Router {
1302 use crate::intelligent_behavior::{Request as MockAIRequest, Response as MockAIResponse};
1303 use axum::extract::Query;
1304 use axum::routing::{delete, get, patch, post, put};
1305
1306 let mut router = Router::new();
1307 tracing::debug!("Building router with MockAI support from {} routes", self.routes.len());
1308
1309 for route in &self.routes {
1310 tracing::debug!("Adding MockAI-enabled route: {} {}", route.method, route.path);
1311
1312 let route_clone = route.clone();
1313 let mockai_clone = mockai.clone();
1314
1315 let handler = move |query: axum::extract::Query<HashMap<String, String>>,
1319 headers: HeaderMap,
1320 body: Option<Json<Value>>| {
1321 let route = route_clone.clone();
1322 let mockai = mockai_clone.clone();
1323
1324 async move {
1325 tracing::debug!(
1326 "Handling MockAI request for route: {} {}",
1327 route.method,
1328 route.path
1329 );
1330
1331 let mockai_query = query.0;
1333
1334 if let Some(mockai_arc) = mockai {
1336 let mockai_guard = mockai_arc.read().await;
1337
1338 let mut mockai_headers = HashMap::new();
1340 for (k, v) in headers.iter() {
1341 mockai_headers
1342 .insert(k.to_string(), v.to_str().unwrap_or("").to_string());
1343 }
1344
1345 let mockai_request = MockAIRequest {
1346 method: route.method.clone(),
1347 path: route.path.clone(),
1348 body: body.as_ref().map(|Json(b)| b.clone()),
1349 query_params: mockai_query,
1350 headers: mockai_headers,
1351 };
1352
1353 match mockai_guard.process_request(&mockai_request).await {
1355 Ok(mockai_response) => {
1356 tracing::debug!(
1357 "MockAI generated response with status: {}",
1358 mockai_response.status_code
1359 );
1360 return (
1361 axum::http::StatusCode::from_u16(mockai_response.status_code)
1362 .unwrap_or(axum::http::StatusCode::OK),
1363 axum::response::Json(mockai_response.body),
1364 );
1365 }
1366 Err(e) => {
1367 tracing::warn!(
1368 "MockAI processing failed for {} {}: {}, falling back to standard response",
1369 route.method,
1370 route.path,
1371 e
1372 );
1373 }
1375 }
1376 }
1377
1378 let (status, response) = route.mock_response_with_status();
1380 (
1381 axum::http::StatusCode::from_u16(status)
1382 .unwrap_or(axum::http::StatusCode::OK),
1383 axum::response::Json(response),
1384 )
1385 }
1386 };
1387
1388 match route.method.as_str() {
1389 "GET" => {
1390 router = router.route(&route.path, get(handler));
1391 }
1392 "POST" => {
1393 router = router.route(&route.path, post(handler));
1394 }
1395 "PUT" => {
1396 router = router.route(&route.path, put(handler));
1397 }
1398 "DELETE" => {
1399 router = router.route(&route.path, delete(handler));
1400 }
1401 "PATCH" => {
1402 router = router.route(&route.path, patch(handler));
1403 }
1404 _ => tracing::warn!("Unsupported HTTP method for MockAI: {}", route.method),
1405 }
1406 }
1407
1408 router
1409 }
1410}
1411
1412async fn extract_multipart_from_bytes(
1417 body: &axum::body::Bytes,
1418 headers: &HeaderMap,
1419) -> Result<(
1420 std::collections::HashMap<String, Value>,
1421 std::collections::HashMap<String, String>,
1422)> {
1423 let boundary = headers
1425 .get(axum::http::header::CONTENT_TYPE)
1426 .and_then(|v| v.to_str().ok())
1427 .and_then(|ct| {
1428 ct.split(';').find_map(|part| {
1429 let part = part.trim();
1430 if part.starts_with("boundary=") {
1431 Some(part.strip_prefix("boundary=").unwrap_or("").trim_matches('"'))
1432 } else {
1433 None
1434 }
1435 })
1436 })
1437 .ok_or_else(|| Error::generic("Missing boundary in Content-Type header"))?;
1438
1439 let mut fields = std::collections::HashMap::new();
1440 let mut files = std::collections::HashMap::new();
1441
1442 let boundary_prefix = format!("--{}", boundary).into_bytes();
1445 let boundary_line = format!("\r\n--{}\r\n", boundary).into_bytes();
1446 let end_boundary = format!("\r\n--{}--\r\n", boundary).into_bytes();
1447
1448 let mut pos = 0;
1450 let mut parts = Vec::new();
1451
1452 if body.starts_with(&boundary_prefix) {
1454 if let Some(first_crlf) = body.iter().position(|&b| b == b'\r') {
1455 pos = first_crlf + 2; }
1457 }
1458
1459 while let Some(boundary_pos) = body[pos..]
1461 .windows(boundary_line.len())
1462 .position(|window| window == boundary_line.as_slice())
1463 {
1464 let actual_pos = pos + boundary_pos;
1465 if actual_pos > pos {
1466 parts.push((pos, actual_pos));
1467 }
1468 pos = actual_pos + boundary_line.len();
1469 }
1470
1471 if let Some(end_pos) = body[pos..]
1473 .windows(end_boundary.len())
1474 .position(|window| window == end_boundary.as_slice())
1475 {
1476 let actual_end = pos + end_pos;
1477 if actual_end > pos {
1478 parts.push((pos, actual_end));
1479 }
1480 } else if pos < body.len() {
1481 parts.push((pos, body.len()));
1483 }
1484
1485 for (start, end) in parts {
1487 let part_data = &body[start..end];
1488
1489 let separator = b"\r\n\r\n";
1491 if let Some(sep_pos) =
1492 part_data.windows(separator.len()).position(|window| window == separator)
1493 {
1494 let header_bytes = &part_data[..sep_pos];
1495 let body_start = sep_pos + separator.len();
1496 let body_data = &part_data[body_start..];
1497
1498 let header_str = String::from_utf8_lossy(header_bytes);
1500 let mut field_name = None;
1501 let mut filename = None;
1502
1503 for header_line in header_str.lines() {
1504 if header_line.starts_with("Content-Disposition:") {
1505 if let Some(name_start) = header_line.find("name=\"") {
1507 let name_start = name_start + 6;
1508 if let Some(name_end) = header_line[name_start..].find('"') {
1509 field_name =
1510 Some(header_line[name_start..name_start + name_end].to_string());
1511 }
1512 }
1513
1514 if let Some(file_start) = header_line.find("filename=\"") {
1516 let file_start = file_start + 10;
1517 if let Some(file_end) = header_line[file_start..].find('"') {
1518 filename =
1519 Some(header_line[file_start..file_start + file_end].to_string());
1520 }
1521 }
1522 }
1523 }
1524
1525 if let Some(name) = field_name {
1526 if let Some(file) = filename {
1527 let temp_dir = std::env::temp_dir().join("mockforge-uploads");
1529 std::fs::create_dir_all(&temp_dir).map_err(|e| {
1530 Error::generic(format!("Failed to create temp directory: {}", e))
1531 })?;
1532
1533 let file_path = temp_dir.join(format!("{}_{}", uuid::Uuid::new_v4(), file));
1534 std::fs::write(&file_path, body_data)
1535 .map_err(|e| Error::generic(format!("Failed to write file: {}", e)))?;
1536
1537 let file_path_str = file_path.to_string_lossy().to_string();
1538 files.insert(name.clone(), file_path_str.clone());
1539 fields.insert(name, Value::String(file_path_str));
1540 } else {
1541 let body_str = body_data
1544 .strip_suffix(b"\r\n")
1545 .or_else(|| body_data.strip_suffix(b"\n"))
1546 .unwrap_or(body_data);
1547
1548 if let Ok(field_value) = String::from_utf8(body_str.to_vec()) {
1549 fields.insert(name, Value::String(field_value.trim().to_string()));
1550 } else {
1551 use base64::{engine::general_purpose, Engine as _};
1553 fields.insert(
1554 name,
1555 Value::String(general_purpose::STANDARD.encode(body_str)),
1556 );
1557 }
1558 }
1559 }
1560 }
1561 }
1562
1563 Ok((fields, files))
1564}
1565
1566static LAST_ERRORS: Lazy<Mutex<VecDeque<serde_json::Value>>> =
1567 Lazy::new(|| Mutex::new(VecDeque::with_capacity(20)));
1568
1569pub fn record_validation_error(v: &serde_json::Value) {
1571 if let Ok(mut q) = LAST_ERRORS.lock() {
1572 if q.len() >= 20 {
1573 q.pop_front();
1574 }
1575 q.push_back(v.clone());
1576 }
1577 }
1579
1580pub fn get_last_validation_error() -> Option<serde_json::Value> {
1582 LAST_ERRORS.lock().ok()?.back().cloned()
1583}
1584
1585pub fn get_validation_errors() -> Vec<serde_json::Value> {
1587 LAST_ERRORS.lock().map(|q| q.iter().cloned().collect()).unwrap_or_default()
1588}
1589
1590fn coerce_value_for_schema(value: &Value, schema: &openapiv3::Schema) -> Value {
1595 match value {
1597 Value::String(s) => {
1598 if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
1600 &schema.schema_kind
1601 {
1602 if s.contains(',') {
1603 let parts: Vec<&str> = s.split(',').map(|s| s.trim()).collect();
1605 let mut array_values = Vec::new();
1606
1607 for part in parts {
1608 if let Some(items_schema) = &array_type.items {
1610 if let Some(items_schema_obj) = items_schema.as_item() {
1611 let part_value = Value::String(part.to_string());
1612 let coerced_part =
1613 coerce_value_for_schema(&part_value, items_schema_obj);
1614 array_values.push(coerced_part);
1615 } else {
1616 array_values.push(Value::String(part.to_string()));
1618 }
1619 } else {
1620 array_values.push(Value::String(part.to_string()));
1622 }
1623 }
1624 return Value::Array(array_values);
1625 }
1626 }
1627
1628 match &schema.schema_kind {
1630 openapiv3::SchemaKind::Type(openapiv3::Type::String(_)) => {
1631 value.clone()
1633 }
1634 openapiv3::SchemaKind::Type(openapiv3::Type::Number(_)) => {
1635 if let Ok(n) = s.parse::<f64>() {
1637 if let Some(num) = serde_json::Number::from_f64(n) {
1638 return Value::Number(num);
1639 }
1640 }
1641 value.clone()
1642 }
1643 openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_)) => {
1644 if let Ok(n) = s.parse::<i64>() {
1646 if let Some(num) = serde_json::Number::from_f64(n as f64) {
1647 return Value::Number(num);
1648 }
1649 }
1650 value.clone()
1651 }
1652 openapiv3::SchemaKind::Type(openapiv3::Type::Boolean(_)) => {
1653 match s.to_lowercase().as_str() {
1655 "true" | "1" | "yes" | "on" => Value::Bool(true),
1656 "false" | "0" | "no" | "off" => Value::Bool(false),
1657 _ => value.clone(),
1658 }
1659 }
1660 _ => {
1661 value.clone()
1663 }
1664 }
1665 }
1666 _ => value.clone(),
1667 }
1668}
1669
1670fn coerce_by_style(value: &Value, schema: &openapiv3::Schema, style: Option<&str>) -> Value {
1672 match value {
1674 Value::String(s) => {
1675 if let openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) =
1677 &schema.schema_kind
1678 {
1679 let delimiter = match style {
1680 Some("spaceDelimited") => " ",
1681 Some("pipeDelimited") => "|",
1682 Some("form") | None => ",", _ => ",", };
1685
1686 if s.contains(delimiter) {
1687 let parts: Vec<&str> = s.split(delimiter).map(|s| s.trim()).collect();
1689 let mut array_values = Vec::new();
1690
1691 for part in parts {
1692 if let Some(items_schema) = &array_type.items {
1694 if let Some(items_schema_obj) = items_schema.as_item() {
1695 let part_value = Value::String(part.to_string());
1696 let coerced_part =
1697 coerce_by_style(&part_value, items_schema_obj, style);
1698 array_values.push(coerced_part);
1699 } else {
1700 array_values.push(Value::String(part.to_string()));
1702 }
1703 } else {
1704 array_values.push(Value::String(part.to_string()));
1706 }
1707 }
1708 return Value::Array(array_values);
1709 }
1710 }
1711
1712 if let Ok(n) = s.parse::<f64>() {
1714 if let Some(num) = serde_json::Number::from_f64(n) {
1715 return Value::Number(num);
1716 }
1717 }
1718 match s.to_lowercase().as_str() {
1720 "true" | "1" | "yes" | "on" => return Value::Bool(true),
1721 "false" | "0" | "no" | "off" => return Value::Bool(false),
1722 _ => {}
1723 }
1724 value.clone()
1726 }
1727 _ => value.clone(),
1728 }
1729}
1730
1731fn build_deep_object(name: &str, params: &Map<String, Value>) -> Option<Value> {
1733 let prefix = format!("{}[", name);
1734 let mut obj = Map::new();
1735 for (k, v) in params.iter() {
1736 if let Some(rest) = k.strip_prefix(&prefix) {
1737 if let Some(key) = rest.strip_suffix(']') {
1738 obj.insert(key.to_string(), v.clone());
1739 }
1740 }
1741 }
1742 if obj.is_empty() {
1743 None
1744 } else {
1745 Some(Value::Object(obj))
1746 }
1747}
1748
1749#[allow(clippy::too_many_arguments)]
1755fn generate_enhanced_422_response(
1756 validator: &OpenApiRouteRegistry,
1757 path_template: &str,
1758 method: &str,
1759 body: Option<&Value>,
1760 path_params: &serde_json::Map<String, Value>,
1761 query_params: &serde_json::Map<String, Value>,
1762 header_params: &serde_json::Map<String, Value>,
1763 cookie_params: &serde_json::Map<String, Value>,
1764) -> Value {
1765 let mut field_errors = Vec::new();
1766
1767 if let Some(route) = validator.get_route(path_template, method) {
1769 if let Some(schema) = &route.operation.request_body {
1771 if let Some(value) = body {
1772 if let Some(content) =
1773 schema.as_item().and_then(|rb| rb.content.get("application/json"))
1774 {
1775 if let Some(_schema_ref) = &content.schema {
1776 if serde_json::from_value::<serde_json::Value>(value.clone()).is_err() {
1778 field_errors.push(json!({
1779 "path": "body",
1780 "message": "invalid JSON"
1781 }));
1782 }
1783 }
1784 }
1785 } else {
1786 field_errors.push(json!({
1787 "path": "body",
1788 "expected": "object",
1789 "found": "missing",
1790 "message": "Request body is required but not provided"
1791 }));
1792 }
1793 }
1794
1795 for param_ref in &route.operation.parameters {
1797 if let Some(param) = param_ref.as_item() {
1798 match param {
1799 openapiv3::Parameter::Path { parameter_data, .. } => {
1800 validate_parameter_detailed(
1801 parameter_data,
1802 path_params,
1803 "path",
1804 "path parameter",
1805 &mut field_errors,
1806 );
1807 }
1808 openapiv3::Parameter::Query { parameter_data, .. } => {
1809 let deep_value = if Some("form") == Some("deepObject") {
1810 build_deep_object(¶meter_data.name, query_params)
1811 } else {
1812 None
1813 };
1814 validate_parameter_detailed_with_deep(
1815 parameter_data,
1816 query_params,
1817 "query",
1818 "query parameter",
1819 deep_value,
1820 &mut field_errors,
1821 );
1822 }
1823 openapiv3::Parameter::Header { parameter_data, .. } => {
1824 validate_parameter_detailed(
1825 parameter_data,
1826 header_params,
1827 "header",
1828 "header parameter",
1829 &mut field_errors,
1830 );
1831 }
1832 openapiv3::Parameter::Cookie { parameter_data, .. } => {
1833 validate_parameter_detailed(
1834 parameter_data,
1835 cookie_params,
1836 "cookie",
1837 "cookie parameter",
1838 &mut field_errors,
1839 );
1840 }
1841 }
1842 }
1843 }
1844 }
1845
1846 json!({
1848 "error": "Schema validation failed",
1849 "details": field_errors,
1850 "method": method,
1851 "path": path_template,
1852 "timestamp": Utc::now().to_rfc3339(),
1853 "validation_type": "openapi_schema"
1854 })
1855}
1856
1857fn validate_parameter(
1859 parameter_data: &openapiv3::ParameterData,
1860 params_map: &Map<String, Value>,
1861 prefix: &str,
1862 aggregate: bool,
1863 errors: &mut Vec<String>,
1864 details: &mut Vec<serde_json::Value>,
1865) {
1866 match params_map.get(¶meter_data.name) {
1867 Some(v) => {
1868 if let ParameterSchemaOrContent::Schema(s) = ¶meter_data.format {
1869 if let Some(schema) = s.as_item() {
1870 let coerced = coerce_value_for_schema(v, schema);
1871 if let Err(validation_error) =
1873 OpenApiSchema::new(schema.clone()).validate(&coerced)
1874 {
1875 let error_msg = validation_error.to_string();
1876 errors.push(format!(
1877 "{} parameter '{}' validation failed: {}",
1878 prefix, parameter_data.name, error_msg
1879 ));
1880 if aggregate {
1881 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
1882 }
1883 }
1884 }
1885 }
1886 }
1887 None => {
1888 if parameter_data.required {
1889 errors.push(format!(
1890 "missing required {} parameter '{}'",
1891 prefix, parameter_data.name
1892 ));
1893 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
1894 }
1895 }
1896 }
1897}
1898
1899#[allow(clippy::too_many_arguments)]
1901fn validate_parameter_with_deep_object(
1902 parameter_data: &openapiv3::ParameterData,
1903 params_map: &Map<String, Value>,
1904 prefix: &str,
1905 deep_value: Option<Value>,
1906 style: Option<&str>,
1907 aggregate: bool,
1908 errors: &mut Vec<String>,
1909 details: &mut Vec<serde_json::Value>,
1910) {
1911 match deep_value.as_ref().or_else(|| params_map.get(¶meter_data.name)) {
1912 Some(v) => {
1913 if let ParameterSchemaOrContent::Schema(s) = ¶meter_data.format {
1914 if let Some(schema) = s.as_item() {
1915 let coerced = coerce_by_style(v, schema, style); if let Err(validation_error) =
1918 OpenApiSchema::new(schema.clone()).validate(&coerced)
1919 {
1920 let error_msg = validation_error.to_string();
1921 errors.push(format!(
1922 "{} parameter '{}' validation failed: {}",
1923 prefix, parameter_data.name, error_msg
1924 ));
1925 if aggregate {
1926 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"schema_validation","message":error_msg}));
1927 }
1928 }
1929 }
1930 }
1931 }
1932 None => {
1933 if parameter_data.required {
1934 errors.push(format!(
1935 "missing required {} parameter '{}'",
1936 prefix, parameter_data.name
1937 ));
1938 details.push(serde_json::json!({"path":format!("{}.{}", prefix, parameter_data.name),"code":"required","message":"Missing required parameter"}));
1939 }
1940 }
1941 }
1942}
1943
1944fn validate_parameter_detailed(
1946 parameter_data: &openapiv3::ParameterData,
1947 params_map: &Map<String, Value>,
1948 location: &str,
1949 value_type: &str,
1950 field_errors: &mut Vec<Value>,
1951) {
1952 match params_map.get(¶meter_data.name) {
1953 Some(value) => {
1954 if let ParameterSchemaOrContent::Schema(schema) = ¶meter_data.format {
1955 let details: Vec<serde_json::Value> = Vec::new();
1957 let param_path = format!("{}.{}", location, parameter_data.name);
1958
1959 if let Some(schema_ref) = schema.as_item() {
1961 let coerced_value = coerce_value_for_schema(value, schema_ref);
1962 if let Err(validation_error) =
1964 OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
1965 {
1966 field_errors.push(json!({
1967 "path": param_path,
1968 "expected": "valid according to schema",
1969 "found": coerced_value,
1970 "message": validation_error.to_string()
1971 }));
1972 }
1973 }
1974
1975 for detail in details {
1976 field_errors.push(json!({
1977 "path": detail["path"],
1978 "expected": detail["expected_type"],
1979 "found": detail["value"],
1980 "message": detail["message"]
1981 }));
1982 }
1983 }
1984 }
1985 None => {
1986 if parameter_data.required {
1987 field_errors.push(json!({
1988 "path": format!("{}.{}", location, parameter_data.name),
1989 "expected": "value",
1990 "found": "missing",
1991 "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
1992 }));
1993 }
1994 }
1995 }
1996}
1997
1998fn validate_parameter_detailed_with_deep(
2000 parameter_data: &openapiv3::ParameterData,
2001 params_map: &Map<String, Value>,
2002 location: &str,
2003 value_type: &str,
2004 deep_value: Option<Value>,
2005 field_errors: &mut Vec<Value>,
2006) {
2007 match deep_value.as_ref().or_else(|| params_map.get(¶meter_data.name)) {
2008 Some(value) => {
2009 if let ParameterSchemaOrContent::Schema(schema) = ¶meter_data.format {
2010 let details: Vec<serde_json::Value> = Vec::new();
2012 let param_path = format!("{}.{}", location, parameter_data.name);
2013
2014 if let Some(schema_ref) = schema.as_item() {
2016 let coerced_value = coerce_by_style(value, schema_ref, Some("form")); if let Err(validation_error) =
2019 OpenApiSchema::new(schema_ref.clone()).validate(&coerced_value)
2020 {
2021 field_errors.push(json!({
2022 "path": param_path,
2023 "expected": "valid according to schema",
2024 "found": coerced_value,
2025 "message": validation_error.to_string()
2026 }));
2027 }
2028 }
2029
2030 for detail in details {
2031 field_errors.push(json!({
2032 "path": detail["path"],
2033 "expected": detail["expected_type"],
2034 "found": detail["value"],
2035 "message": detail["message"]
2036 }));
2037 }
2038 }
2039 }
2040 None => {
2041 if parameter_data.required {
2042 field_errors.push(json!({
2043 "path": format!("{}.{}", location, parameter_data.name),
2044 "expected": "value",
2045 "found": "missing",
2046 "message": format!("Missing required {} '{}'", value_type, parameter_data.name)
2047 }));
2048 }
2049 }
2050 }
2051}
2052
2053pub async fn create_registry_from_file<P: AsRef<std::path::Path>>(
2055 path: P,
2056) -> Result<OpenApiRouteRegistry> {
2057 let spec = OpenApiSpec::from_file(path).await?;
2058 spec.validate()?;
2059 Ok(OpenApiRouteRegistry::new(spec))
2060}
2061
2062pub fn create_registry_from_json(json: Value) -> Result<OpenApiRouteRegistry> {
2064 let spec = OpenApiSpec::from_json(json)?;
2065 spec.validate()?;
2066 Ok(OpenApiRouteRegistry::new(spec))
2067}
2068
2069#[cfg(test)]
2070mod tests {
2071 use super::*;
2072 use serde_json::json;
2073
2074 #[tokio::test]
2075 async fn test_registry_creation() {
2076 let spec_json = json!({
2077 "openapi": "3.0.0",
2078 "info": {
2079 "title": "Test API",
2080 "version": "1.0.0"
2081 },
2082 "paths": {
2083 "/users": {
2084 "get": {
2085 "summary": "Get users",
2086 "responses": {
2087 "200": {
2088 "description": "Success",
2089 "content": {
2090 "application/json": {
2091 "schema": {
2092 "type": "array",
2093 "items": {
2094 "type": "object",
2095 "properties": {
2096 "id": {"type": "integer"},
2097 "name": {"type": "string"}
2098 }
2099 }
2100 }
2101 }
2102 }
2103 }
2104 }
2105 },
2106 "post": {
2107 "summary": "Create user",
2108 "requestBody": {
2109 "content": {
2110 "application/json": {
2111 "schema": {
2112 "type": "object",
2113 "properties": {
2114 "name": {"type": "string"}
2115 },
2116 "required": ["name"]
2117 }
2118 }
2119 }
2120 },
2121 "responses": {
2122 "201": {
2123 "description": "Created",
2124 "content": {
2125 "application/json": {
2126 "schema": {
2127 "type": "object",
2128 "properties": {
2129 "id": {"type": "integer"},
2130 "name": {"type": "string"}
2131 }
2132 }
2133 }
2134 }
2135 }
2136 }
2137 }
2138 },
2139 "/users/{id}": {
2140 "get": {
2141 "summary": "Get user by ID",
2142 "parameters": [
2143 {
2144 "name": "id",
2145 "in": "path",
2146 "required": true,
2147 "schema": {"type": "integer"}
2148 }
2149 ],
2150 "responses": {
2151 "200": {
2152 "description": "Success",
2153 "content": {
2154 "application/json": {
2155 "schema": {
2156 "type": "object",
2157 "properties": {
2158 "id": {"type": "integer"},
2159 "name": {"type": "string"}
2160 }
2161 }
2162 }
2163 }
2164 }
2165 }
2166 }
2167 }
2168 }
2169 });
2170
2171 let registry = create_registry_from_json(spec_json).unwrap();
2172
2173 assert_eq!(registry.paths().len(), 2);
2175 assert!(registry.paths().contains(&"/users".to_string()));
2176 assert!(registry.paths().contains(&"/users/{id}".to_string()));
2177
2178 assert_eq!(registry.methods().len(), 2);
2179 assert!(registry.methods().contains(&"GET".to_string()));
2180 assert!(registry.methods().contains(&"POST".to_string()));
2181
2182 let get_users_route = registry.get_route("/users", "GET").unwrap();
2184 assert_eq!(get_users_route.method, "GET");
2185 assert_eq!(get_users_route.path, "/users");
2186
2187 let post_users_route = registry.get_route("/users", "POST").unwrap();
2188 assert_eq!(post_users_route.method, "POST");
2189 assert!(post_users_route.operation.request_body.is_some());
2190
2191 let user_by_id_route = registry.get_route("/users/{id}", "GET").unwrap();
2193 assert_eq!(user_by_id_route.axum_path(), "/users/{id}");
2194 }
2195
2196 #[tokio::test]
2197 async fn test_validate_request_with_params_and_formats() {
2198 let spec_json = json!({
2199 "openapi": "3.0.0",
2200 "info": { "title": "Test API", "version": "1.0.0" },
2201 "paths": {
2202 "/users/{id}": {
2203 "post": {
2204 "parameters": [
2205 { "name": "id", "in": "path", "required": true, "schema": {"type": "string"} },
2206 { "name": "q", "in": "query", "required": false, "schema": {"type": "integer"} }
2207 ],
2208 "requestBody": {
2209 "content": {
2210 "application/json": {
2211 "schema": {
2212 "type": "object",
2213 "required": ["email", "website"],
2214 "properties": {
2215 "email": {"type": "string", "format": "email"},
2216 "website": {"type": "string", "format": "uri"}
2217 }
2218 }
2219 }
2220 }
2221 },
2222 "responses": {"200": {"description": "ok"}}
2223 }
2224 }
2225 }
2226 });
2227
2228 let registry = create_registry_from_json(spec_json).unwrap();
2229 let mut path_params = serde_json::Map::new();
2230 path_params.insert("id".to_string(), json!("abc"));
2231 let mut query_params = serde_json::Map::new();
2232 query_params.insert("q".to_string(), json!(123));
2233
2234 let body = json!({"email":"a@b.co","website":"https://example.com"});
2236 assert!(registry
2237 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
2238 .is_ok());
2239
2240 let bad_email = json!({"email":"not-an-email","website":"https://example.com"});
2242 assert!(registry
2243 .validate_request_with(
2244 "/users/{id}",
2245 "POST",
2246 &path_params,
2247 &query_params,
2248 Some(&bad_email)
2249 )
2250 .is_err());
2251
2252 let empty_path_params = serde_json::Map::new();
2254 assert!(registry
2255 .validate_request_with(
2256 "/users/{id}",
2257 "POST",
2258 &empty_path_params,
2259 &query_params,
2260 Some(&body)
2261 )
2262 .is_err());
2263 }
2264
2265 #[tokio::test]
2266 async fn test_ref_resolution_for_params_and_body() {
2267 let spec_json = json!({
2268 "openapi": "3.0.0",
2269 "info": { "title": "Ref API", "version": "1.0.0" },
2270 "components": {
2271 "schemas": {
2272 "EmailWebsite": {
2273 "type": "object",
2274 "required": ["email", "website"],
2275 "properties": {
2276 "email": {"type": "string", "format": "email"},
2277 "website": {"type": "string", "format": "uri"}
2278 }
2279 }
2280 },
2281 "parameters": {
2282 "PathId": {"name": "id", "in": "path", "required": true, "schema": {"type": "string"}},
2283 "QueryQ": {"name": "q", "in": "query", "required": false, "schema": {"type": "integer"}}
2284 },
2285 "requestBodies": {
2286 "CreateUser": {
2287 "content": {
2288 "application/json": {
2289 "schema": {"$ref": "#/components/schemas/EmailWebsite"}
2290 }
2291 }
2292 }
2293 }
2294 },
2295 "paths": {
2296 "/users/{id}": {
2297 "post": {
2298 "parameters": [
2299 {"$ref": "#/components/parameters/PathId"},
2300 {"$ref": "#/components/parameters/QueryQ"}
2301 ],
2302 "requestBody": {"$ref": "#/components/requestBodies/CreateUser"},
2303 "responses": {"200": {"description": "ok"}}
2304 }
2305 }
2306 }
2307 });
2308
2309 let registry = create_registry_from_json(spec_json).unwrap();
2310 let mut path_params = serde_json::Map::new();
2311 path_params.insert("id".to_string(), json!("abc"));
2312 let mut query_params = serde_json::Map::new();
2313 query_params.insert("q".to_string(), json!(7));
2314
2315 let body = json!({"email":"user@example.com","website":"https://example.com"});
2316 assert!(registry
2317 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&body))
2318 .is_ok());
2319
2320 let bad = json!({"email":"nope","website":"https://example.com"});
2321 assert!(registry
2322 .validate_request_with("/users/{id}", "POST", &path_params, &query_params, Some(&bad))
2323 .is_err());
2324 }
2325
2326 #[tokio::test]
2327 async fn test_header_cookie_and_query_coercion() {
2328 let spec_json = json!({
2329 "openapi": "3.0.0",
2330 "info": { "title": "Params API", "version": "1.0.0" },
2331 "paths": {
2332 "/items": {
2333 "get": {
2334 "parameters": [
2335 {"name": "X-Flag", "in": "header", "required": true, "schema": {"type": "boolean"}},
2336 {"name": "session", "in": "cookie", "required": true, "schema": {"type": "string"}},
2337 {"name": "ids", "in": "query", "required": false, "schema": {"type": "array", "items": {"type": "integer"}}}
2338 ],
2339 "responses": {"200": {"description": "ok"}}
2340 }
2341 }
2342 }
2343 });
2344
2345 let registry = create_registry_from_json(spec_json).unwrap();
2346
2347 let path_params = serde_json::Map::new();
2348 let mut query_params = serde_json::Map::new();
2349 query_params.insert("ids".to_string(), json!("1,2,3"));
2351 let mut header_params = serde_json::Map::new();
2352 header_params.insert("X-Flag".to_string(), json!("true"));
2353 let mut cookie_params = serde_json::Map::new();
2354 cookie_params.insert("session".to_string(), json!("abc123"));
2355
2356 assert!(registry
2357 .validate_request_with_all(
2358 "/items",
2359 "GET",
2360 &path_params,
2361 &query_params,
2362 &header_params,
2363 &cookie_params,
2364 None
2365 )
2366 .is_ok());
2367
2368 let empty_cookie = serde_json::Map::new();
2370 assert!(registry
2371 .validate_request_with_all(
2372 "/items",
2373 "GET",
2374 &path_params,
2375 &query_params,
2376 &header_params,
2377 &empty_cookie,
2378 None
2379 )
2380 .is_err());
2381
2382 let mut bad_header = serde_json::Map::new();
2384 bad_header.insert("X-Flag".to_string(), json!("notabool"));
2385 assert!(registry
2386 .validate_request_with_all(
2387 "/items",
2388 "GET",
2389 &path_params,
2390 &query_params,
2391 &bad_header,
2392 &cookie_params,
2393 None
2394 )
2395 .is_err());
2396 }
2397
2398 #[tokio::test]
2399 async fn test_query_styles_space_pipe_deepobject() {
2400 let spec_json = json!({
2401 "openapi": "3.0.0",
2402 "info": { "title": "Query Styles API", "version": "1.0.0" },
2403 "paths": {"/search": {"get": {
2404 "parameters": [
2405 {"name":"tags","in":"query","style":"spaceDelimited","schema":{"type":"array","items":{"type":"string"}}},
2406 {"name":"ids","in":"query","style":"pipeDelimited","schema":{"type":"array","items":{"type":"integer"}}},
2407 {"name":"filter","in":"query","style":"deepObject","schema":{"type":"object","properties":{"color":{"type":"string"}},"required":["color"]}}
2408 ],
2409 "responses": {"200": {"description":"ok"}}
2410 }} }
2411 });
2412
2413 let registry = create_registry_from_json(spec_json).unwrap();
2414
2415 let path_params = Map::new();
2416 let mut query = Map::new();
2417 query.insert("tags".into(), json!("alpha beta gamma"));
2418 query.insert("ids".into(), json!("1|2|3"));
2419 query.insert("filter[color]".into(), json!("red"));
2420
2421 assert!(registry
2422 .validate_request_with("/search", "GET", &path_params, &query, None)
2423 .is_ok());
2424 }
2425
2426 #[tokio::test]
2427 async fn test_oneof_anyof_allof_validation() {
2428 let spec_json = json!({
2429 "openapi": "3.0.0",
2430 "info": { "title": "Composite API", "version": "1.0.0" },
2431 "paths": {
2432 "/composite": {
2433 "post": {
2434 "requestBody": {
2435 "content": {
2436 "application/json": {
2437 "schema": {
2438 "allOf": [
2439 {"type": "object", "required": ["base"], "properties": {"base": {"type": "string"}}}
2440 ],
2441 "oneOf": [
2442 {"type": "object", "properties": {"a": {"type": "integer"}}, "required": ["a"], "not": {"required": ["b"]}},
2443 {"type": "object", "properties": {"b": {"type": "integer"}}, "required": ["b"], "not": {"required": ["a"]}}
2444 ],
2445 "anyOf": [
2446 {"type": "object", "properties": {"flag": {"type": "boolean"}}, "required": ["flag"]},
2447 {"type": "object", "properties": {"extra": {"type": "string"}}, "required": ["extra"]}
2448 ]
2449 }
2450 }
2451 }
2452 },
2453 "responses": {"200": {"description": "ok"}}
2454 }
2455 }
2456 }
2457 });
2458
2459 let registry = create_registry_from_json(spec_json).unwrap();
2460 let ok = json!({"base": "x", "a": 1, "flag": true});
2462 assert!(registry
2463 .validate_request_with(
2464 "/composite",
2465 "POST",
2466 &serde_json::Map::new(),
2467 &serde_json::Map::new(),
2468 Some(&ok)
2469 )
2470 .is_ok());
2471
2472 let bad_oneof = json!({"base": "x", "a": 1, "b": 2, "flag": false});
2474 assert!(registry
2475 .validate_request_with(
2476 "/composite",
2477 "POST",
2478 &serde_json::Map::new(),
2479 &serde_json::Map::new(),
2480 Some(&bad_oneof)
2481 )
2482 .is_err());
2483
2484 let bad_anyof = json!({"base": "x", "a": 1});
2486 assert!(registry
2487 .validate_request_with(
2488 "/composite",
2489 "POST",
2490 &serde_json::Map::new(),
2491 &serde_json::Map::new(),
2492 Some(&bad_anyof)
2493 )
2494 .is_err());
2495
2496 let bad_allof = json!({"a": 1, "flag": true});
2498 assert!(registry
2499 .validate_request_with(
2500 "/composite",
2501 "POST",
2502 &serde_json::Map::new(),
2503 &serde_json::Map::new(),
2504 Some(&bad_allof)
2505 )
2506 .is_err());
2507 }
2508
2509 #[tokio::test]
2510 async fn test_overrides_warn_mode_allows_invalid() {
2511 let spec_json = json!({
2513 "openapi": "3.0.0",
2514 "info": { "title": "Overrides API", "version": "1.0.0" },
2515 "paths": {"/things": {"post": {
2516 "parameters": [{"name":"q","in":"query","required":true,"schema":{"type":"integer"}}],
2517 "responses": {"200": {"description":"ok"}}
2518 }}}
2519 });
2520
2521 let spec = OpenApiSpec::from_json(spec_json).unwrap();
2522 let mut overrides = std::collections::HashMap::new();
2523 overrides.insert("POST /things".to_string(), ValidationMode::Warn);
2524 let registry = OpenApiRouteRegistry::new_with_options(
2525 spec,
2526 ValidationOptions {
2527 request_mode: ValidationMode::Enforce,
2528 aggregate_errors: true,
2529 validate_responses: false,
2530 overrides,
2531 admin_skip_prefixes: vec![],
2532 response_template_expand: false,
2533 validation_status: None,
2534 },
2535 );
2536
2537 let ok = registry.validate_request_with("/things", "POST", &Map::new(), &Map::new(), None);
2539 assert!(ok.is_ok());
2540 }
2541
2542 #[tokio::test]
2543 async fn test_admin_skip_prefix_short_circuit() {
2544 let spec_json = json!({
2545 "openapi": "3.0.0",
2546 "info": { "title": "Skip API", "version": "1.0.0" },
2547 "paths": {}
2548 });
2549 let spec = OpenApiSpec::from_json(spec_json).unwrap();
2550 let registry = OpenApiRouteRegistry::new_with_options(
2551 spec,
2552 ValidationOptions {
2553 request_mode: ValidationMode::Enforce,
2554 aggregate_errors: true,
2555 validate_responses: false,
2556 overrides: std::collections::HashMap::new(),
2557 admin_skip_prefixes: vec!["/admin".into()],
2558 response_template_expand: false,
2559 validation_status: None,
2560 },
2561 );
2562
2563 let res = registry.validate_request_with_all(
2565 "/admin/__mockforge/health",
2566 "GET",
2567 &Map::new(),
2568 &Map::new(),
2569 &Map::new(),
2570 &Map::new(),
2571 None,
2572 );
2573 assert!(res.is_ok());
2574 }
2575
2576 #[test]
2577 fn test_path_conversion() {
2578 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users"), "/users");
2579 assert_eq!(OpenApiRouteRegistry::convert_path_to_axum("/users/{id}"), "/users/{id}");
2580 assert_eq!(
2581 OpenApiRouteRegistry::convert_path_to_axum("/users/{id}/posts/{postId}"),
2582 "/users/{id}/posts/{postId}"
2583 );
2584 }
2585}