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::openapi::response_selection::{ResponseSelectionMode, ResponseSelector};
7use crate::{ai_response::AiResponseConfig, openapi::spec::OpenApiSpec, Result};
8use openapiv3::{Operation, PathItem, ReferenceOr};
9use std::collections::BTreeMap;
10use std::sync::Arc;
11
12/// Extract path parameters from an OpenAPI path template
13fn extract_path_parameters(path_template: &str) -> Vec<String> {
14    let mut params = Vec::new();
15    let mut in_param = false;
16    let mut current_param = String::new();
17
18    for ch in path_template.chars() {
19        match ch {
20            '{' => {
21                in_param = true;
22                current_param.clear();
23            }
24            '}' => {
25                if in_param {
26                    params.push(current_param.clone());
27                    in_param = false;
28                }
29            }
30            ch if in_param => {
31                current_param.push(ch);
32            }
33            _ => {}
34        }
35    }
36
37    params
38}
39
40/// OpenAPI route wrapper with additional metadata
41#[derive(Debug, Clone)]
42pub struct OpenApiRoute {
43    /// The HTTP method
44    pub method: String,
45    /// The path pattern
46    pub path: String,
47    /// The OpenAPI operation
48    pub operation: Operation,
49    /// Route-specific metadata
50    pub metadata: BTreeMap<String, String>,
51    /// Path parameters extracted from the path
52    pub parameters: Vec<String>,
53    /// Reference to the OpenAPI spec for response generation
54    pub spec: Arc<OpenApiSpec>,
55    /// AI response configuration (parsed from x-mockforge-ai extension)
56    pub ai_config: Option<AiResponseConfig>,
57    /// Response selection mode (parsed from x-mockforge-response-selection extension)
58    pub response_selection_mode: ResponseSelectionMode,
59    /// Response selector for sequential/random modes (shared across requests)
60    pub response_selector: Arc<ResponseSelector>,
61}
62
63impl OpenApiRoute {
64    /// Create a new OpenApiRoute
65    pub fn new(method: String, path: String, operation: Operation, spec: Arc<OpenApiSpec>) -> Self {
66        let parameters = extract_path_parameters(&path);
67
68        // Parse AI configuration from x-mockforge-ai vendor extension
69        let ai_config = Self::parse_ai_config(&operation);
70
71        // Parse response selection mode from x-mockforge-response-selection extension
72        let response_selection_mode = Self::parse_response_selection_mode(&operation);
73        let response_selector = Arc::new(ResponseSelector::new(response_selection_mode));
74
75        Self {
76            method,
77            path,
78            operation,
79            metadata: BTreeMap::new(),
80            parameters,
81            spec,
82            ai_config,
83            response_selection_mode,
84            response_selector,
85        }
86    }
87
88    /// Parse AI configuration from OpenAPI operation's vendor extensions
89    fn parse_ai_config(operation: &Operation) -> Option<AiResponseConfig> {
90        // Check for x-mockforge-ai extension
91        if let Some(ai_config_value) = operation.extensions.get("x-mockforge-ai") {
92            // Try to deserialize the AI config from the extension value
93            match serde_json::from_value::<AiResponseConfig>(ai_config_value.clone()) {
94                Ok(config) => {
95                    if config.is_active() {
96                        tracing::debug!(
97                            "Parsed AI config for operation {}: mode={:?}, prompt={:?}",
98                            operation.operation_id.as_deref().unwrap_or("unknown"),
99                            config.mode,
100                            config.prompt
101                        );
102                        return Some(config);
103                    }
104                }
105                Err(e) => {
106                    tracing::warn!(
107                        "Failed to parse x-mockforge-ai extension for operation {}: {}",
108                        operation.operation_id.as_deref().unwrap_or("unknown"),
109                        e
110                    );
111                }
112            }
113        }
114        None
115    }
116
117    /// Parse response selection mode from OpenAPI operation's vendor extensions
118    fn parse_response_selection_mode(operation: &Operation) -> ResponseSelectionMode {
119        // Check for environment variable override (per-operation or global)
120        let op_id = operation.operation_id.as_deref().unwrap_or("unknown");
121
122        // Try operation-specific env var first: MOCKFORGE_RESPONSE_SELECTION_<OPERATION_ID>
123        if let Some(op_env_var) = std::env::var(format!(
124            "MOCKFORGE_RESPONSE_SELECTION_{}",
125            op_id.to_uppercase().replace('-', "_")
126        ))
127        .ok()
128        {
129            if let Some(mode) = ResponseSelectionMode::from_str(&op_env_var) {
130                tracing::debug!(
131                    "Using response selection mode from env var for operation {}: {:?}",
132                    op_id,
133                    mode
134                );
135                return mode;
136            }
137        }
138
139        // Check global env var: MOCKFORGE_RESPONSE_SELECTION_MODE
140        if let Some(global_mode_str) = std::env::var("MOCKFORGE_RESPONSE_SELECTION_MODE").ok() {
141            if let Some(mode) = ResponseSelectionMode::from_str(&global_mode_str) {
142                tracing::debug!("Using global response selection mode from env var: {:?}", mode);
143                return mode;
144            }
145        }
146
147        // Check for x-mockforge-response-selection extension
148        if let Some(selection_value) = operation.extensions.get("x-mockforge-response-selection") {
149            // Try to parse as string first
150            if let Some(mode_str) = selection_value.as_str() {
151                if let Some(mode) = ResponseSelectionMode::from_str(mode_str) {
152                    tracing::debug!(
153                        "Parsed response selection mode for operation {}: {:?}",
154                        op_id,
155                        mode
156                    );
157                    return mode;
158                }
159            }
160            // Try to parse as object with mode field
161            if let Some(obj) = selection_value.as_object() {
162                if let Some(mode_str) = obj.get("mode").and_then(|v| v.as_str()) {
163                    if let Some(mode) = ResponseSelectionMode::from_str(mode_str) {
164                        tracing::debug!(
165                            "Parsed response selection mode for operation {}: {:?}",
166                            op_id,
167                            mode
168                        );
169                        return mode;
170                    }
171                }
172            }
173            tracing::warn!(
174                "Failed to parse x-mockforge-response-selection extension for operation {}",
175                op_id
176            );
177        }
178        // Default to First mode
179        ResponseSelectionMode::First
180    }
181
182    /// Create an OpenApiRoute from an operation
183    pub fn from_operation(
184        method: &str,
185        path: String,
186        operation: &Operation,
187        spec: Arc<OpenApiSpec>,
188    ) -> Self {
189        Self::new(method.to_string(), path, operation.clone(), spec)
190    }
191
192    /// Convert OpenAPI path to Axum-compatible path format
193    pub fn axum_path(&self) -> String {
194        // Axum v0.7+ uses {param} format, same as OpenAPI
195        self.path.clone()
196    }
197
198    /// Add metadata to the route
199    pub fn with_metadata(mut self, key: String, value: String) -> Self {
200        self.metadata.insert(key, value);
201        self
202    }
203
204    /// Generate a mock response with status code for this route (async version with AI support)
205    ///
206    /// This method checks if AI response generation is configured and uses it if available,
207    /// otherwise falls back to standard OpenAPI response generation.
208    ///
209    /// # Arguments
210    /// * `context` - The request context for AI prompt expansion
211    /// * `ai_generator` - Optional AI generator implementation for actual LLM calls
212    pub async fn mock_response_with_status_async(
213        &self,
214        context: &crate::ai_response::RequestContext,
215        ai_generator: Option<&dyn crate::openapi::response::AiGenerator>,
216    ) -> (u16, serde_json::Value) {
217        use crate::openapi::response::ResponseGenerator;
218
219        // Find the first available status code from the OpenAPI spec
220        let status_code = self.find_first_available_status_code();
221
222        // Check if AI response generation is configured
223        if let Some(ai_config) = &self.ai_config {
224            if ai_config.is_active() {
225                tracing::info!(
226                    "Using AI-assisted response generation for {} {}",
227                    self.method,
228                    self.path
229                );
230
231                match ResponseGenerator::generate_ai_response(ai_config, context, ai_generator)
232                    .await
233                {
234                    Ok(response_body) => {
235                        tracing::debug!(
236                            "AI response generated successfully for {} {}: {:?}",
237                            self.method,
238                            self.path,
239                            response_body
240                        );
241                        return (status_code, response_body);
242                    }
243                    Err(e) => {
244                        tracing::warn!(
245                            "AI response generation failed for {} {}: {}, falling back to standard generation",
246                            self.method,
247                            self.path,
248                            e
249                        );
250                        // Continue to standard generation on error
251                    }
252                }
253            }
254        }
255
256        // Standard OpenAPI-based response generation
257        let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
258            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
259            .unwrap_or(false);
260
261        // Use response selection mode for multiple examples
262        let mode = Some(self.response_selection_mode);
263        let selector = Some(self.response_selector.as_ref());
264
265        match ResponseGenerator::generate_response_with_expansion_and_mode(
266            &self.spec,
267            &self.operation,
268            status_code,
269            Some("application/json"),
270            expand_tokens,
271            mode,
272            selector,
273        ) {
274            Ok(response_body) => {
275                tracing::debug!(
276                    "ResponseGenerator succeeded for {} {} with status {}: {:?}",
277                    self.method,
278                    self.path,
279                    status_code,
280                    response_body
281                );
282                (status_code, response_body)
283            }
284            Err(e) => {
285                tracing::debug!(
286                    "ResponseGenerator failed for {} {}: {}, using fallback",
287                    self.method,
288                    self.path,
289                    e
290                );
291                // Fallback to simple mock response if schema-based generation fails
292                let response_body = serde_json::json!({
293                    "message": format!("Mock response for {} {}", self.method, self.path),
294                    "operation_id": self.operation.operation_id,
295                    "status": status_code
296                });
297                (status_code, response_body)
298            }
299        }
300    }
301
302    /// Generate a mock response with status code for this route (synchronous version)
303    ///
304    /// Note: This method does not support AI-assisted response generation.
305    /// Use `mock_response_with_status_async` for AI features.
306    pub fn mock_response_with_status(&self) -> (u16, serde_json::Value) {
307        self.mock_response_with_status_and_scenario(None)
308    }
309
310    /// Generate a mock response with status code and scenario selection
311    ///
312    /// # Arguments
313    /// * `scenario` - Optional scenario name to select from the OpenAPI examples
314    ///
315    /// # Example
316    /// ```rust
317    /// // Select the "error" scenario from examples
318    /// let (status, body) = route.mock_response_with_status_and_scenario(Some("error"));
319    /// ```
320    pub fn mock_response_with_status_and_scenario(
321        &self,
322        scenario: Option<&str>,
323    ) -> (u16, serde_json::Value) {
324        use crate::openapi::response::ResponseGenerator;
325
326        // Find the first available status code from the OpenAPI spec
327        let status_code = self.find_first_available_status_code();
328
329        // Check if token expansion should be enabled
330        let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
331            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
332            .unwrap_or(false);
333
334        // Use response selection mode for multiple examples
335        let mode = Some(self.response_selection_mode);
336        let selector = Some(self.response_selector.as_ref());
337
338        match ResponseGenerator::generate_response_with_scenario_and_mode(
339            &self.spec,
340            &self.operation,
341            status_code,
342            Some("application/json"),
343            expand_tokens,
344            scenario,
345            mode,
346            selector,
347        ) {
348            Ok(response_body) => {
349                tracing::debug!(
350                    "ResponseGenerator succeeded for {} {} with status {} and scenario {:?}: {:?}",
351                    self.method,
352                    self.path,
353                    status_code,
354                    scenario,
355                    response_body
356                );
357                (status_code, response_body)
358            }
359            Err(e) => {
360                tracing::debug!(
361                    "ResponseGenerator failed for {} {}: {}, using fallback",
362                    self.method,
363                    self.path,
364                    e
365                );
366                // Fallback to simple mock response if schema-based generation fails
367                let response_body = serde_json::json!({
368                    "message": format!("Mock response for {} {}", self.method, self.path),
369                    "operation_id": self.operation.operation_id,
370                    "status": status_code
371                });
372                (status_code, response_body)
373            }
374        }
375    }
376
377    /// Find the first available status code from the OpenAPI operation responses
378    fn find_first_available_status_code(&self) -> u16 {
379        // Look for the first available status code in the responses
380        for (status, _) in &self.operation.responses.responses {
381            match status {
382                openapiv3::StatusCode::Code(code) => {
383                    return *code;
384                }
385                openapiv3::StatusCode::Range(range) => {
386                    // For ranges, use the appropriate status code
387                    match range {
388                        2 => return 200, // 2XX range
389                        3 => return 300, // 3XX range
390                        4 => return 400, // 4XX range
391                        5 => return 500, // 5XX range
392                        _ => continue,   // Skip unknown ranges
393                    }
394                }
395            }
396        }
397
398        // If no specific status codes found, check for default
399        if self.operation.responses.default.is_some() {
400            return 200; // Default to 200 for default responses
401        }
402
403        // Fallback to 200 if nothing else is available
404        200
405    }
406}
407
408/// OpenAPI operation wrapper with path context
409#[derive(Debug, Clone)]
410pub struct OpenApiOperation {
411    /// The HTTP method
412    pub method: String,
413    /// The path this operation belongs to
414    pub path: String,
415    /// The OpenAPI operation
416    pub operation: Operation,
417}
418
419impl OpenApiOperation {
420    /// Create a new OpenApiOperation
421    pub fn new(method: String, path: String, operation: Operation) -> Self {
422        Self {
423            method,
424            path,
425            operation,
426        }
427    }
428}
429
430/// Route generation utilities
431pub struct RouteGenerator;
432
433impl RouteGenerator {
434    /// Generate routes from an OpenAPI path item
435    pub fn generate_routes_from_path(
436        path: &str,
437        path_item: &ReferenceOr<PathItem>,
438        spec: &Arc<OpenApiSpec>,
439    ) -> Result<Vec<OpenApiRoute>> {
440        let mut routes = Vec::new();
441
442        if let Some(item) = path_item.as_item() {
443            // Generate route for each HTTP method
444            if let Some(op) = &item.get {
445                routes.push(OpenApiRoute::new(
446                    "GET".to_string(),
447                    path.to_string(),
448                    op.clone(),
449                    spec.clone(),
450                ));
451            }
452            if let Some(op) = &item.post {
453                routes.push(OpenApiRoute::new(
454                    "POST".to_string(),
455                    path.to_string(),
456                    op.clone(),
457                    spec.clone(),
458                ));
459            }
460            if let Some(op) = &item.put {
461                routes.push(OpenApiRoute::new(
462                    "PUT".to_string(),
463                    path.to_string(),
464                    op.clone(),
465                    spec.clone(),
466                ));
467            }
468            if let Some(op) = &item.delete {
469                routes.push(OpenApiRoute::new(
470                    "DELETE".to_string(),
471                    path.to_string(),
472                    op.clone(),
473                    spec.clone(),
474                ));
475            }
476            if let Some(op) = &item.patch {
477                routes.push(OpenApiRoute::new(
478                    "PATCH".to_string(),
479                    path.to_string(),
480                    op.clone(),
481                    spec.clone(),
482                ));
483            }
484            if let Some(op) = &item.head {
485                routes.push(OpenApiRoute::new(
486                    "HEAD".to_string(),
487                    path.to_string(),
488                    op.clone(),
489                    spec.clone(),
490                ));
491            }
492            if let Some(op) = &item.options {
493                routes.push(OpenApiRoute::new(
494                    "OPTIONS".to_string(),
495                    path.to_string(),
496                    op.clone(),
497                    spec.clone(),
498                ));
499            }
500            if let Some(op) = &item.trace {
501                routes.push(OpenApiRoute::new(
502                    "TRACE".to_string(),
503                    path.to_string(),
504                    op.clone(),
505                    spec.clone(),
506                ));
507            }
508        }
509
510        Ok(routes)
511    }
512}