mockforge_core/openapi_routes/
registry.rs

1//! OpenAPI route registry and management
2//!
3//! This module provides the main OpenApiRouteRegistry struct and related
4//! functionality for managing OpenAPI-based routes.
5
6use 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/// OpenAPI route registry that manages generated routes
20#[derive(Debug, Clone)]
21pub struct OpenApiRouteRegistry {
22    /// The OpenAPI specification
23    spec: Arc<OpenApiSpec>,
24    /// Generated routes
25    routes: Vec<OpenApiRoute>,
26    /// Validation options
27    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    /// Create a new registry from an OpenAPI spec with default options
154    pub fn new(spec: OpenApiSpec) -> Self {
155        Self::new_with_env(spec)
156    }
157
158    /// Create a new registry from an OpenAPI spec with environment-based options
159    ///
160    /// Options are read from environment variables:
161    /// - `MOCKFORGE_REQUEST_VALIDATION`: "off"/"warn"/"enforce" (default: "enforce")
162    /// - `MOCKFORGE_AGGREGATE_ERRORS`: "1"/"true" to aggregate errors (default: true)
163    /// - `MOCKFORGE_RESPONSE_VALIDATION`: "1"/"true" to validate responses (default: false)
164    /// - `MOCKFORGE_RESPONSE_TEMPLATE_EXPAND`: "1"/"true" to expand templates (default: false)
165    /// - `MOCKFORGE_VALIDATION_STATUS`: HTTP status code for validation failures (optional)
166    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    /// Create a new registry from an OpenAPI spec with explicit validation options
203    ///
204    /// # Arguments
205    /// * `spec` - OpenAPI specification
206    /// * `options` - Validation options to use
207    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    /// Generate routes from the OpenAPI specification
219    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    /// Get all generated routes
445    pub fn routes(&self) -> &[OpenApiRoute] {
446        &self.routes
447    }
448
449    /// Get the OpenAPI specification used to generate routes
450    pub fn spec(&self) -> &OpenApiSpec {
451        &self.spec
452    }
453
454    /// Get immutable reference to validation options
455    pub fn options(&self) -> &ValidationOptions {
456        &self.options
457    }
458
459    /// Get mutable reference to validation options for runtime configuration changes
460    pub fn options_mut(&mut self) -> &mut ValidationOptions {
461        &mut self.options
462    }
463
464    /// Build an Axum router from the generated routes
465    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    /// Build router with latency and failure injection support
522    ///
523    /// # Arguments
524    /// * `latency_injector` - Latency injector for simulating network delays
525    /// * `failure_injector` - Optional failure injector for simulating errors
526    ///
527    /// # Returns
528    /// Axum router with chaos engineering capabilities
529    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                    // Extract tags from the operation
559                    let tags = route.operation.tags.clone();
560
561                    // Inject latency if configured
562                    if let Err(e) = latency_injector.inject_latency(&tags).await {
563                        tracing::warn!("Failed to inject latency: {}", e);
564                    }
565
566                    // Check for failure injection
567                    if let Some(ref injector) = failure_injector {
568                        if injector.should_inject_failure(&tags) {
569                            // Return a failure response
570                            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                    // Generate normal response
581                    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    /// Extract path parameters from a request path by matching against known routes
604    ///
605    /// # Arguments
606    /// * `path` - Request path (e.g., "/users/123")
607    /// * `method` - HTTP method (e.g., "GET")
608    ///
609    /// # Returns
610    /// Map of parameter names to values extracted from the path
611    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    /// Match a request path against a route pattern and extract parameters
625    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        // Split both paths into segments
633        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                // This is a parameter
644                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                // Static segment doesn't match
648                return None;
649            }
650        }
651
652        Some(params)
653    }
654
655    /// Build router with AI generator support for dynamic response generation
656    ///
657    /// # Arguments
658    /// * `ai_generator` - Optional AI generator for creating dynamic responses based on request context
659    ///
660    /// # Returns
661    /// Axum router with AI-powered response generation
662    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            // Create async handler that extracts request data and builds context
678            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                    // Build request context
690                    let mut context = RequestContext::new(route.method.clone(), route.path.clone());
691
692                    // Extract headers
693                    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                    // Extract body if present
701                    context.body = body.map(|Json(b)| b);
702
703                    // Generate AI response if AI generator is available and route has AI config
704                    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                        // No AI support, use static response
712                        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}