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