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