1use crate::error::{BenchError, Result};
4use mockforge_openapi::spec::OpenApiSpec;
5use openapiv3::{OpenAPI, Operation, Parameter, PathItem, ReferenceOr};
6use std::path::Path;
7
8#[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 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
26pub struct SpecParser {
28 spec: OpenAPI,
29}
30
31impl SpecParser {
32 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 pub fn from_spec(spec: OpenApiSpec) -> Self {
45 Self { spec: spec.spec }
46 }
47
48 pub fn spec(&self) -> &OpenAPI {
50 &self.spec
51 }
52
53 pub fn get_operations(&self) -> Vec<ApiOperation> {
55 let mut operations = Vec::new();
56
57 for (path, path_item) in &self.spec.paths.paths {
58 if let ReferenceOr::Item(item) = path_item {
59 self.extract_operations_from_path(path, item, &mut operations);
60 }
61 }
62
63 operations
64 }
65
66 pub fn filter_operations(&self, filter: &str) -> Result<Vec<ApiOperation>> {
68 let all_ops = self.get_operations();
69
70 if filter.is_empty() {
71 return Ok(all_ops);
72 }
73
74 let filters: Vec<&str> = filter.split(',').map(|s| s.trim()).collect();
75 let mut filtered = Vec::new();
76
77 for filter_str in filters {
78 let parts: Vec<&str> = filter_str.splitn(2, ' ').collect();
82 let (method, path_pattern) = if parts.len() == 2 {
83 (parts[0].to_uppercase(), Some(parts[1]))
84 } else if parts.len() == 1 && !parts[0].is_empty() {
85 (parts[0].to_uppercase(), None)
86 } else {
87 return Err(BenchError::Other(format!(
88 "Invalid operation filter format: '{}'. Expected 'METHOD /path' or 'METHOD'",
89 filter_str
90 )));
91 };
92
93 for op in &all_ops {
94 let method_matches = op.method.to_uppercase() == method;
95 let path_matches =
96 path_pattern.map(|p| Self::matches_path(&op.path, p)).unwrap_or(true);
97 if method_matches && path_matches {
98 filtered.push(op.clone());
99 }
100 }
101 }
102
103 if filtered.is_empty() {
104 return Err(BenchError::OperationNotFound(filter.to_string()));
105 }
106
107 Ok(filtered)
108 }
109
110 pub fn exclude_operations(
115 &self,
116 operations: Vec<ApiOperation>,
117 exclude: &str,
118 ) -> Result<Vec<ApiOperation>> {
119 if exclude.is_empty() {
120 return Ok(operations);
121 }
122
123 let exclusions: Vec<&str> = exclude.split(',').map(|s| s.trim()).collect();
124 let mut result = Vec::new();
125
126 for op in operations {
127 let mut should_exclude = false;
128
129 for exclude_str in &exclusions {
130 let parts: Vec<&str> = exclude_str.splitn(2, ' ').collect();
132
133 let (method, path_pattern) = if parts.len() == 2 {
134 (parts[0].to_uppercase(), Some(parts[1]))
135 } else {
136 (parts[0].to_uppercase(), None)
138 };
139
140 let method_matches = op.method.to_uppercase() == method;
141 let path_matches =
142 path_pattern.map(|p| Self::matches_path(&op.path, p)).unwrap_or(true); if method_matches && path_matches {
145 should_exclude = true;
146 break;
147 }
148 }
149
150 if !should_exclude {
151 result.push(op);
152 }
153 }
154
155 Ok(result)
156 }
157
158 fn extract_operations_from_path(
160 &self,
161 path: &str,
162 path_item: &PathItem,
163 operations: &mut Vec<ApiOperation>,
164 ) {
165 let path_level_params: Vec<ReferenceOr<Parameter>> = path_item
167 .parameters
168 .iter()
169 .filter_map(|p| self.resolve_parameter(p).map(ReferenceOr::Item))
170 .collect();
171
172 let method_ops = vec![
173 ("get", &path_item.get),
174 ("post", &path_item.post),
175 ("put", &path_item.put),
176 ("delete", &path_item.delete),
177 ("patch", &path_item.patch),
178 ("head", &path_item.head),
179 ("options", &path_item.options),
180 ];
181
182 for (method, op_ref) in method_ops {
183 if let Some(op) = op_ref {
184 let mut resolved_op = op.clone();
186 let mut resolved_params: Vec<ReferenceOr<Parameter>> = Vec::new();
187
188 resolved_params.extend(path_level_params.clone());
190
191 for param_ref in &op.parameters {
193 if let Some(resolved) = self.resolve_parameter(param_ref) {
194 resolved_params.push(ReferenceOr::Item(resolved));
195 }
196 }
197
198 resolved_op.parameters = resolved_params;
199
200 operations.push(ApiOperation {
201 method: method.to_string(),
202 path: as_url_path(path),
203 operation: resolved_op,
204 operation_id: op.operation_id.clone(),
205 });
206 }
207 }
208 }
209
210 fn resolve_parameter(&self, param_ref: &ReferenceOr<Parameter>) -> Option<Parameter> {
212 match param_ref {
213 ReferenceOr::Item(param) => Some(param.clone()),
214 ReferenceOr::Reference { reference } => {
215 let parts: Vec<&str> = reference.split('/').collect();
217 if parts.len() >= 4 && parts[1] == "components" && parts[2] == "parameters" {
218 let param_name = parts[3];
219 if let Some(components) = &self.spec.components {
220 if let ReferenceOr::Item(param) = components.parameters.get(param_name)? {
221 return Some(param.clone());
222 }
223 }
224 }
225 None
226 }
227 }
228 }
229
230 fn matches_path(path: &str, pattern: &str) -> bool {
232 if pattern == path {
233 return true;
234 }
235
236 if let Some(prefix) = pattern.strip_suffix('*') {
238 return path.starts_with(prefix);
239 }
240
241 false
242 }
243
244 pub fn get_base_url(&self) -> Option<String> {
246 self.spec.servers.first().map(|server| server.url.clone())
247 }
248
249 pub fn get_base_path(&self) -> Option<String> {
259 let server_url = self.spec.servers.first().map(|s| &s.url)?;
260
261 if server_url.starts_with('/') {
263 let path = server_url.trim_end_matches('/');
264 if !path.is_empty() && path != "/" {
265 return Some(path.to_string());
266 }
267 return None;
268 }
269
270 if let Ok(parsed) = url::Url::parse(server_url) {
272 let path = parsed.path().trim_end_matches('/');
273 if !path.is_empty() && path != "/" {
274 return Some(path.to_string());
275 }
276 }
277
278 None
279 }
280
281 pub fn get_info(&self) -> (String, String) {
283 (self.spec.info.title.clone(), self.spec.info.version.clone())
284 }
285}
286
287fn as_url_path(path: &str) -> String {
293 if path.starts_with('/') {
294 path.to_string()
295 } else {
296 format!("/{path}")
297 }
298}
299
300#[cfg(test)]
301mod tests {
302
303 #[test]
304 fn spec_path_keys_always_become_url_paths() {
305 assert_eq!(as_url_path("/pets"), "/pets");
306 assert_eq!(as_url_path("pets"), "/pets");
307 assert_eq!(as_url_path("@localhost:6565/x"), "/@localhost:6565/x");
308 }
309 use super::*;
310 use openapiv3::Operation;
311
312 fn create_test_operation(method: &str, path: &str, operation_id: Option<&str>) -> ApiOperation {
314 ApiOperation {
315 method: method.to_string(),
316 path: path.to_string(),
317 operation: Operation::default(),
318 operation_id: operation_id.map(|s| s.to_string()),
319 }
320 }
321
322 #[test]
323 fn test_matches_path() {
324 assert!(SpecParser::matches_path("/users", "/users"));
325 assert!(SpecParser::matches_path("/users/123", "/users/*"));
326 assert!(!SpecParser::matches_path("/posts", "/users"));
327 }
328
329 #[test]
330 fn test_exclude_operations_by_method() {
331 let operations = vec![
333 create_test_operation("get", "/users", Some("getUsers")),
334 create_test_operation("post", "/users", Some("createUser")),
335 create_test_operation("delete", "/users/{id}", Some("deleteUser")),
336 create_test_operation("get", "/posts", Some("getPosts")),
337 create_test_operation("delete", "/posts/{id}", Some("deletePost")),
338 ];
339
340 let spec = OpenAPI::default();
342 let parser = SpecParser { spec };
343 let result = parser.exclude_operations(operations.clone(), "DELETE").unwrap();
344
345 assert_eq!(result.len(), 3);
346 assert!(result.iter().all(|op| op.method.to_uppercase() != "DELETE"));
347 }
348
349 #[test]
350 fn test_exclude_operations_by_method_and_path() {
351 let operations = vec![
352 create_test_operation("get", "/users", Some("getUsers")),
353 create_test_operation("post", "/users", Some("createUser")),
354 create_test_operation("delete", "/users/{id}", Some("deleteUser")),
355 create_test_operation("get", "/posts", Some("getPosts")),
356 create_test_operation("delete", "/posts/{id}", Some("deletePost")),
357 ];
358
359 let spec = OpenAPI::default();
360 let parser = SpecParser { spec };
361
362 let result = parser.exclude_operations(operations.clone(), "DELETE /users/{id}").unwrap();
364
365 assert_eq!(result.len(), 4);
366 assert!(result.iter().any(|op| op.operation_id == Some("deletePost".to_string())));
368 assert!(!result.iter().any(|op| op.operation_id == Some("deleteUser".to_string())));
370 }
371
372 #[test]
373 fn test_exclude_operations_multiple_methods() {
374 let operations = vec![
375 create_test_operation("get", "/users", Some("getUsers")),
376 create_test_operation("post", "/users", Some("createUser")),
377 create_test_operation("delete", "/users/{id}", Some("deleteUser")),
378 create_test_operation("put", "/users/{id}", Some("updateUser")),
379 ];
380
381 let spec = OpenAPI::default();
382 let parser = SpecParser { spec };
383
384 let result = parser.exclude_operations(operations.clone(), "DELETE,POST").unwrap();
386
387 assert_eq!(result.len(), 2);
388 assert!(result.iter().all(|op| op.method.to_uppercase() != "DELETE"));
389 assert!(result.iter().all(|op| op.method.to_uppercase() != "POST"));
390 }
391
392 #[test]
393 fn test_exclude_operations_empty_string() {
394 let operations = vec![
395 create_test_operation("get", "/users", Some("getUsers")),
396 create_test_operation("delete", "/users/{id}", Some("deleteUser")),
397 ];
398
399 let spec = OpenAPI::default();
400 let parser = SpecParser { spec };
401
402 let result = parser.exclude_operations(operations.clone(), "").unwrap();
404
405 assert_eq!(result.len(), 2);
406 }
407
408 #[test]
409 fn test_exclude_operations_with_wildcard() {
410 let operations = vec![
411 create_test_operation("get", "/users", Some("getUsers")),
412 create_test_operation("delete", "/users/{id}", Some("deleteUser")),
413 create_test_operation("delete", "/posts/{id}", Some("deletePost")),
414 ];
415
416 let spec = OpenAPI::default();
417 let parser = SpecParser { spec };
418
419 let result = parser.exclude_operations(operations.clone(), "DELETE /users/*").unwrap();
421
422 assert_eq!(result.len(), 2);
423 assert!(result.iter().any(|op| op.operation_id == Some("deletePost".to_string())));
425 assert!(!result.iter().any(|op| op.operation_id == Some("deleteUser".to_string())));
427 }
428
429 #[test]
430 fn test_get_base_path_with_full_url() {
431 let mut spec = OpenAPI::default();
432 spec.servers.push(openapiv3::Server {
433 url: "https://api.example.com/api/v1".to_string(),
434 description: None,
435 variables: Default::default(),
436 extensions: Default::default(),
437 });
438
439 let parser = SpecParser { spec };
440 let base_path = parser.get_base_path();
441
442 assert_eq!(base_path, Some("/api/v1".to_string()));
443 }
444
445 #[test]
446 fn test_get_base_path_with_relative_path() {
447 let mut spec = OpenAPI::default();
448 spec.servers.push(openapiv3::Server {
449 url: "/api/v2".to_string(),
450 description: None,
451 variables: Default::default(),
452 extensions: Default::default(),
453 });
454
455 let parser = SpecParser { spec };
456 let base_path = parser.get_base_path();
457
458 assert_eq!(base_path, Some("/api/v2".to_string()));
459 }
460
461 #[test]
462 fn test_get_base_path_no_path_in_url() {
463 let mut spec = OpenAPI::default();
464 spec.servers.push(openapiv3::Server {
465 url: "https://api.example.com".to_string(),
466 description: None,
467 variables: Default::default(),
468 extensions: Default::default(),
469 });
470
471 let parser = SpecParser { spec };
472 let base_path = parser.get_base_path();
473
474 assert_eq!(base_path, None);
475 }
476
477 #[test]
478 fn test_get_base_path_no_servers() {
479 let spec = OpenAPI::default();
480 let parser = SpecParser { spec };
481 let base_path = parser.get_base_path();
482
483 assert_eq!(base_path, None);
484 }
485
486 #[test]
487 fn test_get_base_path_trailing_slash_removed() {
488 let mut spec = OpenAPI::default();
489 spec.servers.push(openapiv3::Server {
490 url: "https://api.example.com/api/v1/".to_string(),
491 description: None,
492 variables: Default::default(),
493 extensions: Default::default(),
494 });
495
496 let parser = SpecParser { spec };
497 let base_path = parser.get_base_path();
498
499 assert_eq!(base_path, Some("/api/v1".to_string()));
501 }
502}