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 =
126                    path_pattern.map(|p| Self::matches_path(&op.path, p)).unwrap_or(true); // If no path specified, match all paths for this method
127
128                if method_matches && path_matches {
129                    should_exclude = true;
130                    break;
131                }
132            }
133
134            if !should_exclude {
135                result.push(op);
136            }
137        }
138
139        Ok(result)
140    }
141
142    /// Extract operations from a path item
143    fn extract_operations_from_path(
144        &self,
145        path: &str,
146        path_item: &PathItem,
147        operations: &mut Vec<ApiOperation>,
148    ) {
149        let method_ops = vec![
150            ("get", &path_item.get),
151            ("post", &path_item.post),
152            ("put", &path_item.put),
153            ("delete", &path_item.delete),
154            ("patch", &path_item.patch),
155            ("head", &path_item.head),
156            ("options", &path_item.options),
157        ];
158
159        for (method, op_ref) in method_ops {
160            if let Some(op) = op_ref {
161                operations.push(ApiOperation {
162                    method: method.to_string(),
163                    path: path.to_string(),
164                    operation: op.clone(),
165                    operation_id: op.operation_id.clone(),
166                });
167            }
168        }
169    }
170
171    /// Check if a path matches a pattern (supports wildcards)
172    fn matches_path(path: &str, pattern: &str) -> bool {
173        if pattern == path {
174            return true;
175        }
176
177        // Simple wildcard matching
178        if let Some(prefix) = pattern.strip_suffix('*') {
179            return path.starts_with(prefix);
180        }
181
182        false
183    }
184
185    /// Get the base URL from the spec (if available)
186    pub fn get_base_url(&self) -> Option<String> {
187        self.spec.servers.first().map(|server| server.url.clone())
188    }
189
190    /// Get API info
191    pub fn get_info(&self) -> (String, String) {
192        (self.spec.info.title.clone(), self.spec.info.version.clone())
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use openapiv3::Operation;
200
201    /// Helper to create test operations
202    fn create_test_operation(method: &str, path: &str, operation_id: Option<&str>) -> ApiOperation {
203        ApiOperation {
204            method: method.to_string(),
205            path: path.to_string(),
206            operation: Operation::default(),
207            operation_id: operation_id.map(|s| s.to_string()),
208        }
209    }
210
211    #[test]
212    fn test_matches_path() {
213        assert!(SpecParser::matches_path("/users", "/users"));
214        assert!(SpecParser::matches_path("/users/123", "/users/*"));
215        assert!(!SpecParser::matches_path("/posts", "/users"));
216    }
217
218    #[test]
219    fn test_exclude_operations_by_method() {
220        // Create a mock parser (we'll test the exclude_operations method directly)
221        let operations = vec![
222            create_test_operation("get", "/users", Some("getUsers")),
223            create_test_operation("post", "/users", Some("createUser")),
224            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
225            create_test_operation("get", "/posts", Some("getPosts")),
226            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
227        ];
228
229        // Test excluding all DELETE operations
230        let spec = openapiv3::OpenAPI::default();
231        let parser = SpecParser { spec };
232        let result = parser.exclude_operations(operations.clone(), "DELETE").unwrap();
233
234        assert_eq!(result.len(), 3);
235        assert!(result.iter().all(|op| op.method.to_uppercase() != "DELETE"));
236    }
237
238    #[test]
239    fn test_exclude_operations_by_method_and_path() {
240        let operations = vec![
241            create_test_operation("get", "/users", Some("getUsers")),
242            create_test_operation("post", "/users", Some("createUser")),
243            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
244            create_test_operation("get", "/posts", Some("getPosts")),
245            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
246        ];
247
248        let spec = openapiv3::OpenAPI::default();
249        let parser = SpecParser { spec };
250
251        // Test excluding specific DELETE operation by path
252        let result = parser.exclude_operations(operations.clone(), "DELETE /users/{id}").unwrap();
253
254        assert_eq!(result.len(), 4);
255        // Should still have deletePost
256        assert!(result.iter().any(|op| op.operation_id == Some("deletePost".to_string())));
257        // Should not have deleteUser
258        assert!(!result.iter().any(|op| op.operation_id == Some("deleteUser".to_string())));
259    }
260
261    #[test]
262    fn test_exclude_operations_multiple_methods() {
263        let operations = vec![
264            create_test_operation("get", "/users", Some("getUsers")),
265            create_test_operation("post", "/users", Some("createUser")),
266            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
267            create_test_operation("put", "/users/{id}", Some("updateUser")),
268        ];
269
270        let spec = openapiv3::OpenAPI::default();
271        let parser = SpecParser { spec };
272
273        // Test excluding DELETE and POST
274        let result = parser.exclude_operations(operations.clone(), "DELETE,POST").unwrap();
275
276        assert_eq!(result.len(), 2);
277        assert!(result.iter().all(|op| op.method.to_uppercase() != "DELETE"));
278        assert!(result.iter().all(|op| op.method.to_uppercase() != "POST"));
279    }
280
281    #[test]
282    fn test_exclude_operations_empty_string() {
283        let operations = vec![
284            create_test_operation("get", "/users", Some("getUsers")),
285            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
286        ];
287
288        let spec = openapiv3::OpenAPI::default();
289        let parser = SpecParser { spec };
290
291        // Empty string should return all operations
292        let result = parser.exclude_operations(operations.clone(), "").unwrap();
293
294        assert_eq!(result.len(), 2);
295    }
296
297    #[test]
298    fn test_exclude_operations_with_wildcard() {
299        let operations = vec![
300            create_test_operation("get", "/users", Some("getUsers")),
301            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
302            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
303        ];
304
305        let spec = openapiv3::OpenAPI::default();
306        let parser = SpecParser { spec };
307
308        // Test excluding DELETE operations matching /users/*
309        let result = parser.exclude_operations(operations.clone(), "DELETE /users/*").unwrap();
310
311        assert_eq!(result.len(), 2);
312        // Should still have deletePost
313        assert!(result.iter().any(|op| op.operation_id == Some("deletePost".to_string())));
314        // Should not have deleteUser
315        assert!(!result.iter().any(|op| op.operation_id == Some("deleteUser".to_string())));
316    }
317}