mockforge_recorder/
stub_mapping.rs

1//! Stub mapping conversion from recorded requests/responses
2//!
3//! Converts recorded API interactions into MockForge fixture format for replay.
4
5use crate::{models::RecordedExchange, Result};
6use regex::Regex;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10use uuid::Uuid;
11
12/// Output format for stub mappings
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum StubFormat {
15    /// YAML format
16    Yaml,
17    /// JSON format
18    Json,
19}
20
21/// Stub mapping fixture structure
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct StubMapping {
24    /// Unique identifier for this stub
25    pub identifier: String,
26    /// Human-readable name
27    pub name: String,
28    /// Description
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub description: Option<String>,
31    /// Request matching criteria
32    pub request: RequestMatcher,
33    /// Response configuration
34    pub response: ResponseTemplate,
35    /// Metadata
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub metadata: Option<HashMap<String, String>>,
38}
39
40/// Request matching criteria
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct RequestMatcher {
43    /// HTTP method
44    pub method: String,
45    /// Path pattern (exact or with template variables)
46    pub path: String,
47    /// Query parameters (optional)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub query_params: Option<HashMap<String, String>>,
50    /// Headers to match (optional)
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub headers: Option<HashMap<String, String>>,
53    /// Body pattern (optional, for POST/PUT/PATCH)
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub body_pattern: Option<String>,
56}
57
58/// Response template
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ResponseTemplate {
61    /// HTTP status code
62    pub status_code: i32,
63    /// Response headers
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub headers: Option<HashMap<String, String>>,
66    /// Response body (with template variables)
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub body: Option<String>,
69    /// Content type
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub content_type: Option<String>,
72}
73
74/// Converter for generating stub mappings from recordings
75pub struct StubMappingConverter {
76    /// Whether to detect and replace dynamic values with templates
77    detect_dynamic_values: bool,
78    /// UUID pattern for detection
79    uuid_pattern: Regex,
80    /// Timestamp pattern for detection
81    timestamp_pattern: Regex,
82}
83
84impl StubMappingConverter {
85    /// Create a new converter
86    pub fn new(detect_dynamic_values: bool) -> Self {
87        Self {
88            detect_dynamic_values,
89            // UUID v4 pattern
90            uuid_pattern: Regex::new(
91                r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",
92            )
93            .unwrap(),
94            // RFC3339 timestamp pattern
95            timestamp_pattern: Regex::new(
96                r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})",
97            )
98            .unwrap(),
99        }
100    }
101
102    /// Convert a recorded exchange to a stub mapping
103    pub fn convert(&self, exchange: &RecordedExchange) -> Result<StubMapping> {
104        let request = &exchange.request;
105        let response = exchange.response.as_ref().ok_or_else(|| {
106            crate::RecorderError::InvalidFilter("No response found for request".to_string())
107        })?;
108
109        // Extract request matcher
110        let request_matcher = self.extract_request_matcher(request)?;
111
112        // Extract response template
113        let response_template = self.extract_response_template(response)?;
114
115        // Generate identifier from request
116        let identifier = self.generate_identifier(request);
117
118        // Generate name
119        let name = format!("{} {}", request.method, request.path);
120
121        // Extract description from tags if available
122        let description = request.tags_vec().first().map(|s| format!("Recorded from {}", s));
123
124        // Build metadata
125        let mut metadata = HashMap::new();
126        metadata.insert("source".to_string(), "recorder".to_string());
127        metadata.insert("recorded_at".to_string(), request.timestamp.to_rfc3339());
128        if let Some(ref trace_id) = request.trace_id {
129            metadata.insert("trace_id".to_string(), trace_id.clone());
130        }
131        // Check if this was a proxied request (check tags for proxy indicator)
132        if let Some(ref tags) = request.tags {
133            let tags_lower = tags.to_lowercase();
134            if tags_lower.contains("proxy") || tags_lower.contains("\"proxy\"") {
135                metadata.insert("proxy_source".to_string(), "true".to_string());
136            }
137        }
138
139        Ok(StubMapping {
140            identifier,
141            name,
142            description,
143            request: request_matcher,
144            response: response_template,
145            metadata: Some(metadata),
146        })
147    }
148
149    /// Extract request matcher from recorded request
150    fn extract_request_matcher(
151        &self,
152        request: &crate::models::RecordedRequest,
153    ) -> Result<RequestMatcher> {
154        let mut query_params = None;
155        if let Some(ref query_str) = request.query_params {
156            if let Ok(params) = serde_json::from_str::<HashMap<String, String>>(query_str) {
157                if !params.is_empty() {
158                    query_params = Some(params);
159                }
160            }
161        }
162
163        let mut headers = None;
164        let headers_map = request.headers_map();
165        if !headers_map.is_empty() {
166            // Filter out common headers that shouldn't be used for matching
167            let filtered: HashMap<String, String> = headers_map
168                .into_iter()
169                .filter(|(k, _)| {
170                    !matches!(
171                        k.to_lowercase().as_str(),
172                        "host" | "user-agent" | "accept-encoding" | "connection" | "content-length"
173                    )
174                })
175                .collect();
176            if !filtered.is_empty() {
177                headers = Some(filtered);
178            }
179        }
180
181        // Extract body pattern if present
182        let body_pattern = request
183            .decoded_body()
184            .and_then(|body| String::from_utf8(body).ok())
185            .map(|body_str| {
186                if self.detect_dynamic_values {
187                    self.replace_dynamic_values(&body_str)
188                } else {
189                    body_str
190                }
191            });
192
193        Ok(RequestMatcher {
194            method: request.method.clone(),
195            path: self.process_path(&request.path),
196            query_params,
197            headers,
198            body_pattern,
199        })
200    }
201
202    /// Extract response template from recorded response
203    fn extract_response_template(
204        &self,
205        response: &crate::models::RecordedResponse,
206    ) -> Result<ResponseTemplate> {
207        let headers_map = response.headers_map();
208        let content_type = headers_map
209            .get("content-type")
210            .or_else(|| headers_map.get("Content-Type"))
211            .cloned();
212
213        // Filter response headers (exclude common ones)
214        let mut response_headers = HashMap::new();
215        for (key, value) in &headers_map {
216            if !matches!(
217                key.to_lowercase().as_str(),
218                "content-length" | "date" | "server" | "connection"
219            ) {
220                response_headers.insert(key.clone(), value.clone());
221            }
222        }
223
224        let headers = if response_headers.is_empty() {
225            None
226        } else {
227            Some(response_headers)
228        };
229
230        // Extract body and process dynamic values
231        let body = response.decoded_body().and_then(|body_bytes| {
232            String::from_utf8(body_bytes).ok().map(|body_str| {
233                if self.detect_dynamic_values {
234                    self.replace_dynamic_values(&body_str)
235                } else {
236                    body_str
237                }
238            })
239        });
240
241        Ok(ResponseTemplate {
242            status_code: response.status_code,
243            headers,
244            body,
245            content_type,
246        })
247    }
248
249    /// Process path to extract dynamic segments
250    fn process_path(&self, path: &str) -> String {
251        if self.detect_dynamic_values {
252            // Replace UUIDs in path with template variable
253            let path = self.uuid_pattern.replace_all(path, "{{uuid}}");
254            // Replace numeric IDs with template variable (common pattern)
255            let numeric_id_pattern = Regex::new(r"/\d+").unwrap();
256            let path = numeric_id_pattern.replace_all(&path, "/{{id}}");
257            path.to_string()
258        } else {
259            path.to_string()
260        }
261    }
262
263    /// Replace dynamic values in text with template variables
264    fn replace_dynamic_values(&self, text: &str) -> String {
265        let mut result = text.to_string();
266
267        // Replace UUIDs
268        result = self.uuid_pattern.replace_all(&result, "{{uuid}}").to_string();
269
270        // Replace timestamps
271        result = self.timestamp_pattern.replace_all(&result, "{{now}}").to_string();
272
273        // Try to parse as JSON and replace common dynamic fields
274        if let Ok(json_value) = serde_json::from_str::<Value>(&result) {
275            if let Some(processed) = self.process_json_value(&json_value) {
276                if let Ok(json_str) = serde_json::to_string_pretty(&processed) {
277                    return json_str;
278                }
279            }
280        }
281
282        result
283    }
284
285    /// Process JSON value to replace dynamic fields
286    fn process_json_value(&self, value: &Value) -> Option<Value> {
287        match value {
288            Value::Object(map) => {
289                let mut processed = serde_json::Map::new();
290                for (key, val) in map {
291                    let processed_val = self.process_json_value(val)?;
292                    processed.insert(key.clone(), processed_val);
293                }
294                Some(Value::Object(processed))
295            }
296            Value::Array(arr) => {
297                let processed: Vec<Value> =
298                    arr.iter().filter_map(|v| self.process_json_value(v)).collect();
299                Some(Value::Array(processed))
300            }
301            Value::String(s) => {
302                // Check if it's a UUID
303                if self.uuid_pattern.is_match(s) {
304                    Some(Value::String("{{uuid}}".to_string()))
305                }
306                // Check if it's a timestamp
307                else if self.timestamp_pattern.is_match(s) {
308                    Some(Value::String("{{now}}".to_string()))
309                }
310                // Check if it looks like an ID (numeric string)
311                else if s.chars().all(|c| c.is_ascii_digit()) && s.len() > 3 {
312                    Some(Value::String("{{id}}".to_string()))
313                } else {
314                    Some(Value::String(s.clone()))
315                }
316            }
317            _ => Some(value.clone()),
318        }
319    }
320
321    /// Generate identifier from request
322    fn generate_identifier(&self, request: &crate::models::RecordedRequest) -> String {
323        // Create a simple identifier from method and path
324        let base = format!("{}-{}", request.method.to_lowercase(), request.path);
325        base.replace('/', "-")
326            .replace(':', "")
327            .replace('{', "")
328            .replace('}', "")
329            .chars()
330            .take(50)
331            .collect()
332    }
333
334    /// Convert stub mapping to YAML string
335    pub fn to_yaml(&self, stub: &StubMapping) -> Result<String> {
336        serde_yaml::to_string(stub).map_err(|e| {
337            crate::RecorderError::InvalidFilter(format!("YAML serialization error: {}", e))
338        })
339    }
340
341    /// Convert stub mapping to JSON string
342    pub fn to_json(&self, stub: &StubMapping) -> Result<String> {
343        serde_json::to_string_pretty(stub).map_err(|e| crate::RecorderError::Serialization(e))
344    }
345
346    /// Convert stub mapping to string in specified format
347    pub fn to_string(&self, stub: &StubMapping, format: StubFormat) -> Result<String> {
348        match format {
349            StubFormat::Yaml => self.to_yaml(stub),
350            StubFormat::Json => self.to_json(stub),
351        }
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use crate::models::{Protocol, RecordedRequest, RecordedResponse};
359    use chrono::Utc;
360
361    fn create_test_exchange() -> RecordedExchange {
362        let request = RecordedRequest {
363            id: "test-123".to_string(),
364            protocol: Protocol::Http,
365            timestamp: Utc::now(),
366            method: "GET".to_string(),
367            path: "/api/users/123".to_string(),
368            query_params: Some(r#"{"page": "1"}"#.to_string()),
369            headers: r#"{"content-type": "application/json", "authorization": "Bearer token123"}"#
370                .to_string(),
371            body: None,
372            body_encoding: "utf8".to_string(),
373            client_ip: None,
374            trace_id: None,
375            span_id: None,
376            duration_ms: Some(50),
377            status_code: Some(200),
378            tags: Some(r#"["api", "users"]"#.to_string()),
379        };
380
381        let response = RecordedResponse {
382            request_id: "test-123".to_string(),
383            status_code: 200,
384            headers: r#"{"content-type": "application/json"}"#.to_string(),
385            body: Some(
386                r#"{"id": "123", "name": "John", "created_at": "2024-01-01T00:00:00Z"}"#
387                    .to_string(),
388            ),
389            body_encoding: "utf8".to_string(),
390            size_bytes: 100,
391            timestamp: Utc::now(),
392        };
393
394        RecordedExchange {
395            request,
396            response: Some(response),
397        }
398    }
399
400    #[test]
401    fn test_convert_basic() {
402        let converter = StubMappingConverter::new(true);
403        let exchange = create_test_exchange();
404        let stub = converter.convert(&exchange).unwrap();
405
406        assert_eq!(stub.request.method, "GET");
407        assert_eq!(stub.request.path, "/api/users/{{id}}");
408        assert_eq!(stub.response.status_code, 200);
409    }
410
411    #[test]
412    fn test_uuid_detection() {
413        let converter = StubMappingConverter::new(true);
414        let text = "User ID: 550e8400-e29b-41d4-a716-446655440000";
415        let result = converter.replace_dynamic_values(text);
416        assert!(result.contains("{{uuid}}"));
417    }
418
419    #[test]
420    fn test_timestamp_detection() {
421        let converter = StubMappingConverter::new(true);
422        let text = "Created at: 2024-01-01T00:00:00Z";
423        let result = converter.replace_dynamic_values(text);
424        assert!(result.contains("{{now}}"));
425    }
426}