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