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