Skip to main content

mockforge_core/openapi/
route.rs

1//! OpenAPI route generation from specifications
2//!
3//! This module provides functionality for generating Axum routes
4//! from OpenAPI path definitions.
5
6use crate::intelligent_behavior::config::Persona;
7use crate::openapi::response_selection::{ResponseSelectionMode, ResponseSelector};
8use crate::{ai_response::AiResponseConfig, openapi::spec::OpenApiSpec, Result};
9use openapiv3::{Operation, PathItem, ReferenceOr};
10use std::collections::BTreeMap;
11use std::sync::Arc;
12
13/// Extract path parameters from an OpenAPI path template
14fn extract_path_parameters(path_template: &str) -> Vec<String> {
15    let mut params = Vec::new();
16    let mut in_param = false;
17    let mut current_param = String::new();
18
19    for ch in path_template.chars() {
20        match ch {
21            '{' => {
22                in_param = true;
23                current_param.clear();
24            }
25            '}' => {
26                if in_param {
27                    params.push(current_param.clone());
28                    in_param = false;
29                }
30            }
31            ch if in_param => {
32                current_param.push(ch);
33            }
34            _ => {}
35        }
36    }
37
38    params
39}
40
41/// OpenAPI route wrapper with additional metadata
42#[derive(Debug, Clone)]
43pub struct OpenApiRoute {
44    /// The HTTP method
45    pub method: String,
46    /// The path pattern
47    pub path: String,
48    /// The OpenAPI operation
49    pub operation: Operation,
50    /// Route-specific metadata
51    pub metadata: BTreeMap<String, String>,
52    /// Path parameters extracted from the path
53    pub parameters: Vec<String>,
54    /// Reference to the OpenAPI spec for response generation
55    pub spec: Arc<OpenApiSpec>,
56    /// AI response configuration (parsed from x-mockforge-ai extension)
57    pub ai_config: Option<AiResponseConfig>,
58    /// Response selection mode (parsed from x-mockforge-response-selection extension)
59    pub response_selection_mode: ResponseSelectionMode,
60    /// Response selector for sequential/random modes (shared across requests)
61    pub response_selector: Arc<ResponseSelector>,
62    /// Active persona for consistent data generation (optional)
63    pub persona: Option<Arc<Persona>>,
64}
65
66impl OpenApiRoute {
67    /// Create a new OpenApiRoute
68    pub fn new(method: String, path: String, operation: Operation, spec: Arc<OpenApiSpec>) -> Self {
69        Self::new_with_persona(method, path, operation, spec, None)
70    }
71
72    /// Create a new OpenApiRoute with persona
73    pub fn new_with_persona(
74        method: String,
75        path: String,
76        operation: Operation,
77        spec: Arc<OpenApiSpec>,
78        persona: Option<Arc<Persona>>,
79    ) -> Self {
80        let parameters = extract_path_parameters(&path);
81
82        // Parse AI configuration from x-mockforge-ai vendor extension
83        let ai_config = Self::parse_ai_config(&operation);
84
85        // Parse response selection mode from x-mockforge-response-selection extension
86        let response_selection_mode = Self::parse_response_selection_mode(&operation);
87        let response_selector = Arc::new(ResponseSelector::new(response_selection_mode));
88
89        Self {
90            method,
91            path,
92            operation,
93            metadata: BTreeMap::new(),
94            parameters,
95            spec,
96            ai_config,
97            response_selection_mode,
98            response_selector,
99            persona,
100        }
101    }
102
103    /// Parse AI configuration from OpenAPI operation's vendor extensions
104    fn parse_ai_config(operation: &Operation) -> Option<AiResponseConfig> {
105        // Check for x-mockforge-ai extension
106        if let Some(ai_config_value) = operation.extensions.get("x-mockforge-ai") {
107            // Try to deserialize the AI config from the extension value
108            match serde_json::from_value::<AiResponseConfig>(ai_config_value.clone()) {
109                Ok(config) => {
110                    if config.is_active() {
111                        tracing::debug!(
112                            "Parsed AI config for operation {}: mode={:?}, prompt={:?}",
113                            operation.operation_id.as_deref().unwrap_or("unknown"),
114                            config.mode,
115                            config.prompt
116                        );
117                        return Some(config);
118                    }
119                }
120                Err(e) => {
121                    tracing::warn!(
122                        "Failed to parse x-mockforge-ai extension for operation {}: {}",
123                        operation.operation_id.as_deref().unwrap_or("unknown"),
124                        e
125                    );
126                }
127            }
128        }
129        None
130    }
131
132    /// Parse response selection mode from OpenAPI operation's vendor extensions
133    fn parse_response_selection_mode(operation: &Operation) -> ResponseSelectionMode {
134        // Check for environment variable override (per-operation or global)
135        let op_id = operation.operation_id.as_deref().unwrap_or("unknown");
136
137        // Try operation-specific env var first: MOCKFORGE_RESPONSE_SELECTION_<OPERATION_ID>
138        if let Ok(op_env_var) = std::env::var(format!(
139            "MOCKFORGE_RESPONSE_SELECTION_{}",
140            op_id.to_uppercase().replace('-', "_")
141        )) {
142            if let Some(mode) = ResponseSelectionMode::from_str(&op_env_var) {
143                tracing::debug!(
144                    "Using response selection mode from env var for operation {}: {:?}",
145                    op_id,
146                    mode
147                );
148                return mode;
149            }
150        }
151
152        // Check global env var: MOCKFORGE_RESPONSE_SELECTION_MODE
153        if let Ok(global_mode_str) = std::env::var("MOCKFORGE_RESPONSE_SELECTION_MODE") {
154            if let Some(mode) = ResponseSelectionMode::from_str(&global_mode_str) {
155                tracing::debug!("Using global response selection mode from env var: {:?}", mode);
156                return mode;
157            }
158        }
159
160        // Check for x-mockforge-response-selection extension
161        if let Some(selection_value) = operation.extensions.get("x-mockforge-response-selection") {
162            // Try to parse as string first
163            if let Some(mode_str) = selection_value.as_str() {
164                if let Some(mode) = ResponseSelectionMode::from_str(mode_str) {
165                    tracing::debug!(
166                        "Parsed response selection mode for operation {}: {:?}",
167                        op_id,
168                        mode
169                    );
170                    return mode;
171                }
172            }
173            // Try to parse as object with mode field
174            if let Some(obj) = selection_value.as_object() {
175                if let Some(mode_str) = obj.get("mode").and_then(|v| v.as_str()) {
176                    if let Some(mode) = ResponseSelectionMode::from_str(mode_str) {
177                        tracing::debug!(
178                            "Parsed response selection mode for operation {}: {:?}",
179                            op_id,
180                            mode
181                        );
182                        return mode;
183                    }
184                }
185            }
186            tracing::warn!(
187                "Failed to parse x-mockforge-response-selection extension for operation {}",
188                op_id
189            );
190        }
191        // Default to First mode
192        ResponseSelectionMode::First
193    }
194
195    /// Create an OpenApiRoute from an operation
196    pub fn from_operation(
197        method: &str,
198        path: String,
199        operation: &Operation,
200        spec: Arc<OpenApiSpec>,
201    ) -> Self {
202        Self::from_operation_with_persona(method, path, operation, spec, None)
203    }
204
205    /// Create a new OpenApiRoute from an operation with optional persona
206    pub fn from_operation_with_persona(
207        method: &str,
208        path: String,
209        operation: &Operation,
210        spec: Arc<OpenApiSpec>,
211        persona: Option<Arc<Persona>>,
212    ) -> Self {
213        Self::new_with_persona(method.to_string(), path, operation.clone(), spec, persona)
214    }
215
216    /// Convert OpenAPI path to Axum-compatible path format
217    pub fn axum_path(&self) -> String {
218        // Strip query string if present (some non-standard OpenAPI specs embed query params in path)
219        // Axum v0.7+ uses {param} format, same as OpenAPI
220        let path = self.path.split('?').next().unwrap_or(&self.path);
221
222        // Handle empty function call parens: functionName() → functionName
223        if path.contains("()") {
224            let path = path.replace("()", "");
225            return path;
226        }
227
228        // Handle OData function call syntax: functionName(key='{param}',key2={param2})
229        // Also handles Microsoft Graph style: functionName(key='{param}') where quotes wrap braces
230        // Convert to: functionName/{param}/{param2}
231        // This prevents Axum from panicking on multiple params per segment or invalid chars
232        if path.contains('(') && path.contains('=') {
233            let mut result = String::with_capacity(path.len());
234            let mut chars = path.chars().peekable();
235
236            while let Some(ch) = chars.next() {
237                if ch == '(' {
238                    // Extract params from inside parentheses
239                    let mut paren_content = String::new();
240                    for c in chars.by_ref() {
241                        if c == ')' {
242                            break;
243                        }
244                        paren_content.push(c);
245                    }
246                    // Parse key='{value}' or key={value} pairs
247                    for part in paren_content.split(',') {
248                        if let Some((_key, value)) = part.split_once('=') {
249                            let param = value.trim_matches(|c| c == '\'' || c == '"');
250                            result.push('/');
251                            result.push_str(param);
252                        }
253                    }
254                } else {
255                    result.push(ch);
256                }
257            }
258            return result;
259        }
260
261        path.to_string()
262    }
263
264    /// Returns true if this route's path can be registered with Axum's router.
265    ///
266    /// Paths that contain characters Axum can't handle (e.g., unmatched braces,
267    /// multiple params per segment after conversion) are considered invalid.
268    pub fn is_valid_axum_path(&self) -> bool {
269        let path = self.axum_path();
270        // If parentheses survived conversion, the path is invalid for Axum
271        if path.contains('(') || path.contains(')') {
272            return false;
273        }
274        // Each segment may contain at most one `{param}` capture
275        for segment in path.split('/') {
276            let brace_count = segment.matches('{').count();
277            if brace_count > 1 {
278                return false;
279            }
280            // A segment with a param must be ONLY the param (e.g. `{id}` not `prefix{id}suffix`)
281            // unless it's a wildcard. Axum allows `{*rest}` as a catch-all.
282            if brace_count == 1
283                && segment
284                    != format!(
285                        "{{{}}}",
286                        segment
287                            .trim_matches(|c: char| c != '{' && c != '}')
288                            .trim_matches(|c| c == '{' || c == '}')
289                    )
290            {
291                // Segment has a param mixed with literal text — check if it's truly invalid
292                // Axum 0.8 allows `{param}` as full segment only
293                if !segment.starts_with('{') || !segment.ends_with('}') {
294                    return false;
295                }
296            }
297        }
298        true
299    }
300
301    /// Add metadata to the route
302    pub fn with_metadata(mut self, key: String, value: String) -> Self {
303        self.metadata.insert(key, value);
304        self
305    }
306
307    /// Generate a mock response with status code for this route (async version with AI support)
308    ///
309    /// This method checks if AI response generation is configured and uses it if available,
310    /// otherwise falls back to standard OpenAPI response generation.
311    ///
312    /// # Arguments
313    /// * `context` - The request context for AI prompt expansion
314    /// * `ai_generator` - Optional AI generator implementation for actual LLM calls
315    pub async fn mock_response_with_status_async(
316        &self,
317        context: &crate::ai_response::RequestContext,
318        ai_generator: Option<&dyn crate::openapi::response::AiGenerator>,
319    ) -> (u16, serde_json::Value) {
320        use crate::openapi::response::ResponseGenerator;
321
322        // Find the first available status code from the OpenAPI spec
323        let status_code = self.find_first_available_status_code();
324
325        // Check if AI response generation is configured
326        if let Some(ai_config) = &self.ai_config {
327            if ai_config.is_active() {
328                tracing::info!(
329                    "Using AI-assisted response generation for {} {}",
330                    self.method,
331                    self.path
332                );
333
334                match ResponseGenerator::generate_ai_response(ai_config, context, ai_generator)
335                    .await
336                {
337                    Ok(response_body) => {
338                        tracing::debug!(
339                            "AI response generated successfully for {} {}: {:?}",
340                            self.method,
341                            self.path,
342                            response_body
343                        );
344                        return (status_code, response_body);
345                    }
346                    Err(e) => {
347                        tracing::warn!(
348                            "AI response generation failed for {} {}: {}, falling back to standard generation",
349                            self.method,
350                            self.path,
351                            e
352                        );
353                        // Continue to standard generation on error
354                    }
355                }
356            }
357        }
358
359        // Standard OpenAPI-based response generation
360        let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
361            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
362            .unwrap_or(false);
363
364        // Use response selection mode for multiple examples
365        let mode = Some(self.response_selection_mode);
366        let selector = Some(self.response_selector.as_ref());
367
368        // Get persona reference for response generation
369        let persona_ref = self.persona.as_deref();
370
371        match ResponseGenerator::generate_response_with_expansion_and_mode_and_persona(
372            &self.spec,
373            &self.operation,
374            status_code,
375            Some("application/json"),
376            expand_tokens,
377            mode,
378            selector,
379            persona_ref,
380        ) {
381            Ok(response_body) => {
382                tracing::debug!(
383                    "ResponseGenerator succeeded for {} {} with status {}: {:?}",
384                    self.method,
385                    self.path,
386                    status_code,
387                    response_body
388                );
389                (status_code, response_body)
390            }
391            Err(e) => {
392                tracing::debug!(
393                    "ResponseGenerator failed for {} {}: {}, using fallback",
394                    self.method,
395                    self.path,
396                    e
397                );
398                // Fallback to simple mock response if schema-based generation fails
399                let response_body = serde_json::json!({
400                    "message": format!("Mock response for {} {}", self.method, self.path),
401                    "operation_id": self.operation.operation_id,
402                    "status": status_code
403                });
404                (status_code, response_body)
405            }
406        }
407    }
408
409    /// Generate a mock response with status code for this route (synchronous version)
410    ///
411    /// Note: This method does not support AI-assisted response generation.
412    /// Use `mock_response_with_status_async` for AI features.
413    pub fn mock_response_with_status(&self) -> (u16, serde_json::Value) {
414        self.mock_response_with_status_and_scenario(None)
415    }
416
417    /// Generate a mock response with status code and scenario selection
418    ///
419    /// # Arguments
420    /// * `scenario` - Optional scenario name to select from the OpenAPI examples
421    ///
422    /// # Example
423    ///
424    /// ```rust,ignore
425    /// // Select the "error" scenario from examples
426    /// let (status, body) = route.mock_response_with_status_and_scenario(Some("error"));
427    /// ```
428    pub fn mock_response_with_status_and_scenario(
429        &self,
430        scenario: Option<&str>,
431    ) -> (u16, serde_json::Value) {
432        self.mock_response_with_status_and_scenario_and_override(scenario, None)
433    }
434
435    /// Generate a mock response with status code, scenario, and optional status override
436    ///
437    /// # Arguments
438    /// * `scenario` - Optional scenario name to select from the OpenAPI examples
439    /// * `status_override` - Optional HTTP status code to use instead of the default
440    pub fn mock_response_with_status_and_scenario_and_override(
441        &self,
442        scenario: Option<&str>,
443        status_override: Option<u16>,
444    ) -> (u16, serde_json::Value) {
445        let (status, body, _) =
446            self.mock_response_with_status_and_scenario_and_trace(scenario, status_override);
447        (status, body)
448    }
449
450    /// Generate a mock response with status code, scenario selection, and trace collection
451    ///
452    /// Returns a tuple of (status_code, response_body, trace_data)
453    pub fn mock_response_with_status_and_scenario_and_trace(
454        &self,
455        scenario: Option<&str>,
456        status_override: Option<u16>,
457    ) -> (
458        u16,
459        serde_json::Value,
460        crate::reality_continuum::response_trace::ResponseGenerationTrace,
461    ) {
462        use crate::openapi::response_trace;
463        use crate::reality_continuum::response_trace::ResponseGenerationTrace;
464
465        // Use status override if the spec has a response for that code, otherwise default
466        let status_code = status_override
467            .filter(|code| self.has_response_for_status(*code))
468            .unwrap_or_else(|| self.find_first_available_status_code());
469
470        // Check if token expansion should be enabled
471        let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
472            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
473            .unwrap_or(false);
474
475        // Use response selection mode for multiple examples
476        let mode = Some(self.response_selection_mode);
477        let selector = Some(self.response_selector.as_ref());
478
479        // Try to generate with trace collection
480        match response_trace::generate_response_with_trace(
481            &self.spec,
482            &self.operation,
483            status_code,
484            Some("application/json"),
485            expand_tokens,
486            scenario,
487            mode,
488            selector,
489            None, // No persona in basic route
490        ) {
491            Ok((response_body, trace)) => {
492                tracing::debug!(
493                    "ResponseGenerator succeeded for {} {} with status {} and scenario {:?}: {:?}",
494                    self.method,
495                    self.path,
496                    status_code,
497                    scenario,
498                    response_body
499                );
500                (status_code, response_body, trace)
501            }
502            Err(e) => {
503                tracing::debug!(
504                    "ResponseGenerator failed for {} {}: {}, using fallback",
505                    self.method,
506                    self.path,
507                    e
508                );
509                // Fallback to simple mock response if schema-based generation fails
510                let response_body = serde_json::json!({
511                    "message": format!("Mock response for {} {}", self.method, self.path),
512                    "operation_id": self.operation.operation_id,
513                    "status": status_code
514                });
515                // Create a minimal trace for fallback
516                let mut trace = ResponseGenerationTrace::new();
517                trace.set_final_payload(response_body.clone());
518                trace.add_metadata("fallback".to_string(), serde_json::json!(true));
519                trace.add_metadata("error".to_string(), serde_json::json!(e.to_string()));
520                (status_code, response_body, trace)
521            }
522        }
523    }
524
525    /// Check if the operation declares a response for the given HTTP status code
526    pub fn has_response_for_status(&self, code: u16) -> bool {
527        self.operation
528            .responses
529            .responses
530            .iter()
531            .any(|(status, _)| matches!(status, openapiv3::StatusCode::Code(c) if *c == code))
532    }
533
534    /// Find the first available status code from the OpenAPI operation responses
535    pub fn find_first_available_status_code(&self) -> u16 {
536        // Look for the first available status code in the responses
537        for (status, _) in &self.operation.responses.responses {
538            match status {
539                openapiv3::StatusCode::Code(code) => {
540                    return *code;
541                }
542                openapiv3::StatusCode::Range(range) => {
543                    // For ranges, use the appropriate status code
544                    match range {
545                        2 => return 200, // 2XX range
546                        3 => return 300, // 3XX range
547                        4 => return 400, // 4XX range
548                        5 => return 500, // 5XX range
549                        _ => continue,   // Skip unknown ranges
550                    }
551                }
552            }
553        }
554
555        // If no specific status codes found, check for default
556        if self.operation.responses.default.is_some() {
557            return 200; // Default to 200 for default responses
558        }
559
560        // Fallback to 200 if nothing else is available
561        200
562    }
563}
564
565/// OpenAPI operation wrapper with path context
566#[derive(Debug, Clone)]
567pub struct OpenApiOperation {
568    /// The HTTP method
569    pub method: String,
570    /// The path this operation belongs to
571    pub path: String,
572    /// The OpenAPI operation
573    pub operation: Operation,
574}
575
576impl OpenApiOperation {
577    /// Create a new OpenApiOperation
578    pub fn new(method: String, path: String, operation: Operation) -> Self {
579        Self {
580            method,
581            path,
582            operation,
583        }
584    }
585}
586
587/// Route generation utilities
588pub struct RouteGenerator;
589
590impl RouteGenerator {
591    /// Generate routes from an OpenAPI path item
592    pub fn generate_routes_from_path(
593        path: &str,
594        path_item: &ReferenceOr<PathItem>,
595        spec: &Arc<OpenApiSpec>,
596    ) -> Result<Vec<OpenApiRoute>> {
597        Self::generate_routes_from_path_with_persona(path, path_item, spec, None)
598    }
599
600    /// Generate routes from an OpenAPI path item with optional persona
601    pub fn generate_routes_from_path_with_persona(
602        path: &str,
603        path_item: &ReferenceOr<PathItem>,
604        spec: &Arc<OpenApiSpec>,
605        persona: Option<Arc<Persona>>,
606    ) -> Result<Vec<OpenApiRoute>> {
607        let mut routes = Vec::new();
608
609        if let Some(item) = path_item.as_item() {
610            // Generate route for each HTTP method
611            if let Some(op) = &item.get {
612                routes.push(OpenApiRoute::new_with_persona(
613                    "GET".to_string(),
614                    path.to_string(),
615                    op.clone(),
616                    spec.clone(),
617                    persona.clone(),
618                ));
619            }
620            if let Some(op) = &item.post {
621                routes.push(OpenApiRoute::new_with_persona(
622                    "POST".to_string(),
623                    path.to_string(),
624                    op.clone(),
625                    spec.clone(),
626                    persona.clone(),
627                ));
628            }
629            if let Some(op) = &item.put {
630                routes.push(OpenApiRoute::new_with_persona(
631                    "PUT".to_string(),
632                    path.to_string(),
633                    op.clone(),
634                    spec.clone(),
635                    persona.clone(),
636                ));
637            }
638            if let Some(op) = &item.delete {
639                routes.push(OpenApiRoute::new_with_persona(
640                    "DELETE".to_string(),
641                    path.to_string(),
642                    op.clone(),
643                    spec.clone(),
644                    persona.clone(),
645                ));
646            }
647            if let Some(op) = &item.patch {
648                routes.push(OpenApiRoute::new_with_persona(
649                    "PATCH".to_string(),
650                    path.to_string(),
651                    op.clone(),
652                    spec.clone(),
653                    persona.clone(),
654                ));
655            }
656            if let Some(op) = &item.head {
657                routes.push(OpenApiRoute::new_with_persona(
658                    "HEAD".to_string(),
659                    path.to_string(),
660                    op.clone(),
661                    spec.clone(),
662                    persona.clone(),
663                ));
664            }
665            if let Some(op) = &item.options {
666                routes.push(OpenApiRoute::new_with_persona(
667                    "OPTIONS".to_string(),
668                    path.to_string(),
669                    op.clone(),
670                    spec.clone(),
671                    persona.clone(),
672                ));
673            }
674            if let Some(op) = &item.trace {
675                routes.push(OpenApiRoute::new_with_persona(
676                    "TRACE".to_string(),
677                    path.to_string(),
678                    op.clone(),
679                    spec.clone(),
680                    persona.clone(),
681                ));
682            }
683        }
684
685        Ok(routes)
686    }
687}