mockforge_bench/
crud_flow.rs

1//! CRUD Flow support for load testing
2//!
3//! This module provides functionality to automatically detect and configure
4//! CRUD (Create, Read, Update, Delete) flows from OpenAPI specifications,
5//! enabling sequential testing with response chaining.
6
7use crate::error::{BenchError, Result};
8use crate::spec_parser::ApiOperation;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::path::Path;
12
13/// A single step in a CRUD flow
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct FlowStep {
16    /// The operation identifier (e.g., "POST /users" or operation_id)
17    pub operation: String,
18    /// Fields to extract from the response (for subsequent steps)
19    #[serde(default)]
20    pub extract: Vec<String>,
21    /// Mapping of path/body variables to extracted values
22    /// Key: variable name in request, Value: extracted field name
23    #[serde(default)]
24    pub use_values: HashMap<String, String>,
25    /// Optional description for this step
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub description: Option<String>,
28}
29
30impl FlowStep {
31    /// Create a new flow step
32    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    /// Add fields to extract from response
42    pub fn with_extract(mut self, fields: Vec<String>) -> Self {
43        self.extract = fields;
44        self
45    }
46
47    /// Add value mappings for this step
48    pub fn with_values(mut self, values: HashMap<String, String>) -> Self {
49        self.use_values = values;
50        self
51    }
52
53    /// Add a description
54    pub fn with_description(mut self, description: String) -> Self {
55        self.description = Some(description);
56        self
57    }
58}
59
60/// A complete CRUD flow definition
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct CrudFlow {
63    /// Name of this flow
64    pub name: String,
65    /// Base path for this resource (e.g., "/users")
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub base_path: Option<String>,
68    /// Ordered list of steps in the flow
69    pub steps: Vec<FlowStep>,
70}
71
72impl CrudFlow {
73    /// Create a new CRUD flow with a name
74    pub fn new(name: String) -> Self {
75        Self {
76            name,
77            base_path: None,
78            steps: Vec::new(),
79        }
80    }
81
82    /// Set the base path
83    pub fn with_base_path(mut self, path: String) -> Self {
84        self.base_path = Some(path);
85        self
86    }
87
88    /// Add a step to the flow
89    pub fn add_step(&mut self, step: FlowStep) {
90        self.steps.push(step);
91    }
92
93    /// Get all fields that need to be extracted across all steps
94    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/// CRUD flow configuration containing multiple flows
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct CrudFlowConfig {
102    /// List of CRUD flows
103    pub flows: Vec<CrudFlow>,
104    /// Default fields to extract if not specified
105    #[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    /// Load configuration from a YAML file
120    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    /// Parse configuration from YAML string
128    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    /// Create configuration with a single flow
134    pub fn single_flow(flow: CrudFlow) -> Self {
135        Self {
136            flows: vec![flow],
137            ..Default::default()
138        }
139    }
140}
141
142/// Grouped operations by base path for CRUD detection
143#[derive(Debug, Clone)]
144pub struct ResourceOperations {
145    /// Base path (e.g., "/users")
146    pub base_path: String,
147    /// CREATE operation (typically POST on base path)
148    pub create: Option<ApiOperation>,
149    /// READ single operation (typically GET on {id} path)
150    pub read: Option<ApiOperation>,
151    /// UPDATE operation (typically PUT/PATCH on {id} path)
152    pub update: Option<ApiOperation>,
153    /// DELETE operation (typically DELETE on {id} path)
154    pub delete: Option<ApiOperation>,
155    /// LIST operation (typically GET on base path)
156    pub list: Option<ApiOperation>,
157}
158
159impl ResourceOperations {
160    /// Create a new resource operations group
161    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    /// Check if this resource has CRUD operations
173    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    /// Get the ID parameter name from the path (e.g., "{id}", "{userId}")
179    pub fn get_id_param_name(&self) -> Option<String> {
180        // Look at read, update, or delete operations to find the ID parameter
181        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 parameter name from path like "/users/{id}" or "/users/{userId}"
189        extract_id_param_from_path(path)
190    }
191}
192
193/// Extract the ID parameter name from a path pattern
194fn extract_id_param_from_path(path: &str) -> Option<String> {
195    // Find the last path segment that looks like {paramName}
196    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
204/// Extract the base path from a full path
205/// e.g., "/users/{id}" -> "/users"
206fn 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
226/// Check if a path is a "detail" path (has ID parameter)
227fn is_detail_path(path: &str) -> bool {
228    path.contains('{') && path.contains('}')
229}
230
231/// Auto-detect CRUD flows from a list of operations
232pub struct CrudFlowDetector;
233
234impl CrudFlowDetector {
235    /// Detect CRUD flows from API operations
236    ///
237    /// Groups operations by base path and identifies CRUD patterns.
238    pub fn detect_flows(operations: &[ApiOperation]) -> Vec<CrudFlow> {
239        // Group operations by base path
240        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        // Build flows from resources that have CRUD operations
262        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    /// Build a CRUD flow from a resource's operations
270    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        // Step 1: CREATE (POST) - extract ID
279        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        // Step 2: READ (GET) - verify creation
288        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        // Step 3: UPDATE (PUT/PATCH) - modify resource
299        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        // Step 4: READ again (GET) - verify update
311        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        // Step 5: DELETE - cleanup
322        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    /// Merge auto-detected flows with user-provided configuration
337    ///
338    /// User configuration takes precedence over auto-detected flows.
339    pub fn merge_with_config(detected: Vec<CrudFlow>, config: &CrudFlowConfig) -> Vec<CrudFlow> {
340        if !config.flows.is_empty() {
341            // If user provided flows, use those
342            config.flows.clone()
343        } else {
344            // Otherwise use auto-detected flows
345            detected
346        }
347    }
348}
349
350/// Context for CRUD flow execution (tracks extracted values)
351#[derive(Debug, Clone, Default)]
352pub struct FlowExecutionContext {
353    /// Extracted values from previous steps
354    /// Key: field name, Value: extracted value
355    pub extracted_values: HashMap<String, String>,
356    /// Current step index
357    pub current_step: usize,
358    /// Errors encountered
359    pub errors: Vec<String>,
360}
361
362impl FlowExecutionContext {
363    /// Create a new execution context
364    pub fn new() -> Self {
365        Self::default()
366    }
367
368    /// Store an extracted value
369    pub fn store_value(&mut self, key: String, value: String) {
370        self.extracted_values.insert(key, value);
371    }
372
373    /// Get an extracted value
374    pub fn get_value(&self, key: &str) -> Option<&String> {
375        self.extracted_values.get(key)
376    }
377
378    /// Advance to the next step
379    pub fn next_step(&mut self) {
380        self.current_step += 1;
381    }
382
383    /// Record an error
384    pub fn record_error(&mut self, error: String) {
385        self.errors.push(error);
386    }
387
388    /// Check if execution has errors
389    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); // CREATE, READ, UPDATE, READ, DELETE
449
450        // First step should be POST (create)
451        assert!(flow.steps[0].operation.starts_with("POST"));
452        // Should extract id
453        assert!(flow.steps[0].extract.contains(&"id".to_string()));
454    }
455
456    #[test]
457    fn test_multiple_resources_detection() {
458        let operations = vec![
459            // Users resource
460            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            // Posts resource
464            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            // Only read operations - no CREATE
477            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()); // Still needs read/update/delete
553
554        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}