1use super::validation::{ValidationMode, ValidationOptions};
7use crate::ai_response::RequestContext;
8use crate::openapi::response::AiGenerator;
9use crate::openapi::route::OpenApiRoute;
10use crate::openapi::spec::OpenApiSpec;
11use axum::extract::Json;
12use axum::http::HeaderMap;
13use openapiv3::{PathItem, ReferenceOr};
14use serde_json::Value;
15use std::collections::{HashMap, HashSet};
16use std::sync::Arc;
17use url::Url;
18
19#[derive(Debug, Clone)]
21pub struct OpenApiRouteRegistry {
22 spec: Arc<OpenApiSpec>,
24 routes: Vec<OpenApiRoute>,
26 options: ValidationOptions,
28}
29
30#[cfg(test)]
31mod tests {
32 use super::*;
33
34 fn registry_from_yaml(yaml: &str) -> OpenApiRouteRegistry {
35 let spec = OpenApiSpec::from_string(yaml, Some("yaml")).expect("parse spec");
36 OpenApiRouteRegistry::new_with_env(spec)
37 }
38
39 #[test]
40 fn generates_routes_from_components_path_items() {
41 let yaml = r#"
42openapi: 3.1.0
43info:
44 title: Test API
45 version: "1.0.0"
46paths:
47 /users:
48 $ref: '#/components/pathItems/UserCollection'
49components:
50 pathItems:
51 UserCollection:
52 get:
53 operationId: listUsers
54 responses:
55 '200':
56 description: ok
57 content:
58 application/json:
59 schema:
60 type: array
61 items:
62 type: string
63 "#;
64
65 let registry = registry_from_yaml(yaml);
66 let routes = registry.routes();
67 assert_eq!(routes.len(), 1);
68 assert_eq!(routes[0].method, "GET");
69 assert_eq!(routes[0].path, "/users");
70 }
71
72 #[test]
73 fn generates_routes_from_paths_references() {
74 let yaml = r#"
75openapi: 3.0.3
76info:
77 title: PathRef API
78 version: "1.0.0"
79paths:
80 /users:
81 get:
82 operationId: getUsers
83 responses:
84 '200':
85 description: ok
86 /all-users:
87 $ref: '#/paths/~1users'
88 "#;
89
90 let registry = registry_from_yaml(yaml);
91 let routes = registry.routes();
92 assert_eq!(routes.len(), 2);
93
94 let mut paths: Vec<(&str, &str)> = routes
95 .iter()
96 .map(|route| (route.method.as_str(), route.path.as_str()))
97 .collect();
98 paths.sort();
99
100 assert_eq!(paths, vec![("GET", "/all-users"), ("GET", "/users")]);
101 }
102
103 #[test]
104 fn generates_routes_with_server_base_path() {
105 let yaml = r#"
106openapi: 3.0.3
107info:
108 title: Base Path API
109 version: "1.0.0"
110servers:
111 - url: https://api.example.com/api/v1
112paths:
113 /users:
114 get:
115 operationId: getUsers
116 responses:
117 '200':
118 description: ok
119 "#;
120
121 let registry = registry_from_yaml(yaml);
122 let paths: Vec<String> = registry.routes().iter().map(|route| route.path.clone()).collect();
123 assert!(paths.contains(&"/api/v1/users".to_string()));
124 assert!(!paths.contains(&"/users".to_string()));
125 }
126
127 #[test]
128 fn generates_routes_with_relative_server_base_path() {
129 let yaml = r#"
130openapi: 3.0.3
131info:
132 title: Relative Base Path API
133 version: "1.0.0"
134servers:
135 - url: /api/v2
136paths:
137 /orders:
138 post:
139 operationId: createOrder
140 responses:
141 '201':
142 description: created
143 "#;
144
145 let registry = registry_from_yaml(yaml);
146 let paths: Vec<String> = registry.routes().iter().map(|route| route.path.clone()).collect();
147 assert!(paths.contains(&"/api/v2/orders".to_string()));
148 assert!(!paths.contains(&"/orders".to_string()));
149 }
150}
151
152impl OpenApiRouteRegistry {
153 pub fn new(spec: OpenApiSpec) -> Self {
155 Self::new_with_env(spec)
156 }
157
158 pub fn new_with_env(spec: OpenApiSpec) -> Self {
167 tracing::debug!("Creating OpenAPI route registry");
168 let spec = Arc::new(spec);
169 let routes = Self::generate_routes(&spec);
170 let options = ValidationOptions {
171 request_mode: match std::env::var("MOCKFORGE_REQUEST_VALIDATION")
172 .unwrap_or_else(|_| "enforce".into())
173 .to_ascii_lowercase()
174 .as_str()
175 {
176 "off" | "disable" | "disabled" => ValidationMode::Disabled,
177 "warn" | "warning" => ValidationMode::Warn,
178 _ => ValidationMode::Enforce,
179 },
180 aggregate_errors: std::env::var("MOCKFORGE_AGGREGATE_ERRORS")
181 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
182 .unwrap_or(true),
183 validate_responses: std::env::var("MOCKFORGE_RESPONSE_VALIDATION")
184 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
185 .unwrap_or(false),
186 overrides: HashMap::new(),
187 admin_skip_prefixes: Vec::new(),
188 response_template_expand: std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
189 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
190 .unwrap_or(false),
191 validation_status: std::env::var("MOCKFORGE_VALIDATION_STATUS")
192 .ok()
193 .and_then(|s| s.parse::<u16>().ok()),
194 };
195 Self {
196 spec,
197 routes,
198 options,
199 }
200 }
201
202 pub fn new_with_options(spec: OpenApiSpec, options: ValidationOptions) -> Self {
208 tracing::debug!("Creating OpenAPI route registry with custom options");
209 let spec = Arc::new(spec);
210 let routes = Self::generate_routes(&spec);
211 Self {
212 spec,
213 routes,
214 options,
215 }
216 }
217
218 fn generate_routes(spec: &Arc<OpenApiSpec>) -> Vec<OpenApiRoute> {
220 tracing::debug!(
221 "Generating routes from OpenAPI spec with {} paths",
222 spec.spec.paths.paths.len()
223 );
224 let base_paths = Self::collect_base_paths(spec);
225
226 #[cfg(feature = "rayon")]
228 {
229 use rayon::prelude::*;
230 let path_items: Vec<_> = spec.spec.paths.paths.iter().collect();
231
232 if path_items.len() > 100 {
234 tracing::debug!("Using parallel route generation for {} paths", path_items.len());
235 let routes: Vec<Vec<OpenApiRoute>> = path_items
236 .par_iter()
237 .map(|(path, path_item)| {
238 let mut routes = Vec::new();
239 let mut visited = HashSet::new();
240 if let Some(item) = Self::resolve_path_item(path_item, spec, &mut visited) {
241 Self::collect_routes_for_path(&mut routes, path, &item, spec, &base_paths);
242 } else {
243 tracing::warn!(
244 "Skipping path {} because the referenced PathItem could not be resolved",
245 path
246 );
247 }
248 routes
249 })
250 .collect();
251
252 let mut all_routes = Vec::new();
253 for route_batch in routes {
254 all_routes.extend(route_batch);
255 }
256 tracing::debug!(
257 "Generated {} total routes from OpenAPI spec (parallel)",
258 all_routes.len()
259 );
260 return all_routes;
261 }
262 }
263
264 let mut routes = Vec::new();
266 for (path, path_item) in &spec.spec.paths.paths {
267 tracing::debug!("Processing path: {}", path);
268 let mut visited = HashSet::new();
269 if let Some(item) = Self::resolve_path_item(path_item, spec, &mut visited) {
270 Self::collect_routes_for_path(&mut routes, path, &item, spec, &base_paths);
271 } else {
272 tracing::warn!(
273 "Skipping path {} because the referenced PathItem could not be resolved",
274 path
275 );
276 }
277 }
278
279 tracing::debug!("Generated {} total routes from OpenAPI spec", routes.len());
280 routes
281 }
282
283 fn collect_routes_for_path(
284 routes: &mut Vec<OpenApiRoute>,
285 path: &str,
286 item: &PathItem,
287 spec: &Arc<OpenApiSpec>,
288 base_paths: &[String],
289 ) {
290 if let Some(op) = &item.get {
291 tracing::debug!(" Adding GET route for path: {}", path);
292 Self::push_routes_for_method(routes, "GET", path, op, spec, base_paths);
293 }
294 if let Some(op) = &item.post {
295 Self::push_routes_for_method(routes, "POST", path, op, spec, base_paths);
296 }
297 if let Some(op) = &item.put {
298 Self::push_routes_for_method(routes, "PUT", path, op, spec, base_paths);
299 }
300 if let Some(op) = &item.delete {
301 Self::push_routes_for_method(routes, "DELETE", path, op, spec, base_paths);
302 }
303 if let Some(op) = &item.patch {
304 Self::push_routes_for_method(routes, "PATCH", path, op, spec, base_paths);
305 }
306 if let Some(op) = &item.head {
307 Self::push_routes_for_method(routes, "HEAD", path, op, spec, base_paths);
308 }
309 if let Some(op) = &item.options {
310 Self::push_routes_for_method(routes, "OPTIONS", path, op, spec, base_paths);
311 }
312 if let Some(op) = &item.trace {
313 Self::push_routes_for_method(routes, "TRACE", path, op, spec, base_paths);
314 }
315 }
316
317 fn push_routes_for_method(
318 routes: &mut Vec<OpenApiRoute>,
319 method: &str,
320 path: &str,
321 operation: &openapiv3::Operation,
322 spec: &Arc<OpenApiSpec>,
323 base_paths: &[String],
324 ) {
325 for base in base_paths {
326 let full_path = Self::join_base_path(base, path);
327 routes.push(OpenApiRoute::from_operation(method, full_path, operation, spec.clone()));
328 }
329 }
330
331 fn collect_base_paths(spec: &Arc<OpenApiSpec>) -> Vec<String> {
332 let mut base_paths = Vec::new();
333
334 for server in spec.servers() {
335 if let Some(base_path) = Self::extract_base_path(server.url.as_str()) {
336 if !base_paths.contains(&base_path) {
337 base_paths.push(base_path);
338 }
339 }
340 }
341
342 if base_paths.is_empty() {
343 base_paths.push(String::new());
344 }
345
346 base_paths
347 }
348
349 fn extract_base_path(raw_url: &str) -> Option<String> {
350 let trimmed = raw_url.trim();
351 if trimmed.is_empty() {
352 return None;
353 }
354
355 if trimmed.starts_with('/') {
356 return Some(Self::normalize_base_path(trimmed));
357 }
358
359 if let Ok(parsed) = Url::parse(trimmed) {
360 return Some(Self::normalize_base_path(parsed.path()));
361 }
362
363 None
364 }
365
366 fn normalize_base_path(path: &str) -> String {
367 let trimmed = path.trim();
368 if trimmed.is_empty() || trimmed == "/" {
369 String::new()
370 } else {
371 let mut normalized = trimmed.trim_end_matches('/').to_string();
372 if !normalized.starts_with('/') {
373 normalized.insert(0, '/');
374 }
375 normalized
376 }
377 }
378
379 fn join_base_path(base: &str, path: &str) -> String {
380 let trimmed_path = path.trim_start_matches('/');
381
382 if base.is_empty() {
383 if trimmed_path.is_empty() {
384 "/".to_string()
385 } else {
386 format!("/{}", trimmed_path)
387 }
388 } else if trimmed_path.is_empty() {
389 base.to_string()
390 } else {
391 format!("{}/{}", base, trimmed_path)
392 }
393 }
394
395 fn resolve_path_item(
396 value: &ReferenceOr<PathItem>,
397 spec: &Arc<OpenApiSpec>,
398 visited: &mut HashSet<String>,
399 ) -> Option<PathItem> {
400 match value {
401 ReferenceOr::Item(item) => Some(item.clone()),
402 ReferenceOr::Reference { reference } => {
403 Self::resolve_path_item_reference(reference, spec, visited)
404 }
405 }
406 }
407
408 fn resolve_path_item_reference(
409 reference: &str,
410 spec: &Arc<OpenApiSpec>,
411 visited: &mut HashSet<String>,
412 ) -> Option<PathItem> {
413 if !visited.insert(reference.to_string()) {
414 tracing::warn!("Detected recursive path item reference: {}", reference);
415 return None;
416 }
417
418 if let Some(name) = reference.strip_prefix("#/components/pathItems/") {
419 return Self::resolve_component_path_item(name, spec, visited);
420 }
421
422 if let Some(pointer) = reference.strip_prefix("#/paths/") {
423 let decoded_path = Self::decode_json_pointer(pointer);
424 if let Some(next) = spec.spec.paths.paths.get(&decoded_path) {
425 return Self::resolve_path_item(next, spec, visited);
426 }
427 tracing::warn!(
428 "Path reference {} resolved to missing path '{}'",
429 reference,
430 decoded_path
431 );
432 return None;
433 }
434
435 tracing::warn!("Unsupported path item reference: {}", reference);
436 None
437 }
438
439 fn resolve_component_path_item(
440 name: &str,
441 spec: &Arc<OpenApiSpec>,
442 visited: &mut HashSet<String>,
443 ) -> Option<PathItem> {
444 let raw = spec.raw_document.as_ref()?;
445 let components = raw.get("components")?.as_object()?;
446 let path_items = components.get("pathItems")?.as_object()?;
447 let item_value = path_items.get(name)?;
448
449 if let Some(reference) = item_value
450 .as_object()
451 .and_then(|obj| obj.get("$ref"))
452 .and_then(|value| value.as_str())
453 {
454 tracing::debug!(
455 "Resolving components.pathItems entry '{}' via reference {}",
456 name,
457 reference
458 );
459 return Self::resolve_path_item_reference(reference, spec, visited);
460 }
461
462 match serde_json::from_value(item_value.clone()) {
463 Ok(item) => Some(item),
464 Err(err) => {
465 tracing::warn!(
466 "Failed to deserialize components.pathItems entry '{}' as a PathItem: {}",
467 name,
468 err
469 );
470 None
471 }
472 }
473 }
474
475 fn decode_json_pointer(pointer: &str) -> String {
476 let segments: Vec<String> = pointer
477 .split('/')
478 .map(|segment| segment.replace("~1", "/").replace("~0", "~"))
479 .collect();
480 segments.join("/")
481 }
482
483 pub fn routes(&self) -> &[OpenApiRoute] {
485 &self.routes
486 }
487
488 pub fn spec(&self) -> &OpenApiSpec {
490 &self.spec
491 }
492
493 pub fn options(&self) -> &ValidationOptions {
495 &self.options
496 }
497
498 pub fn options_mut(&mut self) -> &mut ValidationOptions {
500 &mut self.options
501 }
502
503 pub fn build_router(&self) -> axum::Router {
505 use axum::routing::{delete, get, patch, post, put};
506
507 let mut router = axum::Router::new();
508 tracing::debug!("Building router from {} routes", self.routes.len());
509
510 for route in &self.routes {
511 tracing::debug!("Adding route: {} {}", route.method, route.path);
512 tracing::debug!(
513 "Route operation responses: {:?}",
514 route.operation.responses.responses.keys().collect::<Vec<_>>()
515 );
516
517 let route_clone = route.clone();
518 let handler = move || {
519 let route = route_clone.clone();
520 async move {
521 tracing::debug!("Handling request for route: {} {}", route.method, route.path);
522 let (status, response) = route.mock_response_with_status();
523 tracing::debug!("Generated response with status: {}", status);
524 (
525 axum::http::StatusCode::from_u16(status)
526 .unwrap_or(axum::http::StatusCode::OK),
527 axum::response::Json(response),
528 )
529 }
530 };
531
532 match route.method.as_str() {
533 "GET" => {
534 tracing::debug!("Registering GET route: {}", route.path);
535 router = router.route(&route.path, get(handler));
536 }
537 "POST" => {
538 tracing::debug!("Registering POST route: {}", route.path);
539 router = router.route(&route.path, post(handler));
540 }
541 "PUT" => {
542 tracing::debug!("Registering PUT route: {}", route.path);
543 router = router.route(&route.path, put(handler));
544 }
545 "DELETE" => {
546 tracing::debug!("Registering DELETE route: {}", route.path);
547 router = router.route(&route.path, delete(handler));
548 }
549 "PATCH" => {
550 tracing::debug!("Registering PATCH route: {}", route.path);
551 router = router.route(&route.path, patch(handler));
552 }
553 _ => tracing::warn!("Unsupported HTTP method: {}", route.method),
554 }
555 }
556
557 router
558 }
559
560 pub fn build_router_with_injectors(
569 &self,
570 latency_injector: crate::latency::LatencyInjector,
571 failure_injector: Option<crate::failure_injection::FailureInjector>,
572 ) -> axum::Router {
573 use axum::routing::{delete, get, patch, post, put};
574
575 let mut router = axum::Router::new();
576 tracing::debug!("Building router with injectors from {} routes", self.routes.len());
577
578 for route in &self.routes {
579 tracing::debug!("Adding route with injectors: {} {}", route.method, route.path);
580
581 let route_clone = route.clone();
582 let latency_injector_clone = latency_injector.clone();
583 let failure_injector_clone = failure_injector.clone();
584
585 let handler = move || {
586 let route = route_clone.clone();
587 let latency_injector = latency_injector_clone.clone();
588 let failure_injector = failure_injector_clone.clone();
589
590 async move {
591 tracing::debug!(
592 "Handling request with injectors for route: {} {}",
593 route.method,
594 route.path
595 );
596
597 let tags = route.operation.tags.clone();
599
600 if let Err(e) = latency_injector.inject_latency(&tags).await {
602 tracing::warn!("Failed to inject latency: {}", e);
603 }
604
605 if let Some(ref injector) = failure_injector {
607 if injector.should_inject_failure(&tags) {
608 return (
610 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
611 axum::response::Json(serde_json::json!({
612 "error": "Injected failure",
613 "code": 500
614 })),
615 );
616 }
617 }
618
619 let (status, response) = route.mock_response_with_status();
621 (
622 axum::http::StatusCode::from_u16(status)
623 .unwrap_or(axum::http::StatusCode::OK),
624 axum::response::Json(response),
625 )
626 }
627 };
628
629 match route.method.as_str() {
630 "GET" => router = router.route(&route.path, get(handler)),
631 "POST" => router = router.route(&route.path, post(handler)),
632 "PUT" => router = router.route(&route.path, put(handler)),
633 "DELETE" => router = router.route(&route.path, delete(handler)),
634 "PATCH" => router = router.route(&route.path, patch(handler)),
635 _ => tracing::warn!("Unsupported HTTP method: {}", route.method),
636 }
637 }
638
639 router
640 }
641
642 pub fn extract_path_parameters(&self, path: &str, method: &str) -> HashMap<String, String> {
651 for route in &self.routes {
652 if route.method != method {
653 continue;
654 }
655
656 if let Some(params) = self.match_path_to_route(path, &route.path) {
657 return params;
658 }
659 }
660 HashMap::new()
661 }
662
663 fn match_path_to_route(
665 &self,
666 request_path: &str,
667 route_pattern: &str,
668 ) -> Option<HashMap<String, String>> {
669 let mut params = HashMap::new();
670
671 let request_segments: Vec<&str> = request_path.trim_start_matches('/').split('/').collect();
673 let pattern_segments: Vec<&str> =
674 route_pattern.trim_start_matches('/').split('/').collect();
675
676 if request_segments.len() != pattern_segments.len() {
677 return None;
678 }
679
680 for (req_seg, pat_seg) in request_segments.iter().zip(pattern_segments.iter()) {
681 if pat_seg.starts_with('{') && pat_seg.ends_with('}') {
682 let param_name = &pat_seg[1..pat_seg.len() - 1];
684 params.insert(param_name.to_string(), req_seg.to_string());
685 } else if req_seg != pat_seg {
686 return None;
688 }
689 }
690
691 Some(params)
692 }
693
694 pub fn build_router_with_ai(
702 &self,
703 ai_generator: Option<std::sync::Arc<dyn AiGenerator + Send + Sync>>,
704 ) -> axum::Router {
705 use axum::routing::{delete, get, patch, post, put};
706
707 let mut router = axum::Router::new();
708 tracing::debug!("Building router with AI support from {} routes", self.routes.len());
709
710 for route in &self.routes {
711 tracing::debug!("Adding AI-enabled route: {} {}", route.method, route.path);
712
713 let route_clone = route.clone();
714 let ai_generator_clone = ai_generator.clone();
715
716 let handler = move |headers: HeaderMap, body: Option<Json<Value>>| {
718 let route = route_clone.clone();
719 let ai_generator = ai_generator_clone.clone();
720
721 async move {
722 tracing::debug!(
723 "Handling AI request for route: {} {}",
724 route.method,
725 route.path
726 );
727
728 let mut context = RequestContext::new(route.method.clone(), route.path.clone());
730
731 context.headers = headers
733 .iter()
734 .map(|(k, v)| {
735 (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string()))
736 })
737 .collect();
738
739 context.body = body.map(|Json(b)| b);
741
742 let (status, response) = if let (Some(generator), Some(_ai_config)) =
744 (ai_generator, &route.ai_config)
745 {
746 route
747 .mock_response_with_status_async(&context, Some(generator.as_ref()))
748 .await
749 } else {
750 route.mock_response_with_status()
752 };
753
754 (
755 axum::http::StatusCode::from_u16(status)
756 .unwrap_or(axum::http::StatusCode::OK),
757 axum::response::Json(response),
758 )
759 }
760 };
761
762 match route.method.as_str() {
763 "GET" => {
764 router = router.route(&route.path, get(handler));
765 }
766 "POST" => {
767 router = router.route(&route.path, post(handler));
768 }
769 "PUT" => {
770 router = router.route(&route.path, put(handler));
771 }
772 "DELETE" => {
773 router = router.route(&route.path, delete(handler));
774 }
775 "PATCH" => {
776 router = router.route(&route.path, patch(handler));
777 }
778 _ => tracing::warn!("Unsupported HTTP method for AI: {}", route.method),
779 }
780 }
781
782 router
783 }
784}