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, 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    /// Extract the base path from the spec's servers URL
196    ///
197    /// This parses the first server URL and extracts the path component.
198    /// For example:
199    /// - "https://api.example.com/api/v1" -> Some("/api/v1")
200    /// - "https://api.example.com" -> None
201    /// - "/api/v1" -> Some("/api/v1")
202    ///
203    /// Returns None if there are no servers or the path is just "/".
204    pub fn get_base_path(&self) -> Option<String> {
205        let server_url = self.spec.servers.first().map(|s| &s.url)?;
206
207        // Handle relative paths directly (e.g., "/api/v1")
208        if server_url.starts_with('/') {
209            let path = server_url.trim_end_matches('/');
210            if !path.is_empty() && path != "/" {
211                return Some(path.to_string());
212            }
213            return None;
214        }
215
216        // Parse as URL to extract path component
217        if let Ok(parsed) = url::Url::parse(server_url) {
218            let path = parsed.path().trim_end_matches('/');
219            if !path.is_empty() && path != "/" {
220                return Some(path.to_string());
221            }
222        }
223
224        None
225    }
226
227    /// Get API info
228    pub fn get_info(&self) -> (String, String) {
229        (self.spec.info.title.clone(), self.spec.info.version.clone())
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use openapiv3::Operation;
237
238    /// Helper to create test operations
239    fn create_test_operation(method: &str, path: &str, operation_id: Option<&str>) -> ApiOperation {
240        ApiOperation {
241            method: method.to_string(),
242            path: path.to_string(),
243            operation: Operation::default(),
244            operation_id: operation_id.map(|s| s.to_string()),
245        }
246    }
247
248    #[test]
249    fn test_matches_path() {
250        assert!(SpecParser::matches_path("/users", "/users"));
251        assert!(SpecParser::matches_path("/users/123", "/users/*"));
252        assert!(!SpecParser::matches_path("/posts", "/users"));
253    }
254
255    #[test]
256    fn test_exclude_operations_by_method() {
257        // Create a mock parser (we'll test the exclude_operations method directly)
258        let operations = vec![
259            create_test_operation("get", "/users", Some("getUsers")),
260            create_test_operation("post", "/users", Some("createUser")),
261            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
262            create_test_operation("get", "/posts", Some("getPosts")),
263            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
264        ];
265
266        // Test excluding all DELETE operations
267        let spec = openapiv3::OpenAPI::default();
268        let parser = SpecParser { spec };
269        let result = parser.exclude_operations(operations.clone(), "DELETE").unwrap();
270
271        assert_eq!(result.len(), 3);
272        assert!(result.iter().all(|op| op.method.to_uppercase() != "DELETE"));
273    }
274
275    #[test]
276    fn test_exclude_operations_by_method_and_path() {
277        let operations = vec![
278            create_test_operation("get", "/users", Some("getUsers")),
279            create_test_operation("post", "/users", Some("createUser")),
280            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
281            create_test_operation("get", "/posts", Some("getPosts")),
282            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
283        ];
284
285        let spec = openapiv3::OpenAPI::default();
286        let parser = SpecParser { spec };
287
288        // Test excluding specific DELETE operation by path
289        let result = parser.exclude_operations(operations.clone(), "DELETE /users/{id}").unwrap();
290
291        assert_eq!(result.len(), 4);
292        // Should still have deletePost
293        assert!(result.iter().any(|op| op.operation_id == Some("deletePost".to_string())));
294        // Should not have deleteUser
295        assert!(!result.iter().any(|op| op.operation_id == Some("deleteUser".to_string())));
296    }
297
298    #[test]
299    fn test_exclude_operations_multiple_methods() {
300        let operations = vec![
301            create_test_operation("get", "/users", Some("getUsers")),
302            create_test_operation("post", "/users", Some("createUser")),
303            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
304            create_test_operation("put", "/users/{id}", Some("updateUser")),
305        ];
306
307        let spec = openapiv3::OpenAPI::default();
308        let parser = SpecParser { spec };
309
310        // Test excluding DELETE and POST
311        let result = parser.exclude_operations(operations.clone(), "DELETE,POST").unwrap();
312
313        assert_eq!(result.len(), 2);
314        assert!(result.iter().all(|op| op.method.to_uppercase() != "DELETE"));
315        assert!(result.iter().all(|op| op.method.to_uppercase() != "POST"));
316    }
317
318    #[test]
319    fn test_exclude_operations_empty_string() {
320        let operations = vec![
321            create_test_operation("get", "/users", Some("getUsers")),
322            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
323        ];
324
325        let spec = openapiv3::OpenAPI::default();
326        let parser = SpecParser { spec };
327
328        // Empty string should return all operations
329        let result = parser.exclude_operations(operations.clone(), "").unwrap();
330
331        assert_eq!(result.len(), 2);
332    }
333
334    #[test]
335    fn test_exclude_operations_with_wildcard() {
336        let operations = vec![
337            create_test_operation("get", "/users", Some("getUsers")),
338            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
339            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
340        ];
341
342        let spec = openapiv3::OpenAPI::default();
343        let parser = SpecParser { spec };
344
345        // Test excluding DELETE operations matching /users/*
346        let result = parser.exclude_operations(operations.clone(), "DELETE /users/*").unwrap();
347
348        assert_eq!(result.len(), 2);
349        // Should still have deletePost
350        assert!(result.iter().any(|op| op.operation_id == Some("deletePost".to_string())));
351        // Should not have deleteUser
352        assert!(!result.iter().any(|op| op.operation_id == Some("deleteUser".to_string())));
353    }
354
355    #[test]
356    fn test_get_base_path_with_full_url() {
357        let mut spec = openapiv3::OpenAPI::default();
358        spec.servers.push(openapiv3::Server {
359            url: "https://api.example.com/api/v1".to_string(),
360            description: None,
361            variables: Default::default(),
362            extensions: Default::default(),
363        });
364
365        let parser = SpecParser { spec };
366        let base_path = parser.get_base_path();
367
368        assert_eq!(base_path, Some("/api/v1".to_string()));
369    }
370
371    #[test]
372    fn test_get_base_path_with_relative_path() {
373        let mut spec = openapiv3::OpenAPI::default();
374        spec.servers.push(openapiv3::Server {
375            url: "/api/v2".to_string(),
376            description: None,
377            variables: Default::default(),
378            extensions: Default::default(),
379        });
380
381        let parser = SpecParser { spec };
382        let base_path = parser.get_base_path();
383
384        assert_eq!(base_path, Some("/api/v2".to_string()));
385    }
386
387    #[test]
388    fn test_get_base_path_no_path_in_url() {
389        let mut spec = openapiv3::OpenAPI::default();
390        spec.servers.push(openapiv3::Server {
391            url: "https://api.example.com".to_string(),
392            description: None,
393            variables: Default::default(),
394            extensions: Default::default(),
395        });
396
397        let parser = SpecParser { spec };
398        let base_path = parser.get_base_path();
399
400        assert_eq!(base_path, None);
401    }
402
403    #[test]
404    fn test_get_base_path_no_servers() {
405        let spec = openapiv3::OpenAPI::default();
406        let parser = SpecParser { spec };
407        let base_path = parser.get_base_path();
408
409        assert_eq!(base_path, None);
410    }
411
412    #[test]
413    fn test_get_base_path_trailing_slash_removed() {
414        let mut spec = openapiv3::OpenAPI::default();
415        spec.servers.push(openapiv3::Server {
416            url: "https://api.example.com/api/v1/".to_string(),
417            description: None,
418            variables: Default::default(),
419            extensions: Default::default(),
420        });
421
422        let parser = SpecParser { spec };
423        let base_path = parser.get_base_path();
424
425        // Trailing slash should be removed
426        assert_eq!(base_path, Some("/api/v1".to_string()));
427    }
428}