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 Some(op_env_var) = std::env::var(format!(
139            "MOCKFORGE_RESPONSE_SELECTION_{}",
140            op_id.to_uppercase().replace('-', "_")
141        ))
142        .ok()
143        {
144            if let Some(mode) = ResponseSelectionMode::from_str(&op_env_var) {
145                tracing::debug!(
146                    "Using response selection mode from env var for operation {}: {:?}",
147                    op_id,
148                    mode
149                );
150                return mode;
151            }
152        }
153
154        // Check global env var: MOCKFORGE_RESPONSE_SELECTION_MODE
155        if let Some(global_mode_str) = std::env::var("MOCKFORGE_RESPONSE_SELECTION_MODE").ok() {
156            if let Some(mode) = ResponseSelectionMode::from_str(&global_mode_str) {
157                tracing::debug!("Using global response selection mode from env var: {:?}", mode);
158                return mode;
159            }
160        }
161
162        // Check for x-mockforge-response-selection extension
163        if let Some(selection_value) = operation.extensions.get("x-mockforge-response-selection") {
164            // Try to parse as string first
165            if let Some(mode_str) = selection_value.as_str() {
166                if let Some(mode) = ResponseSelectionMode::from_str(mode_str) {
167                    tracing::debug!(
168                        "Parsed response selection mode for operation {}: {:?}",
169                        op_id,
170                        mode
171                    );
172                    return mode;
173                }
174            }
175            // Try to parse as object with mode field
176            if let Some(obj) = selection_value.as_object() {
177                if let Some(mode_str) = obj.get("mode").and_then(|v| v.as_str()) {
178                    if let Some(mode) = ResponseSelectionMode::from_str(mode_str) {
179                        tracing::debug!(
180                            "Parsed response selection mode for operation {}: {:?}",
181                            op_id,
182                            mode
183                        );
184                        return mode;
185                    }
186                }
187            }
188            tracing::warn!(
189                "Failed to parse x-mockforge-response-selection extension for operation {}",
190                op_id
191            );
192        }
193        // Default to First mode
194        ResponseSelectionMode::First
195    }
196
197    /// Create an OpenApiRoute from an operation
198    pub fn from_operation(
199        method: &str,
200        path: String,
201        operation: &Operation,
202        spec: Arc<OpenApiSpec>,
203    ) -> Self {
204        Self::from_operation_with_persona(method, path, operation, spec, None)
205    }
206
207    /// Create a new OpenApiRoute from an operation with optional persona
208    pub fn from_operation_with_persona(
209        method: &str,
210        path: String,
211        operation: &Operation,
212        spec: Arc<OpenApiSpec>,
213        persona: Option<Arc<Persona>>,
214    ) -> Self {
215        Self::new_with_persona(method.to_string(), path, operation.clone(), spec, persona)
216    }
217
218    /// Convert OpenAPI path to Axum-compatible path format
219    pub fn axum_path(&self) -> String {
220        // Axum v0.7+ uses {param} format, same as OpenAPI
221        self.path.clone()
222    }
223
224    /// Add metadata to the route
225    pub fn with_metadata(mut self, key: String, value: String) -> Self {
226        self.metadata.insert(key, value);
227        self
228    }
229
230    /// Generate a mock response with status code for this route (async version with AI support)
231    ///
232    /// This method checks if AI response generation is configured and uses it if available,
233    /// otherwise falls back to standard OpenAPI response generation.
234    ///
235    /// # Arguments
236    /// * `context` - The request context for AI prompt expansion
237    /// * `ai_generator` - Optional AI generator implementation for actual LLM calls
238    pub async fn mock_response_with_status_async(
239        &self,
240        context: &crate::ai_response::RequestContext,
241        ai_generator: Option<&dyn crate::openapi::response::AiGenerator>,
242    ) -> (u16, serde_json::Value) {
243        use crate::openapi::response::ResponseGenerator;
244
245        // Find the first available status code from the OpenAPI spec
246        let status_code = self.find_first_available_status_code();
247
248        // Check if AI response generation is configured
249        if let Some(ai_config) = &self.ai_config {
250            if ai_config.is_active() {
251                tracing::info!(
252                    "Using AI-assisted response generation for {} {}",
253                    self.method,
254                    self.path
255                );
256
257                match ResponseGenerator::generate_ai_response(ai_config, context, ai_generator)
258                    .await
259                {
260                    Ok(response_body) => {
261                        tracing::debug!(
262                            "AI response generated successfully for {} {}: {:?}",
263                            self.method,
264                            self.path,
265                            response_body
266                        );
267                        return (status_code, response_body);
268                    }
269                    Err(e) => {
270                        tracing::warn!(
271                            "AI response generation failed for {} {}: {}, falling back to standard generation",
272                            self.method,
273                            self.path,
274                            e
275                        );
276                        // Continue to standard generation on error
277                    }
278                }
279            }
280        }
281
282        // Standard OpenAPI-based response generation
283        let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
284            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
285            .unwrap_or(false);
286
287        // Use response selection mode for multiple examples
288        let mode = Some(self.response_selection_mode);
289        let selector = Some(self.response_selector.as_ref());
290
291        // Get persona reference for response generation
292        let persona_ref = self.persona.as_deref();
293
294        match ResponseGenerator::generate_response_with_expansion_and_mode_and_persona(
295            &self.spec,
296            &self.operation,
297            status_code,
298            Some("application/json"),
299            expand_tokens,
300            mode,
301            selector,
302            persona_ref,
303        ) {
304            Ok(response_body) => {
305                tracing::debug!(
306                    "ResponseGenerator succeeded for {} {} with status {}: {:?}",
307                    self.method,
308                    self.path,
309                    status_code,
310                    response_body
311                );
312                (status_code, response_body)
313            }
314            Err(e) => {
315                tracing::debug!(
316                    "ResponseGenerator failed for {} {}: {}, using fallback",
317                    self.method,
318                    self.path,
319                    e
320                );
321                // Fallback to simple mock response if schema-based generation fails
322                let response_body = serde_json::json!({
323                    "message": format!("Mock response for {} {}", self.method, self.path),
324                    "operation_id": self.operation.operation_id,
325                    "status": status_code
326                });
327                (status_code, response_body)
328            }
329        }
330    }
331
332    /// Generate a mock response with status code for this route (synchronous version)
333    ///
334    /// Note: This method does not support AI-assisted response generation.
335    /// Use `mock_response_with_status_async` for AI features.
336    pub fn mock_response_with_status(&self) -> (u16, serde_json::Value) {
337        self.mock_response_with_status_and_scenario(None)
338    }
339
340    /// Generate a mock response with status code and scenario selection
341    ///
342    /// # Arguments
343    /// * `scenario` - Optional scenario name to select from the OpenAPI examples
344    ///
345    /// # Example
346    /// ```rust
347    /// // Select the "error" scenario from examples
348    /// let (status, body) = route.mock_response_with_status_and_scenario(Some("error"));
349    /// ```
350    pub fn mock_response_with_status_and_scenario(
351        &self,
352        scenario: Option<&str>,
353    ) -> (u16, serde_json::Value) {
354        use crate::openapi::response::ResponseGenerator;
355
356        // Find the first available status code from the OpenAPI spec
357        let status_code = self.find_first_available_status_code();
358
359        // Check if token expansion should be enabled
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        match ResponseGenerator::generate_response_with_scenario_and_mode(
369            &self.spec,
370            &self.operation,
371            status_code,
372            Some("application/json"),
373            expand_tokens,
374            scenario,
375            mode,
376            selector,
377        ) {
378            Ok(response_body) => {
379                tracing::debug!(
380                    "ResponseGenerator succeeded for {} {} with status {} and scenario {:?}: {:?}",
381                    self.method,
382                    self.path,
383                    status_code,
384                    scenario,
385                    response_body
386                );
387                (status_code, response_body)
388            }
389            Err(e) => {
390                tracing::debug!(
391                    "ResponseGenerator failed for {} {}: {}, using fallback",
392                    self.method,
393                    self.path,
394                    e
395                );
396                // Fallback to simple mock response if schema-based generation fails
397                let response_body = serde_json::json!({
398                    "message": format!("Mock response for {} {}", self.method, self.path),
399                    "operation_id": self.operation.operation_id,
400                    "status": status_code
401                });
402                (status_code, response_body)
403            }
404        }
405    }
406
407    /// Find the first available status code from the OpenAPI operation responses
408    fn find_first_available_status_code(&self) -> u16 {
409        // Look for the first available status code in the responses
410        for (status, _) in &self.operation.responses.responses {
411            match status {
412                openapiv3::StatusCode::Code(code) => {
413                    return *code;
414                }
415                openapiv3::StatusCode::Range(range) => {
416                    // For ranges, use the appropriate status code
417                    match range {
418                        2 => return 200, // 2XX range
419                        3 => return 300, // 3XX range
420                        4 => return 400, // 4XX range
421                        5 => return 500, // 5XX range
422                        _ => continue,   // Skip unknown ranges
423                    }
424                }
425            }
426        }
427
428        // If no specific status codes found, check for default
429        if self.operation.responses.default.is_some() {
430            return 200; // Default to 200 for default responses
431        }
432
433        // Fallback to 200 if nothing else is available
434        200
435    }
436}
437
438/// OpenAPI operation wrapper with path context
439#[derive(Debug, Clone)]
440pub struct OpenApiOperation {
441    /// The HTTP method
442    pub method: String,
443    /// The path this operation belongs to
444    pub path: String,
445    /// The OpenAPI operation
446    pub operation: Operation,
447}
448
449impl OpenApiOperation {
450    /// Create a new OpenApiOperation
451    pub fn new(method: String, path: String, operation: Operation) -> Self {
452        Self {
453            method,
454            path,
455            operation,
456        }
457    }
458}
459
460/// Route generation utilities
461pub struct RouteGenerator;
462
463impl RouteGenerator {
464    /// Generate routes from an OpenAPI path item
465    pub fn generate_routes_from_path(
466        path: &str,
467        path_item: &ReferenceOr<PathItem>,
468        spec: &Arc<OpenApiSpec>,
469    ) -> Result<Vec<OpenApiRoute>> {
470        Self::generate_routes_from_path_with_persona(path, path_item, spec, None)
471    }
472
473    /// Generate routes from an OpenAPI path item with optional persona
474    pub fn generate_routes_from_path_with_persona(
475        path: &str,
476        path_item: &ReferenceOr<PathItem>,
477        spec: &Arc<OpenApiSpec>,
478        persona: Option<Arc<Persona>>,
479    ) -> Result<Vec<OpenApiRoute>> {
480        let mut routes = Vec::new();
481
482        if let Some(item) = path_item.as_item() {
483            // Generate route for each HTTP method
484            if let Some(op) = &item.get {
485                routes.push(OpenApiRoute::new_with_persona(
486                    "GET".to_string(),
487                    path.to_string(),
488                    op.clone(),
489                    spec.clone(),
490                    persona.clone(),
491                ));
492            }
493            if let Some(op) = &item.post {
494                routes.push(OpenApiRoute::new_with_persona(
495                    "POST".to_string(),
496                    path.to_string(),
497                    op.clone(),
498                    spec.clone(),
499                    persona.clone(),
500                ));
501            }
502            if let Some(op) = &item.put {
503                routes.push(OpenApiRoute::new_with_persona(
504                    "PUT".to_string(),
505                    path.to_string(),
506                    op.clone(),
507                    spec.clone(),
508                    persona.clone(),
509                ));
510            }
511            if let Some(op) = &item.delete {
512                routes.push(OpenApiRoute::new_with_persona(
513                    "DELETE".to_string(),
514                    path.to_string(),
515                    op.clone(),
516                    spec.clone(),
517                    persona.clone(),
518                ));
519            }
520            if let Some(op) = &item.patch {
521                routes.push(OpenApiRoute::new_with_persona(
522                    "PATCH".to_string(),
523                    path.to_string(),
524                    op.clone(),
525                    spec.clone(),
526                    persona.clone(),
527                ));
528            }
529            if let Some(op) = &item.head {
530                routes.push(OpenApiRoute::new_with_persona(
531                    "HEAD".to_string(),
532                    path.to_string(),
533                    op.clone(),
534                    spec.clone(),
535                    persona.clone(),
536                ));
537            }
538            if let Some(op) = &item.options {
539                routes.push(OpenApiRoute::new_with_persona(
540                    "OPTIONS".to_string(),
541                    path.to_string(),
542                    op.clone(),
543                    spec.clone(),
544                    persona.clone(),
545                ));
546            }
547            if let Some(op) = &item.trace {
548                routes.push(OpenApiRoute::new_with_persona(
549                    "TRACE".to_string(),
550                    path.to_string(),
551                    op.clone(),
552                    spec.clone(),
553                    persona.clone(),
554                ));
555            }
556        }
557
558        Ok(routes)
559    }
560}