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, 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    /// Get all operations from the spec
44    pub fn get_operations(&self) -> Vec<ApiOperation> {
45        let mut operations = Vec::new();
46
47        for (path, path_item) in &self.spec.paths.paths {
48            if let ReferenceOr::Item(item) = path_item {
49                self.extract_operations_from_path(path, item, &mut operations);
50            }
51        }
52
53        operations
54    }
55
56    /// Filter operations by method and path pattern
57    pub fn filter_operations(&self, filter: &str) -> Result<Vec<ApiOperation>> {
58        let all_ops = self.get_operations();
59
60        if filter.is_empty() {
61            return Ok(all_ops);
62        }
63
64        let filters: Vec<&str> = filter.split(',').map(|s| s.trim()).collect();
65        let mut filtered = Vec::new();
66
67        for filter_str in filters {
68            let parts: Vec<&str> = filter_str.splitn(2, ' ').collect();
69            if parts.len() != 2 {
70                return Err(BenchError::Other(format!(
71                    "Invalid operation filter format: '{}'. Expected 'METHOD /path'",
72                    filter_str
73                )));
74            }
75
76            let method = parts[0].to_uppercase();
77            let path_pattern = parts[1];
78
79            for op in &all_ops {
80                if op.method.to_uppercase() == method && Self::matches_path(&op.path, path_pattern)
81                {
82                    filtered.push(op.clone());
83                }
84            }
85        }
86
87        if filtered.is_empty() {
88            return Err(BenchError::OperationNotFound(filter.to_string()));
89        }
90
91        Ok(filtered)
92    }
93
94    /// Exclude operations matching method and path patterns
95    ///
96    /// This is the inverse of filter_operations - it returns all operations
97    /// EXCEPT those matching the exclusion patterns.
98    pub fn exclude_operations(
99        &self,
100        operations: Vec<ApiOperation>,
101        exclude: &str,
102    ) -> Result<Vec<ApiOperation>> {
103        if exclude.is_empty() {
104            return Ok(operations);
105        }
106
107        let exclusions: Vec<&str> = exclude.split(',').map(|s| s.trim()).collect();
108        let mut result = Vec::new();
109
110        for op in operations {
111            let mut should_exclude = false;
112
113            for exclude_str in &exclusions {
114                // Support both "METHOD /path" and just "METHOD" (e.g., "DELETE")
115                let parts: Vec<&str> = exclude_str.splitn(2, ' ').collect();
116
117                let (method, path_pattern) = if parts.len() == 2 {
118                    (parts[0].to_uppercase(), Some(parts[1]))
119                } else {
120                    // Just method name, no path - exclude all operations with this method
121                    (parts[0].to_uppercase(), None)
122                };
123
124                let method_matches = op.method.to_uppercase() == method;
125                let path_matches = path_pattern
126                    .map(|p| Self::matches_path(&op.path, p))
127                    .unwrap_or(true); // If no path specified, match all paths for this method
128
129                if method_matches && path_matches {
130                    should_exclude = true;
131                    break;
132                }
133            }
134
135            if !should_exclude {
136                result.push(op);
137            }
138        }
139
140        Ok(result)
141    }
142
143    /// Extract operations from a path item
144    fn extract_operations_from_path(
145        &self,
146        path: &str,
147        path_item: &PathItem,
148        operations: &mut Vec<ApiOperation>,
149    ) {
150        let method_ops = vec![
151            ("get", &path_item.get),
152            ("post", &path_item.post),
153            ("put", &path_item.put),
154            ("delete", &path_item.delete),
155            ("patch", &path_item.patch),
156            ("head", &path_item.head),
157            ("options", &path_item.options),
158        ];
159
160        for (method, op_ref) in method_ops {
161            if let Some(op) = op_ref {
162                operations.push(ApiOperation {
163                    method: method.to_string(),
164                    path: path.to_string(),
165                    operation: op.clone(),
166                    operation_id: op.operation_id.clone(),
167                });
168            }
169        }
170    }
171
172    /// Check if a path matches a pattern (supports wildcards)
173    fn matches_path(path: &str, pattern: &str) -> bool {
174        if pattern == path {
175            return true;
176        }
177
178        // Simple wildcard matching
179        if let Some(prefix) = pattern.strip_suffix('*') {
180            return path.starts_with(prefix);
181        }
182
183        false
184    }
185
186    /// Get the base URL from the spec (if available)
187    pub fn get_base_url(&self) -> Option<String> {
188        self.spec.servers.first().map(|server| server.url.clone())
189    }
190
191    /// Get API info
192    pub fn get_info(&self) -> (String, String) {
193        (self.spec.info.title.clone(), self.spec.info.version.clone())
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use openapiv3::Operation;
201
202    /// Helper to create test operations
203    fn create_test_operation(method: &str, path: &str, operation_id: Option<&str>) -> ApiOperation {
204        ApiOperation {
205            method: method.to_string(),
206            path: path.to_string(),
207            operation: Operation::default(),
208            operation_id: operation_id.map(|s| s.to_string()),
209        }
210    }
211
212    #[test]
213    fn test_matches_path() {
214        assert!(SpecParser::matches_path("/users", "/users"));
215        assert!(SpecParser::matches_path("/users/123", "/users/*"));
216        assert!(!SpecParser::matches_path("/posts", "/users"));
217    }
218
219    #[test]
220    fn test_exclude_operations_by_method() {
221        // Create a mock parser (we'll test the exclude_operations method directly)
222        let operations = vec![
223            create_test_operation("get", "/users", Some("getUsers")),
224            create_test_operation("post", "/users", Some("createUser")),
225            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
226            create_test_operation("get", "/posts", Some("getPosts")),
227            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
228        ];
229
230        // Test excluding all DELETE operations
231        let spec = openapiv3::OpenAPI::default();
232        let parser = SpecParser { spec };
233        let result = parser.exclude_operations(operations.clone(), "DELETE").unwrap();
234
235        assert_eq!(result.len(), 3);
236        assert!(result.iter().all(|op| op.method.to_uppercase() != "DELETE"));
237    }
238
239    #[test]
240    fn test_exclude_operations_by_method_and_path() {
241        let operations = vec![
242            create_test_operation("get", "/users", Some("getUsers")),
243            create_test_operation("post", "/users", Some("createUser")),
244            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
245            create_test_operation("get", "/posts", Some("getPosts")),
246            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
247        ];
248
249        let spec = openapiv3::OpenAPI::default();
250        let parser = SpecParser { spec };
251
252        // Test excluding specific DELETE operation by path
253        let result = parser.exclude_operations(operations.clone(), "DELETE /users/{id}").unwrap();
254
255        assert_eq!(result.len(), 4);
256        // Should still have deletePost
257        assert!(result.iter().any(|op| op.operation_id == Some("deletePost".to_string())));
258        // Should not have deleteUser
259        assert!(!result.iter().any(|op| op.operation_id == Some("deleteUser".to_string())));
260    }
261
262    #[test]
263    fn test_exclude_operations_multiple_methods() {
264        let operations = vec![
265            create_test_operation("get", "/users", Some("getUsers")),
266            create_test_operation("post", "/users", Some("createUser")),
267            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
268            create_test_operation("put", "/users/{id}", Some("updateUser")),
269        ];
270
271        let spec = openapiv3::OpenAPI::default();
272        let parser = SpecParser { spec };
273
274        // Test excluding DELETE and POST
275        let result = parser.exclude_operations(operations.clone(), "DELETE,POST").unwrap();
276
277        assert_eq!(result.len(), 2);
278        assert!(result.iter().all(|op| op.method.to_uppercase() != "DELETE"));
279        assert!(result.iter().all(|op| op.method.to_uppercase() != "POST"));
280    }
281
282    #[test]
283    fn test_exclude_operations_empty_string() {
284        let operations = vec![
285            create_test_operation("get", "/users", Some("getUsers")),
286            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
287        ];
288
289        let spec = openapiv3::OpenAPI::default();
290        let parser = SpecParser { spec };
291
292        // Empty string should return all operations
293        let result = parser.exclude_operations(operations.clone(), "").unwrap();
294
295        assert_eq!(result.len(), 2);
296    }
297
298    #[test]
299    fn test_exclude_operations_with_wildcard() {
300        let operations = vec![
301            create_test_operation("get", "/users", Some("getUsers")),
302            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
303            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
304        ];
305
306        let spec = openapiv3::OpenAPI::default();
307        let parser = SpecParser { spec };
308
309        // Test excluding DELETE operations matching /users/*
310        let result = parser.exclude_operations(operations.clone(), "DELETE /users/*").unwrap();
311
312        assert_eq!(result.len(), 2);
313        // Should still have deletePost
314        assert!(result.iter().any(|op| op.operation_id == Some("deletePost".to_string())));
315        // Should not have deleteUser
316        assert!(!result.iter().any(|op| op.operation_id == Some("deleteUser".to_string())));
317    }
318}