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 let mut routes = Vec::new();
221 tracing::debug!(
222 "Generating routes from OpenAPI spec with {} paths",
223 spec.spec.paths.paths.len()
224 );
225 let base_paths = Self::collect_base_paths(spec);
226
227 for (path, path_item) in &spec.spec.paths.paths {
228 tracing::debug!("Processing path: {}", path);
229 let mut visited = HashSet::new();
230 if let Some(item) = Self::resolve_path_item(path_item, spec, &mut visited) {
231 Self::collect_routes_for_path(&mut routes, path, &item, spec, &base_paths);
232 } else {
233 tracing::warn!(
234 "Skipping path {} because the referenced PathItem could not be resolved",
235 path
236 );
237 }
238 }
239
240 tracing::debug!("Generated {} total routes from OpenAPI spec", routes.len());
241 routes
242 }
243
244 fn collect_routes_for_path(
245 routes: &mut Vec<OpenApiRoute>,
246 path: &str,
247 item: &PathItem,
248 spec: &Arc<OpenApiSpec>,
249 base_paths: &[String],
250 ) {
251 if let Some(op) = &item.get {
252 tracing::debug!(" Adding GET route for path: {}", path);
253 Self::push_routes_for_method(routes, "GET", path, op, spec, base_paths);
254 }
255 if let Some(op) = &item.post {
256 Self::push_routes_for_method(routes, "POST", path, op, spec, base_paths);
257 }
258 if let Some(op) = &item.put {
259 Self::push_routes_for_method(routes, "PUT", path, op, spec, base_paths);
260 }
261 if let Some(op) = &item.delete {
262 Self::push_routes_for_method(routes, "DELETE", path, op, spec, base_paths);
263 }
264 if let Some(op) = &item.patch {
265 Self::push_routes_for_method(routes, "PATCH", path, op, spec, base_paths);
266 }
267 if let Some(op) = &item.head {
268 Self::push_routes_for_method(routes, "HEAD", path, op, spec, base_paths);
269 }
270 if let Some(op) = &item.options {
271 Self::push_routes_for_method(routes, "OPTIONS", path, op, spec, base_paths);
272 }
273 if let Some(op) = &item.trace {
274 Self::push_routes_for_method(routes, "TRACE", path, op, spec, base_paths);
275 }
276 }
277
278 fn push_routes_for_method(
279 routes: &mut Vec<OpenApiRoute>,
280 method: &str,
281 path: &str,
282 operation: &openapiv3::Operation,
283 spec: &Arc<OpenApiSpec>,
284 base_paths: &[String],
285 ) {
286 for base in base_paths {
287 let full_path = Self::join_base_path(base, path);
288 routes.push(OpenApiRoute::from_operation(method, full_path, operation, spec.clone()));
289 }
290 }
291
292 fn collect_base_paths(spec: &Arc<OpenApiSpec>) -> Vec<String> {
293 let mut base_paths = Vec::new();
294
295 for server in spec.servers() {
296 if let Some(base_path) = Self::extract_base_path(server.url.as_str()) {
297 if !base_paths.contains(&base_path) {
298 base_paths.push(base_path);
299 }
300 }
301 }
302
303 if base_paths.is_empty() {
304 base_paths.push(String::new());
305 }
306
307 base_paths
308 }
309
310 fn extract_base_path(raw_url: &str) -> Option<String> {
311 let trimmed = raw_url.trim();
312 if trimmed.is_empty() {
313 return None;
314 }
315
316 if trimmed.starts_with('/') {
317 return Some(Self::normalize_base_path(trimmed));
318 }
319
320 if let Ok(parsed) = Url::parse(trimmed) {
321 return Some(Self::normalize_base_path(parsed.path()));
322 }
323
324 None
325 }
326
327 fn normalize_base_path(path: &str) -> String {
328 let trimmed = path.trim();
329 if trimmed.is_empty() || trimmed == "/" {
330 String::new()
331 } else {
332 let mut normalized = trimmed.trim_end_matches('/').to_string();
333 if !normalized.starts_with('/') {
334 normalized.insert(0, '/');
335 }
336 normalized
337 }
338 }
339
340 fn join_base_path(base: &str, path: &str) -> String {
341 let trimmed_path = path.trim_start_matches('/');
342
343 if base.is_empty() {
344 if trimmed_path.is_empty() {
345 "/".to_string()
346 } else {
347 format!("/{}", trimmed_path)
348 }
349 } else if trimmed_path.is_empty() {
350 base.to_string()
351 } else {
352 format!("{}/{}", base, trimmed_path)
353 }
354 }
355
356 fn resolve_path_item(
357 value: &ReferenceOr<PathItem>,
358 spec: &Arc<OpenApiSpec>,
359 visited: &mut HashSet<String>,
360 ) -> Option<PathItem> {
361 match value {
362 ReferenceOr::Item(item) => Some(item.clone()),
363 ReferenceOr::Reference { reference } => {
364 Self::resolve_path_item_reference(reference, spec, visited)
365 }
366 }
367 }
368
369 fn resolve_path_item_reference(
370 reference: &str,
371 spec: &Arc<OpenApiSpec>,
372 visited: &mut HashSet<String>,
373 ) -> Option<PathItem> {
374 if !visited.insert(reference.to_string()) {
375 tracing::warn!("Detected recursive path item reference: {}", reference);
376 return None;
377 }
378
379 if let Some(name) = reference.strip_prefix("#/components/pathItems/") {
380 return Self::resolve_component_path_item(name, spec, visited);
381 }
382
383 if let Some(pointer) = reference.strip_prefix("#/paths/") {
384 let decoded_path = Self::decode_json_pointer(pointer);
385 if let Some(next) = spec.spec.paths.paths.get(&decoded_path) {
386 return Self::resolve_path_item(next, spec, visited);
387 }
388 tracing::warn!(
389 "Path reference {} resolved to missing path '{}'",
390 reference,
391 decoded_path
392 );
393 return None;
394 }
395
396 tracing::warn!("Unsupported path item reference: {}", reference);
397 None
398 }
399
400 fn resolve_component_path_item(
401 name: &str,
402 spec: &Arc<OpenApiSpec>,
403 visited: &mut HashSet<String>,
404 ) -> Option<PathItem> {
405 let raw = spec.raw_document.as_ref()?;
406 let components = raw.get("components")?.as_object()?;
407 let path_items = components.get("pathItems")?.as_object()?;
408 let item_value = path_items.get(name)?;
409
410 if let Some(reference) = item_value
411 .as_object()
412 .and_then(|obj| obj.get("$ref"))
413 .and_then(|value| value.as_str())
414 {
415 tracing::debug!(
416 "Resolving components.pathItems entry '{}' via reference {}",
417 name,
418 reference
419 );
420 return Self::resolve_path_item_reference(reference, spec, visited);
421 }
422
423 match serde_json::from_value(item_value.clone()) {
424 Ok(item) => Some(item),
425 Err(err) => {
426 tracing::warn!(
427 "Failed to deserialize components.pathItems entry '{}' as a PathItem: {}",
428 name,
429 err
430 );
431 None
432 }
433 }
434 }
435
436 fn decode_json_pointer(pointer: &str) -> String {
437 let segments: Vec<String> = pointer
438 .split('/')
439 .map(|segment| segment.replace("~1", "/").replace("~0", "~"))
440 .collect();
441 segments.join("/")
442 }
443
444 pub fn routes(&self) -> &[OpenApiRoute] {
446 &self.routes
447 }
448
449 pub fn spec(&self) -> &OpenApiSpec {
451 &self.spec
452 }
453
454 pub fn options(&self) -> &ValidationOptions {
456 &self.options
457 }
458
459 pub fn options_mut(&mut self) -> &mut ValidationOptions {
461 &mut self.options
462 }
463
464 pub fn build_router(&self) -> axum::Router {
466 use axum::routing::{delete, get, patch, post, put};
467
468 let mut router = axum::Router::new();
469 tracing::debug!("Building router from {} routes", self.routes.len());
470
471 for route in &self.routes {
472 tracing::debug!("Adding route: {} {}", route.method, route.path);
473 tracing::debug!(
474 "Route operation responses: {:?}",
475 route.operation.responses.responses.keys().collect::<Vec<_>>()
476 );
477
478 let route_clone = route.clone();
479 let handler = move || {
480 let route = route_clone.clone();
481 async move {
482 tracing::debug!("Handling request for route: {} {}", route.method, route.path);
483 let (status, response) = route.mock_response_with_status();
484 tracing::debug!("Generated response with status: {}", status);
485 (
486 axum::http::StatusCode::from_u16(status)
487 .unwrap_or(axum::http::StatusCode::OK),
488 axum::response::Json(response),
489 )
490 }
491 };
492
493 match route.method.as_str() {
494 "GET" => {
495 tracing::debug!("Registering GET route: {}", route.path);
496 router = router.route(&route.path, get(handler));
497 }
498 "POST" => {
499 tracing::debug!("Registering POST route: {}", route.path);
500 router = router.route(&route.path, post(handler));
501 }
502 "PUT" => {
503 tracing::debug!("Registering PUT route: {}", route.path);
504 router = router.route(&route.path, put(handler));
505 }
506 "DELETE" => {
507 tracing::debug!("Registering DELETE route: {}", route.path);
508 router = router.route(&route.path, delete(handler));
509 }
510 "PATCH" => {
511 tracing::debug!("Registering PATCH route: {}", route.path);
512 router = router.route(&route.path, patch(handler));
513 }
514 _ => tracing::warn!("Unsupported HTTP method: {}", route.method),
515 }
516 }
517
518 router
519 }
520
521 pub fn build_router_with_injectors(
530 &self,
531 latency_injector: crate::latency::LatencyInjector,
532 failure_injector: Option<crate::failure_injection::FailureInjector>,
533 ) -> axum::Router {
534 use axum::routing::{delete, get, patch, post, put};
535
536 let mut router = axum::Router::new();
537 tracing::debug!("Building router with injectors from {} routes", self.routes.len());
538
539 for route in &self.routes {
540 tracing::debug!("Adding route with injectors: {} {}", route.method, route.path);
541
542 let route_clone = route.clone();
543 let latency_injector_clone = latency_injector.clone();
544 let failure_injector_clone = failure_injector.clone();
545
546 let handler = move || {
547 let route = route_clone.clone();
548 let latency_injector = latency_injector_clone.clone();
549 let failure_injector = failure_injector_clone.clone();
550
551 async move {
552 tracing::debug!(
553 "Handling request with injectors for route: {} {}",
554 route.method,
555 route.path
556 );
557
558 let tags = route.operation.tags.clone();
560
561 if let Err(e) = latency_injector.inject_latency(&tags).await {
563 tracing::warn!("Failed to inject latency: {}", e);
564 }
565
566 if let Some(ref injector) = failure_injector {
568 if injector.should_inject_failure(&tags) {
569 return (
571 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
572 axum::response::Json(serde_json::json!({
573 "error": "Injected failure",
574 "code": 500
575 })),
576 );
577 }
578 }
579
580 let (status, response) = route.mock_response_with_status();
582 (
583 axum::http::StatusCode::from_u16(status)
584 .unwrap_or(axum::http::StatusCode::OK),
585 axum::response::Json(response),
586 )
587 }
588 };
589
590 match route.method.as_str() {
591 "GET" => router = router.route(&route.path, get(handler)),
592 "POST" => router = router.route(&route.path, post(handler)),
593 "PUT" => router = router.route(&route.path, put(handler)),
594 "DELETE" => router = router.route(&route.path, delete(handler)),
595 "PATCH" => router = router.route(&route.path, patch(handler)),
596 _ => tracing::warn!("Unsupported HTTP method: {}", route.method),
597 }
598 }
599
600 router
601 }
602
603 pub fn extract_path_parameters(&self, path: &str, method: &str) -> HashMap<String, String> {
612 for route in &self.routes {
613 if route.method != method {
614 continue;
615 }
616
617 if let Some(params) = self.match_path_to_route(path, &route.path) {
618 return params;
619 }
620 }
621 HashMap::new()
622 }
623
624 fn match_path_to_route(
626 &self,
627 request_path: &str,
628 route_pattern: &str,
629 ) -> Option<HashMap<String, String>> {
630 let mut params = HashMap::new();
631
632 let request_segments: Vec<&str> = request_path.trim_start_matches('/').split('/').collect();
634 let pattern_segments: Vec<&str> =
635 route_pattern.trim_start_matches('/').split('/').collect();
636
637 if request_segments.len() != pattern_segments.len() {
638 return None;
639 }
640
641 for (req_seg, pat_seg) in request_segments.iter().zip(pattern_segments.iter()) {
642 if pat_seg.starts_with('{') && pat_seg.ends_with('}') {
643 let param_name = &pat_seg[1..pat_seg.len() - 1];
645 params.insert(param_name.to_string(), req_seg.to_string());
646 } else if req_seg != pat_seg {
647 return None;
649 }
650 }
651
652 Some(params)
653 }
654
655 pub fn build_router_with_ai(
663 &self,
664 ai_generator: Option<std::sync::Arc<dyn AiGenerator + Send + Sync>>,
665 ) -> axum::Router {
666 use axum::routing::{delete, get, patch, post, put};
667
668 let mut router = axum::Router::new();
669 tracing::debug!("Building router with AI support from {} routes", self.routes.len());
670
671 for route in &self.routes {
672 tracing::debug!("Adding AI-enabled route: {} {}", route.method, route.path);
673
674 let route_clone = route.clone();
675 let ai_generator_clone = ai_generator.clone();
676
677 let handler = move |headers: HeaderMap, body: Option<Json<Value>>| {
679 let route = route_clone.clone();
680 let ai_generator = ai_generator_clone.clone();
681
682 async move {
683 tracing::debug!(
684 "Handling AI request for route: {} {}",
685 route.method,
686 route.path
687 );
688
689 let mut context = RequestContext::new(route.method.clone(), route.path.clone());
691
692 context.headers = headers
694 .iter()
695 .map(|(k, v)| {
696 (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string()))
697 })
698 .collect();
699
700 context.body = body.map(|Json(b)| b);
702
703 let (status, response) = if let (Some(generator), Some(_ai_config)) =
705 (ai_generator, &route.ai_config)
706 {
707 route
708 .mock_response_with_status_async(&context, Some(generator.as_ref()))
709 .await
710 } else {
711 route.mock_response_with_status()
713 };
714
715 (
716 axum::http::StatusCode::from_u16(status)
717 .unwrap_or(axum::http::StatusCode::OK),
718 axum::response::Json(response),
719 )
720 }
721 };
722
723 match route.method.as_str() {
724 "GET" => {
725 router = router.route(&route.path, get(handler));
726 }
727 "POST" => {
728 router = router.route(&route.path, post(handler));
729 }
730 "PUT" => {
731 router = router.route(&route.path, put(handler));
732 }
733 "DELETE" => {
734 router = router.route(&route.path, delete(handler));
735 }
736 "PATCH" => {
737 router = router.route(&route.path, patch(handler));
738 }
739 _ => tracing::warn!("Unsupported HTTP method for AI: {}", route.method),
740 }
741 }
742
743 router
744 }
745}