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, Deserializer, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::path::Path;
12
13/// Field extraction configuration - supports both simple field names and aliased extraction
14#[derive(Debug, Clone, Serialize, PartialEq)]
15pub struct ExtractField {
16    /// The field name to extract from the response
17    pub field: String,
18    /// The name to store it as (defaults to field name if not specified)
19    /// Note: Deserialization accepts "as" via custom Deserialize impl, but serializes as "store_as"
20    pub store_as: String,
21}
22
23impl ExtractField {
24    /// Create a new extract field with the same name for field and storage
25    pub fn simple(field: String) -> Self {
26        Self {
27            store_as: field.clone(),
28            field,
29        }
30    }
31
32    /// Create a new extract field with an alias
33    pub fn aliased(field: String, store_as: String) -> Self {
34        Self { field, store_as }
35    }
36}
37
38impl<'de> Deserialize<'de> for ExtractField {
39    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
40    where
41        D: Deserializer<'de>,
42    {
43        use serde::de::{self, MapAccess, Visitor};
44
45        struct ExtractFieldVisitor;
46
47        impl<'de> Visitor<'de> for ExtractFieldVisitor {
48            type Value = ExtractField;
49
50            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
51                formatter.write_str("a string or an object with 'field' and optional 'as' keys")
52            }
53
54            // Handle simple string: "uuid"
55            fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
56            where
57                E: de::Error,
58            {
59                Ok(ExtractField::simple(value.to_string()))
60            }
61
62            // Handle object: {field: "uuid", as: "pool_uuid"}
63            fn visit_map<M>(self, mut map: M) -> std::result::Result<Self::Value, M::Error>
64            where
65                M: MapAccess<'de>,
66            {
67                let mut field: Option<String> = None;
68                let mut store_as: Option<String> = None;
69
70                while let Some(key) = map.next_key::<String>()? {
71                    match key.as_str() {
72                        "field" => {
73                            field = Some(map.next_value()?);
74                        }
75                        "as" => {
76                            store_as = Some(map.next_value()?);
77                        }
78                        _ => {
79                            let _: serde::de::IgnoredAny = map.next_value()?;
80                        }
81                    }
82                }
83
84                let field = field.ok_or_else(|| de::Error::missing_field("field"))?;
85                let store_as = store_as.unwrap_or_else(|| field.clone());
86
87                Ok(ExtractField { field, store_as })
88            }
89        }
90
91        deserializer.deserialize_any(ExtractFieldVisitor)
92    }
93}
94
95/// A single step in a CRUD flow
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct FlowStep {
98    /// The operation identifier (e.g., "POST /users" or operation_id)
99    pub operation: String,
100    /// Fields to extract from the response (for subsequent steps)
101    /// Supports both simple strings and objects with aliases:
102    /// - Simple: "uuid" (extracts uuid, stores as uuid)
103    /// - Aliased: {field: "uuid", as: "pool_uuid"} (extracts uuid, stores as pool_uuid)
104    #[serde(default)]
105    pub extract: Vec<ExtractField>,
106    /// Mapping of path/body variables to extracted values
107    /// Key: variable name in request, Value: extracted field name
108    #[serde(default)]
109    pub use_values: HashMap<String, String>,
110    /// Optional description for this step
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub description: Option<String>,
113}
114
115impl FlowStep {
116    /// Create a new flow step
117    pub fn new(operation: String) -> Self {
118        Self {
119            operation,
120            extract: Vec::new(),
121            use_values: HashMap::new(),
122            description: None,
123        }
124    }
125
126    /// Add fields to extract from response (simple field names)
127    pub fn with_extract(mut self, fields: Vec<String>) -> Self {
128        self.extract = fields.into_iter().map(ExtractField::simple).collect();
129        self
130    }
131
132    /// Add fields to extract with aliases
133    pub fn with_extract_fields(mut self, fields: Vec<ExtractField>) -> Self {
134        self.extract = fields;
135        self
136    }
137
138    /// Add value mappings for this step
139    pub fn with_values(mut self, values: HashMap<String, String>) -> Self {
140        self.use_values = values;
141        self
142    }
143
144    /// Add a description
145    pub fn with_description(mut self, description: String) -> Self {
146        self.description = Some(description);
147        self
148    }
149}
150
151/// A complete CRUD flow definition
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct CrudFlow {
154    /// Name of this flow
155    pub name: String,
156    /// Base path for this resource (e.g., "/users")
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub base_path: Option<String>,
159    /// Ordered list of steps in the flow
160    pub steps: Vec<FlowStep>,
161}
162
163impl CrudFlow {
164    /// Create a new CRUD flow with a name
165    pub fn new(name: String) -> Self {
166        Self {
167            name,
168            base_path: None,
169            steps: Vec::new(),
170        }
171    }
172
173    /// Set the base path
174    pub fn with_base_path(mut self, path: String) -> Self {
175        self.base_path = Some(path);
176        self
177    }
178
179    /// Add a step to the flow
180    pub fn add_step(&mut self, step: FlowStep) {
181        self.steps.push(step);
182    }
183
184    /// Get all fields that need to be extracted across all steps (returns field names)
185    pub fn get_all_extract_fields(&self) -> HashSet<String> {
186        self.steps
187            .iter()
188            .flat_map(|step| step.extract.iter().map(|e| e.field.clone()))
189            .collect()
190    }
191
192    /// Get all extract field configurations across all steps
193    pub fn get_all_extract_configs(&self) -> Vec<&ExtractField> {
194        self.steps.iter().flat_map(|step| step.extract.iter()).collect()
195    }
196}
197
198/// CRUD flow configuration containing multiple flows
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct CrudFlowConfig {
201    /// List of CRUD flows
202    pub flows: Vec<CrudFlow>,
203    /// Default fields to extract if not specified
204    #[serde(default)]
205    pub default_extract_fields: Vec<String>,
206}
207
208impl Default for CrudFlowConfig {
209    fn default() -> Self {
210        Self {
211            flows: Vec::new(),
212            default_extract_fields: vec!["id".to_string(), "uuid".to_string()],
213        }
214    }
215}
216
217impl CrudFlowConfig {
218    /// Load configuration from a YAML file
219    pub fn from_file(path: &Path) -> Result<Self> {
220        let content = std::fs::read_to_string(path)
221            .map_err(|e| BenchError::Other(format!("Failed to read flow config: {}", e)))?;
222
223        Self::from_yaml(&content)
224    }
225
226    /// Parse configuration from YAML string
227    pub fn from_yaml(yaml: &str) -> Result<Self> {
228        serde_yaml::from_str(yaml)
229            .map_err(|e| BenchError::Other(format!("Failed to parse flow config: {}", e)))
230    }
231
232    /// Create configuration with a single flow
233    pub fn single_flow(flow: CrudFlow) -> Self {
234        Self {
235            flows: vec![flow],
236            ..Default::default()
237        }
238    }
239}
240
241/// Grouped operations by base path for CRUD detection
242#[derive(Debug, Clone)]
243pub struct ResourceOperations {
244    /// Base path (e.g., "/users")
245    pub base_path: String,
246    /// CREATE operation (typically POST on base path)
247    pub create: Option<ApiOperation>,
248    /// READ single operation (typically GET on {id} path)
249    pub read: Option<ApiOperation>,
250    /// UPDATE operation (typically PUT/PATCH on {id} path)
251    pub update: Option<ApiOperation>,
252    /// DELETE operation (typically DELETE on {id} path)
253    pub delete: Option<ApiOperation>,
254    /// LIST operation (typically GET on base path)
255    pub list: Option<ApiOperation>,
256}
257
258impl ResourceOperations {
259    /// Create a new resource operations group
260    pub fn new(base_path: String) -> Self {
261        Self {
262            base_path,
263            create: None,
264            read: None,
265            update: None,
266            delete: None,
267            list: None,
268        }
269    }
270
271    /// Check if this resource has CRUD operations
272    pub fn has_crud_operations(&self) -> bool {
273        self.create.is_some()
274            && (self.read.is_some() || self.update.is_some() || self.delete.is_some())
275    }
276
277    /// Get the ID parameter name from the path (e.g., "{id}", "{userId}")
278    pub fn get_id_param_name(&self) -> Option<String> {
279        // Look at read, update, or delete operations to find the ID parameter
280        let path = self
281            .read
282            .as_ref()
283            .or(self.update.as_ref())
284            .or(self.delete.as_ref())
285            .map(|op| &op.path)?;
286
287        // Extract parameter name from path like "/users/{id}" or "/users/{userId}"
288        extract_id_param_from_path(path)
289    }
290}
291
292/// Extract the ID parameter name from a path pattern
293fn extract_id_param_from_path(path: &str) -> Option<String> {
294    // Find the last path segment that looks like {paramName}
295    for segment in path.split('/').rev() {
296        if segment.starts_with('{') && segment.ends_with('}') {
297            return Some(segment[1..segment.len() - 1].to_string());
298        }
299    }
300    None
301}
302
303/// Extract the base path from a full path
304/// e.g., "/users/{id}" -> "/users"
305fn get_base_path(path: &str) -> String {
306    let segments: Vec<&str> = path.split('/').collect();
307    let mut base_segments = Vec::new();
308
309    for segment in segments {
310        if segment.starts_with('{') {
311            break;
312        }
313        if !segment.is_empty() {
314            base_segments.push(segment);
315        }
316    }
317
318    if base_segments.is_empty() {
319        "/".to_string()
320    } else {
321        format!("/{}", base_segments.join("/"))
322    }
323}
324
325/// Check if a path is a "detail" path (has ID parameter)
326fn is_detail_path(path: &str) -> bool {
327    path.contains('{') && path.contains('}')
328}
329
330/// Auto-detect CRUD flows from a list of operations
331pub struct CrudFlowDetector;
332
333impl CrudFlowDetector {
334    /// Detect CRUD flows from API operations
335    ///
336    /// Groups operations by base path and identifies CRUD patterns.
337    pub fn detect_flows(operations: &[ApiOperation]) -> Vec<CrudFlow> {
338        // Group operations by base path
339        let mut resources: HashMap<String, ResourceOperations> = HashMap::new();
340
341        for op in operations {
342            let base_path = get_base_path(&op.path);
343            let is_detail = is_detail_path(&op.path);
344            let method = op.method.to_lowercase();
345
346            let resource = resources
347                .entry(base_path.clone())
348                .or_insert_with(|| ResourceOperations::new(base_path));
349
350            match (method.as_str(), is_detail) {
351                ("post", false) => resource.create = Some(op.clone()),
352                ("get", false) => resource.list = Some(op.clone()),
353                ("get", true) => resource.read = Some(op.clone()),
354                ("put", true) | ("patch", true) => resource.update = Some(op.clone()),
355                ("delete", true) => resource.delete = Some(op.clone()),
356                _ => {}
357            }
358        }
359
360        // Build flows from resources that have CRUD operations
361        resources
362            .into_values()
363            .filter(|r| r.has_crud_operations())
364            .map(|r| Self::build_flow_from_resource(&r))
365            .collect()
366    }
367
368    /// Build a CRUD flow from a resource's operations
369    fn build_flow_from_resource(resource: &ResourceOperations) -> CrudFlow {
370        let name = resource.base_path.trim_start_matches('/').replace('/', "_").to_string();
371
372        let mut flow =
373            CrudFlow::new(format!("{} CRUD", name)).with_base_path(resource.base_path.clone());
374
375        let id_param = resource.get_id_param_name().unwrap_or_else(|| "id".to_string());
376
377        // Step 1: CREATE (POST) - extract ID
378        if let Some(create_op) = &resource.create {
379            let step =
380                FlowStep::new(format!("{} {}", create_op.method.to_uppercase(), create_op.path))
381                    .with_extract(vec!["id".to_string(), "uuid".to_string()])
382                    .with_description("Create resource".to_string());
383            flow.add_step(step);
384        }
385
386        // Step 2: READ (GET) - verify creation
387        if let Some(read_op) = &resource.read {
388            let mut values = HashMap::new();
389            values.insert(id_param.clone(), "id".to_string());
390
391            let step = FlowStep::new(format!("{} {}", read_op.method.to_uppercase(), read_op.path))
392                .with_values(values)
393                .with_description("Read created resource".to_string());
394            flow.add_step(step);
395        }
396
397        // Step 3: UPDATE (PUT/PATCH) - modify resource
398        if let Some(update_op) = &resource.update {
399            let mut values = HashMap::new();
400            values.insert(id_param.clone(), "id".to_string());
401
402            let step =
403                FlowStep::new(format!("{} {}", update_op.method.to_uppercase(), update_op.path))
404                    .with_values(values)
405                    .with_description("Update resource".to_string());
406            flow.add_step(step);
407        }
408
409        // Step 4: READ again (GET) - verify update
410        if let Some(read_op) = &resource.read {
411            let mut values = HashMap::new();
412            values.insert(id_param.clone(), "id".to_string());
413
414            let step = FlowStep::new(format!("{} {}", read_op.method.to_uppercase(), read_op.path))
415                .with_values(values)
416                .with_description("Verify update".to_string());
417            flow.add_step(step);
418        }
419
420        // Step 5: DELETE - cleanup
421        if let Some(delete_op) = &resource.delete {
422            let mut values = HashMap::new();
423            values.insert(id_param.clone(), "id".to_string());
424
425            let step =
426                FlowStep::new(format!("{} {}", delete_op.method.to_uppercase(), delete_op.path))
427                    .with_values(values)
428                    .with_description("Delete resource".to_string());
429            flow.add_step(step);
430        }
431
432        flow
433    }
434
435    /// Merge auto-detected flows with user-provided configuration
436    ///
437    /// User configuration takes precedence over auto-detected flows.
438    pub fn merge_with_config(detected: Vec<CrudFlow>, config: &CrudFlowConfig) -> Vec<CrudFlow> {
439        if !config.flows.is_empty() {
440            // If user provided flows, use those
441            config.flows.clone()
442        } else {
443            // Otherwise use auto-detected flows
444            detected
445        }
446    }
447}
448
449/// Context for CRUD flow execution (tracks extracted values)
450#[derive(Debug, Clone, Default)]
451pub struct FlowExecutionContext {
452    /// Extracted values from previous steps
453    /// Key: field name, Value: extracted value
454    pub extracted_values: HashMap<String, String>,
455    /// Current step index
456    pub current_step: usize,
457    /// Errors encountered
458    pub errors: Vec<String>,
459}
460
461impl FlowExecutionContext {
462    /// Create a new execution context
463    pub fn new() -> Self {
464        Self::default()
465    }
466
467    /// Store an extracted value
468    pub fn store_value(&mut self, key: String, value: String) {
469        self.extracted_values.insert(key, value);
470    }
471
472    /// Get an extracted value
473    pub fn get_value(&self, key: &str) -> Option<&String> {
474        self.extracted_values.get(key)
475    }
476
477    /// Advance to the next step
478    pub fn next_step(&mut self) {
479        self.current_step += 1;
480    }
481
482    /// Record an error
483    pub fn record_error(&mut self, error: String) {
484        self.errors.push(error);
485    }
486
487    /// Check if execution has errors
488    pub fn has_errors(&self) -> bool {
489        !self.errors.is_empty()
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use openapiv3::Operation;
497
498    fn create_test_operation(method: &str, path: &str, operation_id: Option<&str>) -> ApiOperation {
499        ApiOperation {
500            method: method.to_string(),
501            path: path.to_string(),
502            operation: Operation::default(),
503            operation_id: operation_id.map(|s| s.to_string()),
504        }
505    }
506
507    #[test]
508    fn test_get_base_path() {
509        assert_eq!(get_base_path("/users"), "/users");
510        assert_eq!(get_base_path("/users/{id}"), "/users");
511        assert_eq!(get_base_path("/api/v1/users/{userId}"), "/api/v1/users");
512        assert_eq!(get_base_path("/resources/{resourceId}/items/{itemId}"), "/resources");
513        assert_eq!(get_base_path("/"), "/");
514    }
515
516    #[test]
517    fn test_is_detail_path() {
518        assert!(!is_detail_path("/users"));
519        assert!(is_detail_path("/users/{id}"));
520        assert!(is_detail_path("/users/{userId}"));
521        assert!(!is_detail_path("/users/list"));
522    }
523
524    #[test]
525    fn test_extract_id_param_from_path() {
526        assert_eq!(extract_id_param_from_path("/users/{id}"), Some("id".to_string()));
527        assert_eq!(extract_id_param_from_path("/users/{userId}"), Some("userId".to_string()));
528        assert_eq!(extract_id_param_from_path("/a/{b}/{c}"), Some("c".to_string()));
529        assert_eq!(extract_id_param_from_path("/users"), None);
530    }
531
532    #[test]
533    fn test_crud_flow_detection() {
534        let operations = vec![
535            create_test_operation("post", "/users", Some("createUser")),
536            create_test_operation("get", "/users", Some("listUsers")),
537            create_test_operation("get", "/users/{id}", Some("getUser")),
538            create_test_operation("put", "/users/{id}", Some("updateUser")),
539            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
540        ];
541
542        let flows = CrudFlowDetector::detect_flows(&operations);
543        assert_eq!(flows.len(), 1);
544
545        let flow = &flows[0];
546        assert!(flow.name.contains("users"));
547        assert_eq!(flow.steps.len(), 5); // CREATE, READ, UPDATE, READ, DELETE
548
549        // First step should be POST (create)
550        assert!(flow.steps[0].operation.starts_with("POST"));
551        // Should extract id
552        assert!(flow.steps[0].extract.iter().any(|e| e.field == "id"));
553    }
554
555    #[test]
556    fn test_multiple_resources_detection() {
557        let operations = vec![
558            // Users resource
559            create_test_operation("post", "/users", Some("createUser")),
560            create_test_operation("get", "/users/{id}", Some("getUser")),
561            create_test_operation("delete", "/users/{id}", Some("deleteUser")),
562            // Posts resource
563            create_test_operation("post", "/posts", Some("createPost")),
564            create_test_operation("get", "/posts/{id}", Some("getPost")),
565            create_test_operation("put", "/posts/{id}", Some("updatePost")),
566        ];
567
568        let flows = CrudFlowDetector::detect_flows(&operations);
569        assert_eq!(flows.len(), 2);
570    }
571
572    #[test]
573    fn test_no_crud_without_create() {
574        let operations = vec![
575            // Only read operations - no CREATE
576            create_test_operation("get", "/users", Some("listUsers")),
577            create_test_operation("get", "/users/{id}", Some("getUser")),
578        ];
579
580        let flows = CrudFlowDetector::detect_flows(&operations);
581        assert!(flows.is_empty());
582    }
583
584    #[test]
585    fn test_flow_step_builder() {
586        let step = FlowStep::new("POST /users".to_string())
587            .with_extract(vec!["id".to_string(), "uuid".to_string()])
588            .with_description("Create a new user".to_string());
589
590        assert_eq!(step.operation, "POST /users");
591        assert_eq!(step.extract.len(), 2);
592        assert_eq!(step.description, Some("Create a new user".to_string()));
593    }
594
595    #[test]
596    fn test_flow_step_use_values() {
597        let mut values = HashMap::new();
598        values.insert("id".to_string(), "user_id".to_string());
599
600        let step = FlowStep::new("GET /users/{id}".to_string()).with_values(values);
601
602        assert_eq!(step.use_values.get("id"), Some(&"user_id".to_string()));
603    }
604
605    #[test]
606    fn test_crud_flow_config_from_yaml() {
607        let yaml = r#"
608flows:
609  - name: "User CRUD"
610    base_path: "/users"
611    steps:
612      - operation: "POST /users"
613        extract: ["id"]
614        description: "Create user"
615      - operation: "GET /users/{id}"
616        use_values:
617          id: "id"
618        description: "Get user"
619default_extract_fields:
620  - id
621  - uuid
622"#;
623
624        let config = CrudFlowConfig::from_yaml(yaml).expect("Should parse YAML");
625        assert_eq!(config.flows.len(), 1);
626        assert_eq!(config.flows[0].name, "User CRUD");
627        assert_eq!(config.flows[0].steps.len(), 2);
628        assert_eq!(config.default_extract_fields.len(), 2);
629    }
630
631    #[test]
632    fn test_execution_context() {
633        let mut ctx = FlowExecutionContext::new();
634
635        ctx.store_value("id".to_string(), "12345".to_string());
636        assert_eq!(ctx.get_value("id"), Some(&"12345".to_string()));
637
638        ctx.next_step();
639        assert_eq!(ctx.current_step, 1);
640
641        ctx.record_error("Something went wrong".to_string());
642        assert!(ctx.has_errors());
643    }
644
645    #[test]
646    fn test_resource_operations_has_crud() {
647        let mut resource = ResourceOperations::new("/users".to_string());
648        assert!(!resource.has_crud_operations());
649
650        resource.create = Some(create_test_operation("post", "/users", Some("createUser")));
651        assert!(!resource.has_crud_operations()); // Still needs read/update/delete
652
653        resource.read = Some(create_test_operation("get", "/users/{id}", Some("getUser")));
654        assert!(resource.has_crud_operations());
655    }
656
657    #[test]
658    fn test_get_id_param_name() {
659        let mut resource = ResourceOperations::new("/users".to_string());
660        resource.read = Some(create_test_operation("get", "/users/{userId}", Some("getUser")));
661
662        assert_eq!(resource.get_id_param_name(), Some("userId".to_string()));
663    }
664
665    #[test]
666    fn test_merge_with_config_user_provided() {
667        let detected = vec![CrudFlow::new("detected_flow".to_string())];
668
669        let mut config = CrudFlowConfig::default();
670        config.flows.push(CrudFlow::new("user_flow".to_string()));
671
672        let result = CrudFlowDetector::merge_with_config(detected, &config);
673        assert_eq!(result.len(), 1);
674        assert_eq!(result[0].name, "user_flow");
675    }
676
677    #[test]
678    fn test_merge_with_config_auto_detected() {
679        let detected = vec![CrudFlow::new("detected_flow".to_string())];
680
681        let config = CrudFlowConfig::default();
682
683        let result = CrudFlowDetector::merge_with_config(detected, &config);
684        assert_eq!(result.len(), 1);
685        assert_eq!(result[0].name, "detected_flow");
686    }
687
688    #[test]
689    fn test_crud_flow_get_all_extract_fields() {
690        let mut flow = CrudFlow::new("test".to_string());
691        flow.add_step(FlowStep::new("POST /test".to_string()).with_extract(vec!["id".to_string()]));
692        flow.add_step(
693            FlowStep::new("GET /test/{id}".to_string()).with_extract(vec!["uuid".to_string()]),
694        );
695
696        let fields = flow.get_all_extract_fields();
697        assert!(fields.contains(&"id".to_string()));
698        assert!(fields.contains(&"uuid".to_string()));
699        assert_eq!(fields.len(), 2);
700    }
701}