1use crate::error::{BenchError, Result};
8use crate::spec_parser::ApiOperation;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::path::Path;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct FlowStep {
16 pub operation: String,
18 #[serde(default)]
20 pub extract: Vec<String>,
21 #[serde(default)]
24 pub use_values: HashMap<String, String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub description: Option<String>,
28}
29
30impl FlowStep {
31 pub fn new(operation: String) -> Self {
33 Self {
34 operation,
35 extract: Vec::new(),
36 use_values: HashMap::new(),
37 description: None,
38 }
39 }
40
41 pub fn with_extract(mut self, fields: Vec<String>) -> Self {
43 self.extract = fields;
44 self
45 }
46
47 pub fn with_values(mut self, values: HashMap<String, String>) -> Self {
49 self.use_values = values;
50 self
51 }
52
53 pub fn with_description(mut self, description: String) -> Self {
55 self.description = Some(description);
56 self
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct CrudFlow {
63 pub name: String,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub base_path: Option<String>,
68 pub steps: Vec<FlowStep>,
70}
71
72impl CrudFlow {
73 pub fn new(name: String) -> Self {
75 Self {
76 name,
77 base_path: None,
78 steps: Vec::new(),
79 }
80 }
81
82 pub fn with_base_path(mut self, path: String) -> Self {
84 self.base_path = Some(path);
85 self
86 }
87
88 pub fn add_step(&mut self, step: FlowStep) {
90 self.steps.push(step);
91 }
92
93 pub fn get_all_extract_fields(&self) -> HashSet<String> {
95 self.steps.iter().flat_map(|step| step.extract.iter().cloned()).collect()
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct CrudFlowConfig {
102 pub flows: Vec<CrudFlow>,
104 #[serde(default)]
106 pub default_extract_fields: Vec<String>,
107}
108
109impl Default for CrudFlowConfig {
110 fn default() -> Self {
111 Self {
112 flows: Vec::new(),
113 default_extract_fields: vec!["id".to_string(), "uuid".to_string()],
114 }
115 }
116}
117
118impl CrudFlowConfig {
119 pub fn from_file(path: &Path) -> Result<Self> {
121 let content = std::fs::read_to_string(path)
122 .map_err(|e| BenchError::Other(format!("Failed to read flow config: {}", e)))?;
123
124 Self::from_yaml(&content)
125 }
126
127 pub fn from_yaml(yaml: &str) -> Result<Self> {
129 serde_yaml::from_str(yaml)
130 .map_err(|e| BenchError::Other(format!("Failed to parse flow config: {}", e)))
131 }
132
133 pub fn single_flow(flow: CrudFlow) -> Self {
135 Self {
136 flows: vec![flow],
137 ..Default::default()
138 }
139 }
140}
141
142#[derive(Debug, Clone)]
144pub struct ResourceOperations {
145 pub base_path: String,
147 pub create: Option<ApiOperation>,
149 pub read: Option<ApiOperation>,
151 pub update: Option<ApiOperation>,
153 pub delete: Option<ApiOperation>,
155 pub list: Option<ApiOperation>,
157}
158
159impl ResourceOperations {
160 pub fn new(base_path: String) -> Self {
162 Self {
163 base_path,
164 create: None,
165 read: None,
166 update: None,
167 delete: None,
168 list: None,
169 }
170 }
171
172 pub fn has_crud_operations(&self) -> bool {
174 self.create.is_some()
175 && (self.read.is_some() || self.update.is_some() || self.delete.is_some())
176 }
177
178 pub fn get_id_param_name(&self) -> Option<String> {
180 let path = self
182 .read
183 .as_ref()
184 .or(self.update.as_ref())
185 .or(self.delete.as_ref())
186 .map(|op| &op.path)?;
187
188 extract_id_param_from_path(path)
190 }
191}
192
193fn extract_id_param_from_path(path: &str) -> Option<String> {
195 for segment in path.split('/').rev() {
197 if segment.starts_with('{') && segment.ends_with('}') {
198 return Some(segment[1..segment.len() - 1].to_string());
199 }
200 }
201 None
202}
203
204fn get_base_path(path: &str) -> String {
207 let segments: Vec<&str> = path.split('/').collect();
208 let mut base_segments = Vec::new();
209
210 for segment in segments {
211 if segment.starts_with('{') {
212 break;
213 }
214 if !segment.is_empty() {
215 base_segments.push(segment);
216 }
217 }
218
219 if base_segments.is_empty() {
220 "/".to_string()
221 } else {
222 format!("/{}", base_segments.join("/"))
223 }
224}
225
226fn is_detail_path(path: &str) -> bool {
228 path.contains('{') && path.contains('}')
229}
230
231pub struct CrudFlowDetector;
233
234impl CrudFlowDetector {
235 pub fn detect_flows(operations: &[ApiOperation]) -> Vec<CrudFlow> {
239 let mut resources: HashMap<String, ResourceOperations> = HashMap::new();
241
242 for op in operations {
243 let base_path = get_base_path(&op.path);
244 let is_detail = is_detail_path(&op.path);
245 let method = op.method.to_lowercase();
246
247 let resource = resources
248 .entry(base_path.clone())
249 .or_insert_with(|| ResourceOperations::new(base_path));
250
251 match (method.as_str(), is_detail) {
252 ("post", false) => resource.create = Some(op.clone()),
253 ("get", false) => resource.list = Some(op.clone()),
254 ("get", true) => resource.read = Some(op.clone()),
255 ("put", true) | ("patch", true) => resource.update = Some(op.clone()),
256 ("delete", true) => resource.delete = Some(op.clone()),
257 _ => {}
258 }
259 }
260
261 resources
263 .into_values()
264 .filter(|r| r.has_crud_operations())
265 .map(|r| Self::build_flow_from_resource(&r))
266 .collect()
267 }
268
269 fn build_flow_from_resource(resource: &ResourceOperations) -> CrudFlow {
271 let name = resource.base_path.trim_start_matches('/').replace('/', "_").to_string();
272
273 let mut flow =
274 CrudFlow::new(format!("{} CRUD", name)).with_base_path(resource.base_path.clone());
275
276 let id_param = resource.get_id_param_name().unwrap_or_else(|| "id".to_string());
277
278 if let Some(create_op) = &resource.create {
280 let step =
281 FlowStep::new(format!("{} {}", create_op.method.to_uppercase(), create_op.path))
282 .with_extract(vec!["id".to_string(), "uuid".to_string()])
283 .with_description("Create resource".to_string());
284 flow.add_step(step);
285 }
286
287 if let Some(read_op) = &resource.read {
289 let mut values = HashMap::new();
290 values.insert(id_param.clone(), "id".to_string());
291
292 let step = FlowStep::new(format!("{} {}", read_op.method.to_uppercase(), read_op.path))
293 .with_values(values)
294 .with_description("Read created resource".to_string());
295 flow.add_step(step);
296 }
297
298 if let Some(update_op) = &resource.update {
300 let mut values = HashMap::new();
301 values.insert(id_param.clone(), "id".to_string());
302
303 let step =
304 FlowStep::new(format!("{} {}", update_op.method.to_uppercase(), update_op.path))
305 .with_values(values)
306 .with_description("Update resource".to_string());
307 flow.add_step(step);
308 }
309
310 if let Some(read_op) = &resource.read {
312 let mut values = HashMap::new();
313 values.insert(id_param.clone(), "id".to_string());
314
315 let step = FlowStep::new(format!("{} {}", read_op.method.to_uppercase(), read_op.path))
316 .with_values(values)
317 .with_description("Verify update".to_string());
318 flow.add_step(step);
319 }
320
321 if let Some(delete_op) = &resource.delete {
323 let mut values = HashMap::new();
324 values.insert(id_param.clone(), "id".to_string());
325
326 let step =
327 FlowStep::new(format!("{} {}", delete_op.method.to_uppercase(), delete_op.path))
328 .with_values(values)
329 .with_description("Delete resource".to_string());
330 flow.add_step(step);
331 }
332
333 flow
334 }
335
336 pub fn merge_with_config(detected: Vec<CrudFlow>, config: &CrudFlowConfig) -> Vec<CrudFlow> {
340 if !config.flows.is_empty() {
341 config.flows.clone()
343 } else {
344 detected
346 }
347 }
348}
349
350#[derive(Debug, Clone, Default)]
352pub struct FlowExecutionContext {
353 pub extracted_values: HashMap<String, String>,
356 pub current_step: usize,
358 pub errors: Vec<String>,
360}
361
362impl FlowExecutionContext {
363 pub fn new() -> Self {
365 Self::default()
366 }
367
368 pub fn store_value(&mut self, key: String, value: String) {
370 self.extracted_values.insert(key, value);
371 }
372
373 pub fn get_value(&self, key: &str) -> Option<&String> {
375 self.extracted_values.get(key)
376 }
377
378 pub fn next_step(&mut self) {
380 self.current_step += 1;
381 }
382
383 pub fn record_error(&mut self, error: String) {
385 self.errors.push(error);
386 }
387
388 pub fn has_errors(&self) -> bool {
390 !self.errors.is_empty()
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use openapiv3::Operation;
398
399 fn create_test_operation(method: &str, path: &str, operation_id: Option<&str>) -> ApiOperation {
400 ApiOperation {
401 method: method.to_string(),
402 path: path.to_string(),
403 operation: Operation::default(),
404 operation_id: operation_id.map(|s| s.to_string()),
405 }
406 }
407
408 #[test]
409 fn test_get_base_path() {
410 assert_eq!(get_base_path("/users"), "/users");
411 assert_eq!(get_base_path("/users/{id}"), "/users");
412 assert_eq!(get_base_path("/api/v1/users/{userId}"), "/api/v1/users");
413 assert_eq!(get_base_path("/resources/{resourceId}/items/{itemId}"), "/resources");
414 assert_eq!(get_base_path("/"), "/");
415 }
416
417 #[test]
418 fn test_is_detail_path() {
419 assert!(!is_detail_path("/users"));
420 assert!(is_detail_path("/users/{id}"));
421 assert!(is_detail_path("/users/{userId}"));
422 assert!(!is_detail_path("/users/list"));
423 }
424
425 #[test]
426 fn test_extract_id_param_from_path() {
427 assert_eq!(extract_id_param_from_path("/users/{id}"), Some("id".to_string()));
428 assert_eq!(extract_id_param_from_path("/users/{userId}"), Some("userId".to_string()));
429 assert_eq!(extract_id_param_from_path("/a/{b}/{c}"), Some("c".to_string()));
430 assert_eq!(extract_id_param_from_path("/users"), None);
431 }
432
433 #[test]
434 fn test_crud_flow_detection() {
435 let operations = vec![
436 create_test_operation("post", "/users", Some("createUser")),
437 create_test_operation("get", "/users", Some("listUsers")),
438 create_test_operation("get", "/users/{id}", Some("getUser")),
439 create_test_operation("put", "/users/{id}", Some("updateUser")),
440 create_test_operation("delete", "/users/{id}", Some("deleteUser")),
441 ];
442
443 let flows = CrudFlowDetector::detect_flows(&operations);
444 assert_eq!(flows.len(), 1);
445
446 let flow = &flows[0];
447 assert!(flow.name.contains("users"));
448 assert_eq!(flow.steps.len(), 5); assert!(flow.steps[0].operation.starts_with("POST"));
452 assert!(flow.steps[0].extract.contains(&"id".to_string()));
454 }
455
456 #[test]
457 fn test_multiple_resources_detection() {
458 let operations = vec![
459 create_test_operation("post", "/users", Some("createUser")),
461 create_test_operation("get", "/users/{id}", Some("getUser")),
462 create_test_operation("delete", "/users/{id}", Some("deleteUser")),
463 create_test_operation("post", "/posts", Some("createPost")),
465 create_test_operation("get", "/posts/{id}", Some("getPost")),
466 create_test_operation("put", "/posts/{id}", Some("updatePost")),
467 ];
468
469 let flows = CrudFlowDetector::detect_flows(&operations);
470 assert_eq!(flows.len(), 2);
471 }
472
473 #[test]
474 fn test_no_crud_without_create() {
475 let operations = vec![
476 create_test_operation("get", "/users", Some("listUsers")),
478 create_test_operation("get", "/users/{id}", Some("getUser")),
479 ];
480
481 let flows = CrudFlowDetector::detect_flows(&operations);
482 assert!(flows.is_empty());
483 }
484
485 #[test]
486 fn test_flow_step_builder() {
487 let step = FlowStep::new("POST /users".to_string())
488 .with_extract(vec!["id".to_string(), "uuid".to_string()])
489 .with_description("Create a new user".to_string());
490
491 assert_eq!(step.operation, "POST /users");
492 assert_eq!(step.extract.len(), 2);
493 assert_eq!(step.description, Some("Create a new user".to_string()));
494 }
495
496 #[test]
497 fn test_flow_step_use_values() {
498 let mut values = HashMap::new();
499 values.insert("id".to_string(), "user_id".to_string());
500
501 let step = FlowStep::new("GET /users/{id}".to_string()).with_values(values);
502
503 assert_eq!(step.use_values.get("id"), Some(&"user_id".to_string()));
504 }
505
506 #[test]
507 fn test_crud_flow_config_from_yaml() {
508 let yaml = r#"
509flows:
510 - name: "User CRUD"
511 base_path: "/users"
512 steps:
513 - operation: "POST /users"
514 extract: ["id"]
515 description: "Create user"
516 - operation: "GET /users/{id}"
517 use_values:
518 id: "id"
519 description: "Get user"
520default_extract_fields:
521 - id
522 - uuid
523"#;
524
525 let config = CrudFlowConfig::from_yaml(yaml).expect("Should parse YAML");
526 assert_eq!(config.flows.len(), 1);
527 assert_eq!(config.flows[0].name, "User CRUD");
528 assert_eq!(config.flows[0].steps.len(), 2);
529 assert_eq!(config.default_extract_fields.len(), 2);
530 }
531
532 #[test]
533 fn test_execution_context() {
534 let mut ctx = FlowExecutionContext::new();
535
536 ctx.store_value("id".to_string(), "12345".to_string());
537 assert_eq!(ctx.get_value("id"), Some(&"12345".to_string()));
538
539 ctx.next_step();
540 assert_eq!(ctx.current_step, 1);
541
542 ctx.record_error("Something went wrong".to_string());
543 assert!(ctx.has_errors());
544 }
545
546 #[test]
547 fn test_resource_operations_has_crud() {
548 let mut resource = ResourceOperations::new("/users".to_string());
549 assert!(!resource.has_crud_operations());
550
551 resource.create = Some(create_test_operation("post", "/users", Some("createUser")));
552 assert!(!resource.has_crud_operations()); resource.read = Some(create_test_operation("get", "/users/{id}", Some("getUser")));
555 assert!(resource.has_crud_operations());
556 }
557
558 #[test]
559 fn test_get_id_param_name() {
560 let mut resource = ResourceOperations::new("/users".to_string());
561 resource.read = Some(create_test_operation("get", "/users/{userId}", Some("getUser")));
562
563 assert_eq!(resource.get_id_param_name(), Some("userId".to_string()));
564 }
565
566 #[test]
567 fn test_merge_with_config_user_provided() {
568 let detected = vec![CrudFlow::new("detected_flow".to_string())];
569
570 let mut config = CrudFlowConfig::default();
571 config.flows.push(CrudFlow::new("user_flow".to_string()));
572
573 let result = CrudFlowDetector::merge_with_config(detected, &config);
574 assert_eq!(result.len(), 1);
575 assert_eq!(result[0].name, "user_flow");
576 }
577
578 #[test]
579 fn test_merge_with_config_auto_detected() {
580 let detected = vec![CrudFlow::new("detected_flow".to_string())];
581
582 let config = CrudFlowConfig::default();
583
584 let result = CrudFlowDetector::merge_with_config(detected, &config);
585 assert_eq!(result.len(), 1);
586 assert_eq!(result[0].name, "detected_flow");
587 }
588
589 #[test]
590 fn test_crud_flow_get_all_extract_fields() {
591 let mut flow = CrudFlow::new("test".to_string());
592 flow.add_step(FlowStep::new("POST /test".to_string()).with_extract(vec!["id".to_string()]));
593 flow.add_step(
594 FlowStep::new("GET /test/{id}".to_string()).with_extract(vec!["uuid".to_string()]),
595 );
596
597 let fields = flow.get_all_extract_fields();
598 assert!(fields.contains(&"id".to_string()));
599 assert!(fields.contains(&"uuid".to_string()));
600 assert_eq!(fields.len(), 2);
601 }
602}