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