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        // Axum v0.7+ uses {param} format, same as OpenAPI
219        self.path.clone()
220    }
221
222    /// Add metadata to the route
223    pub fn with_metadata(mut self, key: String, value: String) -> Self {
224        self.metadata.insert(key, value);
225        self
226    }
227
228    /// Generate a mock response with status code for this route (async version with AI support)
229    ///
230    /// This method checks if AI response generation is configured and uses it if available,
231    /// otherwise falls back to standard OpenAPI response generation.
232    ///
233    /// # Arguments
234    /// * `context` - The request context for AI prompt expansion
235    /// * `ai_generator` - Optional AI generator implementation for actual LLM calls
236    pub async fn mock_response_with_status_async(
237        &self,
238        context: &crate::ai_response::RequestContext,
239        ai_generator: Option<&dyn crate::openapi::response::AiGenerator>,
240    ) -> (u16, serde_json::Value) {
241        use crate::openapi::response::ResponseGenerator;
242
243        // Find the first available status code from the OpenAPI spec
244        let status_code = self.find_first_available_status_code();
245
246        // Check if AI response generation is configured
247        if let Some(ai_config) = &self.ai_config {
248            if ai_config.is_active() {
249                tracing::info!(
250                    "Using AI-assisted response generation for {} {}",
251                    self.method,
252                    self.path
253                );
254
255                match ResponseGenerator::generate_ai_response(ai_config, context, ai_generator)
256                    .await
257                {
258                    Ok(response_body) => {
259                        tracing::debug!(
260                            "AI response generated successfully for {} {}: {:?}",
261                            self.method,
262                            self.path,
263                            response_body
264                        );
265                        return (status_code, response_body);
266                    }
267                    Err(e) => {
268                        tracing::warn!(
269                            "AI response generation failed for {} {}: {}, falling back to standard generation",
270                            self.method,
271                            self.path,
272                            e
273                        );
274                        // Continue to standard generation on error
275                    }
276                }
277            }
278        }
279
280        // Standard OpenAPI-based response generation
281        let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
282            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
283            .unwrap_or(false);
284
285        // Use response selection mode for multiple examples
286        let mode = Some(self.response_selection_mode);
287        let selector = Some(self.response_selector.as_ref());
288
289        // Get persona reference for response generation
290        let persona_ref = self.persona.as_deref();
291
292        match ResponseGenerator::generate_response_with_expansion_and_mode_and_persona(
293            &self.spec,
294            &self.operation,
295            status_code,
296            Some("application/json"),
297            expand_tokens,
298            mode,
299            selector,
300            persona_ref,
301        ) {
302            Ok(response_body) => {
303                tracing::debug!(
304                    "ResponseGenerator succeeded for {} {} with status {}: {:?}",
305                    self.method,
306                    self.path,
307                    status_code,
308                    response_body
309                );
310                (status_code, response_body)
311            }
312            Err(e) => {
313                tracing::debug!(
314                    "ResponseGenerator failed for {} {}: {}, using fallback",
315                    self.method,
316                    self.path,
317                    e
318                );
319                // Fallback to simple mock response if schema-based generation fails
320                let response_body = serde_json::json!({
321                    "message": format!("Mock response for {} {}", self.method, self.path),
322                    "operation_id": self.operation.operation_id,
323                    "status": status_code
324                });
325                (status_code, response_body)
326            }
327        }
328    }
329
330    /// Generate a mock response with status code for this route (synchronous version)
331    ///
332    /// Note: This method does not support AI-assisted response generation.
333    /// Use `mock_response_with_status_async` for AI features.
334    pub fn mock_response_with_status(&self) -> (u16, serde_json::Value) {
335        self.mock_response_with_status_and_scenario(None)
336    }
337
338    /// Generate a mock response with status code and scenario selection
339    ///
340    /// # Arguments
341    /// * `scenario` - Optional scenario name to select from the OpenAPI examples
342    ///
343    /// # Example
344    /// ```rust
345    /// // Select the "error" scenario from examples
346    /// let (status, body) = route.mock_response_with_status_and_scenario(Some("error"));
347    /// ```
348    pub fn mock_response_with_status_and_scenario(
349        &self,
350        scenario: Option<&str>,
351    ) -> (u16, serde_json::Value) {
352        let (status, body, _) = self.mock_response_with_status_and_scenario_and_trace(scenario);
353        (status, body)
354    }
355
356    /// Generate a mock response with status code, scenario selection, and trace collection
357    ///
358    /// Returns a tuple of (status_code, response_body, trace_data)
359    pub fn mock_response_with_status_and_scenario_and_trace(
360        &self,
361        scenario: Option<&str>,
362    ) -> (
363        u16,
364        serde_json::Value,
365        crate::reality_continuum::response_trace::ResponseGenerationTrace,
366    ) {
367        use crate::openapi::response_trace;
368        use crate::reality_continuum::response_trace::ResponseGenerationTrace;
369
370        // Find the first available status code from the OpenAPI spec
371        let status_code = self.find_first_available_status_code();
372
373        // Check if token expansion should be enabled
374        let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
375            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
376            .unwrap_or(false);
377
378        // Use response selection mode for multiple examples
379        let mode = Some(self.response_selection_mode);
380        let selector = Some(self.response_selector.as_ref());
381
382        // Try to generate with trace collection
383        match response_trace::generate_response_with_trace(
384            &self.spec,
385            &self.operation,
386            status_code,
387            Some("application/json"),
388            expand_tokens,
389            scenario,
390            mode,
391            selector,
392            None, // No persona in basic route
393        ) {
394            Ok((response_body, trace)) => {
395                tracing::debug!(
396                    "ResponseGenerator succeeded for {} {} with status {} and scenario {:?}: {:?}",
397                    self.method,
398                    self.path,
399                    status_code,
400                    scenario,
401                    response_body
402                );
403                (status_code, response_body, trace)
404            }
405            Err(e) => {
406                tracing::debug!(
407                    "ResponseGenerator failed for {} {}: {}, using fallback",
408                    self.method,
409                    self.path,
410                    e
411                );
412                // Fallback to simple mock response if schema-based generation fails
413                let response_body = serde_json::json!({
414                    "message": format!("Mock response for {} {}", self.method, self.path),
415                    "operation_id": self.operation.operation_id,
416                    "status": status_code
417                });
418                // Create a minimal trace for fallback
419                let mut trace = ResponseGenerationTrace::new();
420                trace.set_final_payload(response_body.clone());
421                trace.add_metadata("fallback".to_string(), serde_json::json!(true));
422                trace.add_metadata("error".to_string(), serde_json::json!(e.to_string()));
423                (status_code, response_body, trace)
424            }
425        }
426    }
427
428    /// Find the first available status code from the OpenAPI operation responses
429    fn find_first_available_status_code(&self) -> u16 {
430        // Look for the first available status code in the responses
431        for (status, _) in &self.operation.responses.responses {
432            match status {
433                openapiv3::StatusCode::Code(code) => {
434                    return *code;
435                }
436                openapiv3::StatusCode::Range(range) => {
437                    // For ranges, use the appropriate status code
438                    match range {
439                        2 => return 200, // 2XX range
440                        3 => return 300, // 3XX range
441                        4 => return 400, // 4XX range
442                        5 => return 500, // 5XX range
443                        _ => continue,   // Skip unknown ranges
444                    }
445                }
446            }
447        }
448
449        // If no specific status codes found, check for default
450        if self.operation.responses.default.is_some() {
451            return 200; // Default to 200 for default responses
452        }
453
454        // Fallback to 200 if nothing else is available
455        200
456    }
457}
458
459/// OpenAPI operation wrapper with path context
460#[derive(Debug, Clone)]
461pub struct OpenApiOperation {
462    /// The HTTP method
463    pub method: String,
464    /// The path this operation belongs to
465    pub path: String,
466    /// The OpenAPI operation
467    pub operation: Operation,
468}
469
470impl OpenApiOperation {
471    /// Create a new OpenApiOperation
472    pub fn new(method: String, path: String, operation: Operation) -> Self {
473        Self {
474            method,
475            path,
476            operation,
477        }
478    }
479}
480
481/// Route generation utilities
482pub struct RouteGenerator;
483
484impl RouteGenerator {
485    /// Generate routes from an OpenAPI path item
486    pub fn generate_routes_from_path(
487        path: &str,
488        path_item: &ReferenceOr<PathItem>,
489        spec: &Arc<OpenApiSpec>,
490    ) -> Result<Vec<OpenApiRoute>> {
491        Self::generate_routes_from_path_with_persona(path, path_item, spec, None)
492    }
493
494    /// Generate routes from an OpenAPI path item with optional persona
495    pub fn generate_routes_from_path_with_persona(
496        path: &str,
497        path_item: &ReferenceOr<PathItem>,
498        spec: &Arc<OpenApiSpec>,
499        persona: Option<Arc<Persona>>,
500    ) -> Result<Vec<OpenApiRoute>> {
501        let mut routes = Vec::new();
502
503        if let Some(item) = path_item.as_item() {
504            // Generate route for each HTTP method
505            if let Some(op) = &item.get {
506                routes.push(OpenApiRoute::new_with_persona(
507                    "GET".to_string(),
508                    path.to_string(),
509                    op.clone(),
510                    spec.clone(),
511                    persona.clone(),
512                ));
513            }
514            if let Some(op) = &item.post {
515                routes.push(OpenApiRoute::new_with_persona(
516                    "POST".to_string(),
517                    path.to_string(),
518                    op.clone(),
519                    spec.clone(),
520                    persona.clone(),
521                ));
522            }
523            if let Some(op) = &item.put {
524                routes.push(OpenApiRoute::new_with_persona(
525                    "PUT".to_string(),
526                    path.to_string(),
527                    op.clone(),
528                    spec.clone(),
529                    persona.clone(),
530                ));
531            }
532            if let Some(op) = &item.delete {
533                routes.push(OpenApiRoute::new_with_persona(
534                    "DELETE".to_string(),
535                    path.to_string(),
536                    op.clone(),
537                    spec.clone(),
538                    persona.clone(),
539                ));
540            }
541            if let Some(op) = &item.patch {
542                routes.push(OpenApiRoute::new_with_persona(
543                    "PATCH".to_string(),
544                    path.to_string(),
545                    op.clone(),
546                    spec.clone(),
547                    persona.clone(),
548                ));
549            }
550            if let Some(op) = &item.head {
551                routes.push(OpenApiRoute::new_with_persona(
552                    "HEAD".to_string(),
553                    path.to_string(),
554                    op.clone(),
555                    spec.clone(),
556                    persona.clone(),
557                ));
558            }
559            if let Some(op) = &item.options {
560                routes.push(OpenApiRoute::new_with_persona(
561                    "OPTIONS".to_string(),
562                    path.to_string(),
563                    op.clone(),
564                    spec.clone(),
565                    persona.clone(),
566                ));
567            }
568            if let Some(op) = &item.trace {
569                routes.push(OpenApiRoute::new_with_persona(
570                    "TRACE".to_string(),
571                    path.to_string(),
572                    op.clone(),
573                    spec.clone(),
574                    persona.clone(),
575                ));
576            }
577        }
578
579        Ok(routes)
580    }
581}