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    /// Extract operations from a path item
95    fn extract_operations_from_path(
96        &self,
97        path: &str,
98        path_item: &PathItem,
99        operations: &mut Vec<ApiOperation>,
100    ) {
101        let method_ops = vec![
102            ("get", &path_item.get),
103            ("post", &path_item.post),
104            ("put", &path_item.put),
105            ("delete", &path_item.delete),
106            ("patch", &path_item.patch),
107            ("head", &path_item.head),
108            ("options", &path_item.options),
109        ];
110
111        for (method, op_ref) in method_ops {
112            if let Some(op) = op_ref {
113                operations.push(ApiOperation {
114                    method: method.to_string(),
115                    path: path.to_string(),
116                    operation: op.clone(),
117                    operation_id: op.operation_id.clone(),
118                });
119            }
120        }
121    }
122
123    /// Check if a path matches a pattern (supports wildcards)
124    fn matches_path(path: &str, pattern: &str) -> bool {
125        if pattern == path {
126            return true;
127        }
128
129        // Simple wildcard matching
130        if let Some(prefix) = pattern.strip_suffix('*') {
131            return path.starts_with(prefix);
132        }
133
134        false
135    }
136
137    /// Get the base URL from the spec (if available)
138    pub fn get_base_url(&self) -> Option<String> {
139        self.spec.servers.first().map(|server| server.url.clone())
140    }
141
142    /// Get API info
143    pub fn get_info(&self) -> (String, String) {
144        (self.spec.info.title.clone(), self.spec.info.version.clone())
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_matches_path() {
154        assert!(SpecParser::matches_path("/users", "/users"));
155        assert!(SpecParser::matches_path("/users/123", "/users/*"));
156        assert!(!SpecParser::matches_path("/posts", "/users"));
157    }
158}