Skip to main content

mockforge_bench/
spec_parser.rs

1//! OpenAPI specification parsing for load testing
2
3use crate::error::{BenchError, Result};
4use mockforge_core::openapi::spec::OpenApiSpec;
5use openapiv3::{OpenAPI, Operation, Parameter, PathItem, ReferenceOr};
6use std::path::Path;
7
8/// An API operation extracted from an OpenAPI spec
9#[derive(Debug, Clone)]
10pub struct ApiOperation {
11    pub method: String,
12    pub path: String,
13    pub operation: Operation,
14    pub operation_id: Option<String>,
15}
16
17impl ApiOperation {
18    /// Get a display name for this operation
19    pub fn display_name(&self) -> String {
20        self.operation_id
21            .clone()
22            .unwrap_or_else(|| format!("{} {}", self.method.to_uppercase(), self.path))
23    }
24}
25
26/// Parse OpenAPI specification and extract operations
27pub struct SpecParser {
28    spec: OpenAPI,
29}
30
31impl SpecParser {
32    /// Load and parse an OpenAPI spec from a file
33    pub async fn from_file(path: &Path) -> Result<Self> {
34        let spec = OpenApiSpec::from_file(path)
35            .await
36            .map_err(|e| BenchError::SpecParseError(e.to_string()))?;
37
38        Ok(Self {
39            spec: spec.spec.clone(),
40        })
41    }
42
43    /// Create a parser from a pre-loaded OpenAPI spec
44    pub fn from_spec(spec: OpenApiSpec) -> Self {
45        Self { spec: spec.spec }
46    }
47
48    /// Get a reference to the underlying OpenAPI spec
49    pub fn spec(&self) -> &OpenAPI {
50        &self.spec
51    }
52
53    /// Get all operations from the spec
54    pub fn get_operations(&self) -> Vec<ApiOperation> {
55        let mut operations = Vec::new();
56
57        for (path, path_item) in &self.spec.paths.paths {
58            if let ReferenceOr::Item(item) = path_item {
59                self.extract_operations_from_path(path, item, &mut operations);
60            }
61        }
62
63        operations
64    }
65
66    /// Filter operations by method and path pattern
67    pub fn filter_operations(&self, filter: &str) -> Result<Vec<ApiOperation>> {
68        let all_ops = self.get_operations();
69
70        if filter.is_empty() {
71            return Ok(all_ops);
72        }
73
74        let filters: Vec<&str> = filter.split(',').map(|s| s.trim()).collect();
75        let mut filtered = Vec::new();
76
77        for filter_str in filters {
78            let parts: Vec<&str> = filter_str.splitn(2, ' ').collect();
79            if parts.len() != 2 {
80                return Err(BenchError::Other(format!(
81                    "Invalid operation filter format: '{}'. Expected 'METHOD /path'",
82                    filter_str
83                )));
84            }
85
86            let method = parts[0].to_uppercase();
87            let path_pattern = parts[1];
88
89            for op in &all_ops {
90                if op.method.to_uppercase() == method && Self::matches_path(&op.path, path_pattern)
91                {
92                    filtered.push(op.clone());
93                }
94            }
95        }
96
97        if filtered.is_empty() {
98            return Err(BenchError::OperationNotFound(filter.to_string()));
99        }
100
101        Ok(filtered)
102    }
103
104    /// Exclude operations matching method and path patterns
105    ///
106    /// This is the inverse of filter_operations - it returns all operations
107    /// EXCEPT those matching the exclusion patterns.
108    pub fn exclude_operations(
109        &self,
110        operations: Vec<ApiOperation>,
111        exclude: &str,
112    ) -> Result<Vec<ApiOperation>> {
113        if exclude.is_empty() {
114            return Ok(operations);
115        }
116
117        let exclusions: Vec<&str> = exclude.split(',').map(|s| s.trim()).collect();
118        let mut result = Vec::new();
119
120        for op in operations {
121            let mut should_exclude = false;
122
123            for exclude_str in &exclusions {
124                // Support both "METHOD /path" and just "METHOD" (e.g., "DELETE")
125                let parts: Vec<&str> = exclude_str.splitn(2, ' ').collect();
126
127                let (method, path_pattern) = if parts.len() == 2 {
128                    (parts[0].to_uppercase(), Some(parts[1]))
129                } else {
130                    // Just method name, no path - exclude all operations with this method
131                    (parts[0].to_uppercase(), None)
132                };
133
134                let method_matches = op.method.to_uppercase() == method;
135                let path_matches =
136                    path_pattern.map(|p| Self::matches_path(&op.path, p)).unwrap_or(true); // If no path specified, match all paths for this method
137
138                if method_matches && path_matches {
139                    should_exclude = true;
140                    break;
141                }
142            }
143
144            if !should_exclude {
145                result.push(op);
146            }
147        }
148
149        Ok(result)
150    }
151
152    /// Extract operations from a path item
153    fn extract_operations_from_path(
154        &self,
155        path: &str,
156        path_item: &PathItem,
157        operations: &mut Vec<ApiOperation>,
158    ) {
159        // Resolve path-level parameters (apply to all operations under this path)
160        let path_level_params: Vec<ReferenceOr<Parameter>> = path_item
161            .parameters
162            .iter()
163            .filter_map(|p| self.resolve_parameter(p).map(ReferenceOr::Item))
164            .collect();
165
166        let method_ops = vec![
167            ("get", &path_item.get),
168            ("post", &path_item.post),
169            ("put", &path_item.put),
170            ("delete", &path_item.delete),
171            ("patch", &path_item.patch),
172            ("head", &path_item.head),
173            ("options", &path_item.options),
174        ];
175
176        for (method, op_ref) in method_ops {
177            if let Some(op) = op_ref {
178                // Resolve operation-level $ref parameters and merge with path-level params
179                let mut resolved_op = op.clone();
180                let mut resolved_params: Vec<ReferenceOr<Parameter>> = Vec::new();
181
182                // Start with path-level params
183                resolved_params.extend(path_level_params.clone());
184
185                // Add operation-level params (override path-level for same name)
186                for param_ref in &op.parameters {
187                    if let Some(resolved) = self.resolve_parameter(param_ref) {
188                        resolved_params.push(ReferenceOr::Item(resolved));
189                    }
190                }
191
192                resolved_op.parameters = resolved_params;
193
194                operations.push(ApiOperation {
195                    method: method.to_string(),
196                    path: path.to_string(),
197                    operation: resolved_op,
198                    operation_id: op.operation_id.clone(),
199                });
200            }
201        }
202    }
203
204    /// Resolve a parameter reference to its concrete definition
205    fn resolve_parameter(&self, param_ref: &ReferenceOr<Parameter>) -> Option<Parameter> {
206        match param_ref {
207            ReferenceOr::Item(param) => Some(param.clone()),
208            ReferenceOr::Reference { reference } => {
209                // Parse reference like "#/components/parameters/id"
210                let parts: Vec<&str> = reference.split('/').collect();
211                if parts.len() >= 4 && parts[1] == "components" && parts[2] == "parameters" {
212                    let param_name = parts[3];
213                    if let Some(components) = &self.spec.components {
214                        if let ReferenceOr::Item(param) = components.parameters.get(param_name)? {
215                            return Some(param.clone());
216                        }
217                    }
218                }
219                None
220            }
221        }
222    }
223
224    /// Check if a path matches a pattern (supports wildcards)
225    fn matches_path(path: &str, pattern: &str) -> bool {
226        if pattern == path {
227            return true;
228        }
229
230        // Simple wildcard matching
231        if let Some(prefix) = pattern.strip_suffix('*') {
232            return path.starts_with(prefix);
233        }
234
235        false
236    }
237
238    /// Get the base URL from the spec (if available)
239    pub fn get_base_url(&self) -> Option<String> {
240        self.spec.servers.first().map(|server| server.url.clone())
241    }
242
243    /// Extract the base path from the spec's servers URL
244    ///
245    /// This parses the first server URL and extracts the path component.
246    /// For example:
247    /// - "https://api.example.com/api/v1" -> Some("/api/v1")
248    /// - "https://api.example.com" -> None
249    /// - "/api/v1" -> Some("/api/v1")
250    ///
251    /// Returns None if there are no servers or the path is just "/".
252    pub fn get_base_path(&self) -> Option<String> {
253        let server_url = self.spec.servers.first().map(|s| &s.url)?;
254
255        // Handle relative paths directly (e.g., "/api/v1")
256        if server_url.starts_with('/') {
257            let path = server_url.trim_end_matches('/');
258            if !path.is_empty() && path != "/" {
259                return Some(path.to_string());
260            }
261            return None;
262        }
263
264        // Parse as URL to extract path component
265        if let Ok(parsed) = url::Url::parse(server_url) {
266            let path = parsed.path().trim_end_matches('/');
267            if !path.is_empty() && path != "/" {
268                return Some(path.to_string());
269            }
270        }
271
272        None
273    }
274
275    /// Get API info
276    pub fn get_info(&self) -> (String, String) {
277        (self.spec.info.title.clone(), self.spec.info.version.clone())
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use openapiv3::Operation;
285
286    /// Helper to create test operations
287    fn create_test_operation(method: &str, path: &str, operation_id: Option<&str>) -> ApiOperation {
288        ApiOperation {
289            method: method.to_string(),
290            path: path.to_string(),
291            operation: Operation::default(),
292            operation_id: operation_id.map(|s| s.to_string()),
293        }
294    }
295
296    #[test]
297    fn test_matches_path() {
298        assert!(SpecParser::matches_path("/users", "/users"));
299        assert!(SpecParser::matches_path("/users/123", "/users/*"));
300        assert!(!SpecParser::matches_path("/posts", "/users"));
301    }
302
303    #[test]
304    fn test_exclude_operations_by_method() {
305        // Create a mock parser (we'll test the exclude_operations method directly)
306        let operations = vec![
307            create_test_operation("get", "/users", Some("getUsers")),
308            create_test_operation("post", "/users", Some("createUser")),
309            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
310            create_test_operation("get", "/posts", Some("getPosts")),
311            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
312        ];
313
314        // Test excluding all DELETE operations
315        let spec = openapiv3::OpenAPI::default();
316        let parser = SpecParser { spec };
317        let result = parser.exclude_operations(operations.clone(), "DELETE").unwrap();
318
319        assert_eq!(result.len(), 3);
320        assert!(result.iter().all(|op| op.method.to_uppercase() != "DELETE"));
321    }
322
323    #[test]
324    fn test_exclude_operations_by_method_and_path() {
325        let operations = vec![
326            create_test_operation("get", "/users", Some("getUsers")),
327            create_test_operation("post", "/users", Some("createUser")),
328            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
329            create_test_operation("get", "/posts", Some("getPosts")),
330            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
331        ];
332
333        let spec = openapiv3::OpenAPI::default();
334        let parser = SpecParser { spec };
335
336        // Test excluding specific DELETE operation by path
337        let result = parser.exclude_operations(operations.clone(), "DELETE /users/{id}").unwrap();
338
339        assert_eq!(result.len(), 4);
340        // Should still have deletePost
341        assert!(result.iter().any(|op| op.operation_id == Some("deletePost".to_string())));
342        // Should not have deleteUser
343        assert!(!result.iter().any(|op| op.operation_id == Some("deleteUser".to_string())));
344    }
345
346    #[test]
347    fn test_exclude_operations_multiple_methods() {
348        let operations = vec![
349            create_test_operation("get", "/users", Some("getUsers")),
350            create_test_operation("post", "/users", Some("createUser")),
351            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
352            create_test_operation("put", "/users/{id}", Some("updateUser")),
353        ];
354
355        let spec = openapiv3::OpenAPI::default();
356        let parser = SpecParser { spec };
357
358        // Test excluding DELETE and POST
359        let result = parser.exclude_operations(operations.clone(), "DELETE,POST").unwrap();
360
361        assert_eq!(result.len(), 2);
362        assert!(result.iter().all(|op| op.method.to_uppercase() != "DELETE"));
363        assert!(result.iter().all(|op| op.method.to_uppercase() != "POST"));
364    }
365
366    #[test]
367    fn test_exclude_operations_empty_string() {
368        let operations = vec![
369            create_test_operation("get", "/users", Some("getUsers")),
370            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
371        ];
372
373        let spec = openapiv3::OpenAPI::default();
374        let parser = SpecParser { spec };
375
376        // Empty string should return all operations
377        let result = parser.exclude_operations(operations.clone(), "").unwrap();
378
379        assert_eq!(result.len(), 2);
380    }
381
382    #[test]
383    fn test_exclude_operations_with_wildcard() {
384        let operations = vec![
385            create_test_operation("get", "/users", Some("getUsers")),
386            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
387            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
388        ];
389
390        let spec = openapiv3::OpenAPI::default();
391        let parser = SpecParser { spec };
392
393        // Test excluding DELETE operations matching /users/*
394        let result = parser.exclude_operations(operations.clone(), "DELETE /users/*").unwrap();
395
396        assert_eq!(result.len(), 2);
397        // Should still have deletePost
398        assert!(result.iter().any(|op| op.operation_id == Some("deletePost".to_string())));
399        // Should not have deleteUser
400        assert!(!result.iter().any(|op| op.operation_id == Some("deleteUser".to_string())));
401    }
402
403    #[test]
404    fn test_get_base_path_with_full_url() {
405        let mut spec = openapiv3::OpenAPI::default();
406        spec.servers.push(openapiv3::Server {
407            url: "https://api.example.com/api/v1".to_string(),
408            description: None,
409            variables: Default::default(),
410            extensions: Default::default(),
411        });
412
413        let parser = SpecParser { spec };
414        let base_path = parser.get_base_path();
415
416        assert_eq!(base_path, Some("/api/v1".to_string()));
417    }
418
419    #[test]
420    fn test_get_base_path_with_relative_path() {
421        let mut spec = openapiv3::OpenAPI::default();
422        spec.servers.push(openapiv3::Server {
423            url: "/api/v2".to_string(),
424            description: None,
425            variables: Default::default(),
426            extensions: Default::default(),
427        });
428
429        let parser = SpecParser { spec };
430        let base_path = parser.get_base_path();
431
432        assert_eq!(base_path, Some("/api/v2".to_string()));
433    }
434
435    #[test]
436    fn test_get_base_path_no_path_in_url() {
437        let mut spec = openapiv3::OpenAPI::default();
438        spec.servers.push(openapiv3::Server {
439            url: "https://api.example.com".to_string(),
440            description: None,
441            variables: Default::default(),
442            extensions: Default::default(),
443        });
444
445        let parser = SpecParser { spec };
446        let base_path = parser.get_base_path();
447
448        assert_eq!(base_path, None);
449    }
450
451    #[test]
452    fn test_get_base_path_no_servers() {
453        let spec = openapiv3::OpenAPI::default();
454        let parser = SpecParser { spec };
455        let base_path = parser.get_base_path();
456
457        assert_eq!(base_path, None);
458    }
459
460    #[test]
461    fn test_get_base_path_trailing_slash_removed() {
462        let mut spec = openapiv3::OpenAPI::default();
463        spec.servers.push(openapiv3::Server {
464            url: "https://api.example.com/api/v1/".to_string(),
465            description: None,
466            variables: Default::default(),
467            extensions: Default::default(),
468        });
469
470        let parser = SpecParser { spec };
471        let base_path = parser.get_base_path();
472
473        // Trailing slash should be removed
474        assert_eq!(base_path, Some("/api/v1".to_string()));
475    }
476}