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 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        // Resolve path-level parameters (apply to all operations under this path)
155        let path_level_params: Vec<ReferenceOr<Parameter>> = path_item
156            .parameters
157            .iter()
158            .filter_map(|p| self.resolve_parameter(p).map(ReferenceOr::Item))
159            .collect();
160
161        let method_ops = vec![
162            ("get", &path_item.get),
163            ("post", &path_item.post),
164            ("put", &path_item.put),
165            ("delete", &path_item.delete),
166            ("patch", &path_item.patch),
167            ("head", &path_item.head),
168            ("options", &path_item.options),
169        ];
170
171        for (method, op_ref) in method_ops {
172            if let Some(op) = op_ref {
173                // Resolve operation-level $ref parameters and merge with path-level params
174                let mut resolved_op = op.clone();
175                let mut resolved_params: Vec<ReferenceOr<Parameter>> = Vec::new();
176
177                // Start with path-level params
178                resolved_params.extend(path_level_params.clone());
179
180                // Add operation-level params (override path-level for same name)
181                for param_ref in &op.parameters {
182                    if let Some(resolved) = self.resolve_parameter(param_ref) {
183                        resolved_params.push(ReferenceOr::Item(resolved));
184                    }
185                }
186
187                resolved_op.parameters = resolved_params;
188
189                operations.push(ApiOperation {
190                    method: method.to_string(),
191                    path: path.to_string(),
192                    operation: resolved_op,
193                    operation_id: op.operation_id.clone(),
194                });
195            }
196        }
197    }
198
199    /// Resolve a parameter reference to its concrete definition
200    fn resolve_parameter(&self, param_ref: &ReferenceOr<Parameter>) -> Option<Parameter> {
201        match param_ref {
202            ReferenceOr::Item(param) => Some(param.clone()),
203            ReferenceOr::Reference { reference } => {
204                // Parse reference like "#/components/parameters/id"
205                let parts: Vec<&str> = reference.split('/').collect();
206                if parts.len() >= 4 && parts[1] == "components" && parts[2] == "parameters" {
207                    let param_name = parts[3];
208                    if let Some(components) = &self.spec.components {
209                        if let ReferenceOr::Item(param) = components.parameters.get(param_name)? {
210                            return Some(param.clone());
211                        }
212                    }
213                }
214                None
215            }
216        }
217    }
218
219    /// Check if a path matches a pattern (supports wildcards)
220    fn matches_path(path: &str, pattern: &str) -> bool {
221        if pattern == path {
222            return true;
223        }
224
225        // Simple wildcard matching
226        if let Some(prefix) = pattern.strip_suffix('*') {
227            return path.starts_with(prefix);
228        }
229
230        false
231    }
232
233    /// Get the base URL from the spec (if available)
234    pub fn get_base_url(&self) -> Option<String> {
235        self.spec.servers.first().map(|server| server.url.clone())
236    }
237
238    /// Extract the base path from the spec's servers URL
239    ///
240    /// This parses the first server URL and extracts the path component.
241    /// For example:
242    /// - "https://api.example.com/api/v1" -> Some("/api/v1")
243    /// - "https://api.example.com" -> None
244    /// - "/api/v1" -> Some("/api/v1")
245    ///
246    /// Returns None if there are no servers or the path is just "/".
247    pub fn get_base_path(&self) -> Option<String> {
248        let server_url = self.spec.servers.first().map(|s| &s.url)?;
249
250        // Handle relative paths directly (e.g., "/api/v1")
251        if server_url.starts_with('/') {
252            let path = server_url.trim_end_matches('/');
253            if !path.is_empty() && path != "/" {
254                return Some(path.to_string());
255            }
256            return None;
257        }
258
259        // Parse as URL to extract path component
260        if let Ok(parsed) = url::Url::parse(server_url) {
261            let path = parsed.path().trim_end_matches('/');
262            if !path.is_empty() && path != "/" {
263                return Some(path.to_string());
264            }
265        }
266
267        None
268    }
269
270    /// Get API info
271    pub fn get_info(&self) -> (String, String) {
272        (self.spec.info.title.clone(), self.spec.info.version.clone())
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use openapiv3::Operation;
280
281    /// Helper to create test operations
282    fn create_test_operation(method: &str, path: &str, operation_id: Option<&str>) -> ApiOperation {
283        ApiOperation {
284            method: method.to_string(),
285            path: path.to_string(),
286            operation: Operation::default(),
287            operation_id: operation_id.map(|s| s.to_string()),
288        }
289    }
290
291    #[test]
292    fn test_matches_path() {
293        assert!(SpecParser::matches_path("/users", "/users"));
294        assert!(SpecParser::matches_path("/users/123", "/users/*"));
295        assert!(!SpecParser::matches_path("/posts", "/users"));
296    }
297
298    #[test]
299    fn test_exclude_operations_by_method() {
300        // Create a mock parser (we'll test the exclude_operations method directly)
301        let operations = vec![
302            create_test_operation("get", "/users", Some("getUsers")),
303            create_test_operation("post", "/users", Some("createUser")),
304            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
305            create_test_operation("get", "/posts", Some("getPosts")),
306            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
307        ];
308
309        // Test excluding all DELETE operations
310        let spec = openapiv3::OpenAPI::default();
311        let parser = SpecParser { spec };
312        let result = parser.exclude_operations(operations.clone(), "DELETE").unwrap();
313
314        assert_eq!(result.len(), 3);
315        assert!(result.iter().all(|op| op.method.to_uppercase() != "DELETE"));
316    }
317
318    #[test]
319    fn test_exclude_operations_by_method_and_path() {
320        let operations = vec![
321            create_test_operation("get", "/users", Some("getUsers")),
322            create_test_operation("post", "/users", Some("createUser")),
323            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
324            create_test_operation("get", "/posts", Some("getPosts")),
325            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
326        ];
327
328        let spec = openapiv3::OpenAPI::default();
329        let parser = SpecParser { spec };
330
331        // Test excluding specific DELETE operation by path
332        let result = parser.exclude_operations(operations.clone(), "DELETE /users/{id}").unwrap();
333
334        assert_eq!(result.len(), 4);
335        // Should still have deletePost
336        assert!(result.iter().any(|op| op.operation_id == Some("deletePost".to_string())));
337        // Should not have deleteUser
338        assert!(!result.iter().any(|op| op.operation_id == Some("deleteUser".to_string())));
339    }
340
341    #[test]
342    fn test_exclude_operations_multiple_methods() {
343        let operations = vec![
344            create_test_operation("get", "/users", Some("getUsers")),
345            create_test_operation("post", "/users", Some("createUser")),
346            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
347            create_test_operation("put", "/users/{id}", Some("updateUser")),
348        ];
349
350        let spec = openapiv3::OpenAPI::default();
351        let parser = SpecParser { spec };
352
353        // Test excluding DELETE and POST
354        let result = parser.exclude_operations(operations.clone(), "DELETE,POST").unwrap();
355
356        assert_eq!(result.len(), 2);
357        assert!(result.iter().all(|op| op.method.to_uppercase() != "DELETE"));
358        assert!(result.iter().all(|op| op.method.to_uppercase() != "POST"));
359    }
360
361    #[test]
362    fn test_exclude_operations_empty_string() {
363        let operations = vec![
364            create_test_operation("get", "/users", Some("getUsers")),
365            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
366        ];
367
368        let spec = openapiv3::OpenAPI::default();
369        let parser = SpecParser { spec };
370
371        // Empty string should return all operations
372        let result = parser.exclude_operations(operations.clone(), "").unwrap();
373
374        assert_eq!(result.len(), 2);
375    }
376
377    #[test]
378    fn test_exclude_operations_with_wildcard() {
379        let operations = vec![
380            create_test_operation("get", "/users", Some("getUsers")),
381            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
382            create_test_operation("delete", "/posts/{id}", Some("deletePost")),
383        ];
384
385        let spec = openapiv3::OpenAPI::default();
386        let parser = SpecParser { spec };
387
388        // Test excluding DELETE operations matching /users/*
389        let result = parser.exclude_operations(operations.clone(), "DELETE /users/*").unwrap();
390
391        assert_eq!(result.len(), 2);
392        // Should still have deletePost
393        assert!(result.iter().any(|op| op.operation_id == Some("deletePost".to_string())));
394        // Should not have deleteUser
395        assert!(!result.iter().any(|op| op.operation_id == Some("deleteUser".to_string())));
396    }
397
398    #[test]
399    fn test_get_base_path_with_full_url() {
400        let mut spec = openapiv3::OpenAPI::default();
401        spec.servers.push(openapiv3::Server {
402            url: "https://api.example.com/api/v1".to_string(),
403            description: None,
404            variables: Default::default(),
405            extensions: Default::default(),
406        });
407
408        let parser = SpecParser { spec };
409        let base_path = parser.get_base_path();
410
411        assert_eq!(base_path, Some("/api/v1".to_string()));
412    }
413
414    #[test]
415    fn test_get_base_path_with_relative_path() {
416        let mut spec = openapiv3::OpenAPI::default();
417        spec.servers.push(openapiv3::Server {
418            url: "/api/v2".to_string(),
419            description: None,
420            variables: Default::default(),
421            extensions: Default::default(),
422        });
423
424        let parser = SpecParser { spec };
425        let base_path = parser.get_base_path();
426
427        assert_eq!(base_path, Some("/api/v2".to_string()));
428    }
429
430    #[test]
431    fn test_get_base_path_no_path_in_url() {
432        let mut spec = openapiv3::OpenAPI::default();
433        spec.servers.push(openapiv3::Server {
434            url: "https://api.example.com".to_string(),
435            description: None,
436            variables: Default::default(),
437            extensions: Default::default(),
438        });
439
440        let parser = SpecParser { spec };
441        let base_path = parser.get_base_path();
442
443        assert_eq!(base_path, None);
444    }
445
446    #[test]
447    fn test_get_base_path_no_servers() {
448        let spec = openapiv3::OpenAPI::default();
449        let parser = SpecParser { spec };
450        let base_path = parser.get_base_path();
451
452        assert_eq!(base_path, None);
453    }
454
455    #[test]
456    fn test_get_base_path_trailing_slash_removed() {
457        let mut spec = openapiv3::OpenAPI::default();
458        spec.servers.push(openapiv3::Server {
459            url: "https://api.example.com/api/v1/".to_string(),
460            description: None,
461            variables: Default::default(),
462            extensions: Default::default(),
463        });
464
465        let parser = SpecParser { spec };
466        let base_path = parser.get_base_path();
467
468        // Trailing slash should be removed
469        assert_eq!(base_path, Some("/api/v1".to_string()));
470    }
471}