1use 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
12static 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
18static 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
24static NUMERIC_ID_PATTERN: Lazy<Regex> =
26 Lazy::new(|| Regex::new(r"/\d+").expect("Numeric ID regex pattern should be valid"));
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum StubFormat {
31 Yaml,
33 Json,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct StubMapping {
40 pub identifier: String,
42 pub name: String,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub description: Option<String>,
47 pub request: RequestMatcher,
49 pub response: ResponseTemplate,
51 #[serde(skip_serializing_if = "Option::is_none")]
53 pub metadata: Option<HashMap<String, String>>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct RequestMatcher {
59 pub method: String,
61 pub path: String,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub query_params: Option<HashMap<String, String>>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub headers: Option<HashMap<String, String>>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub body_pattern: Option<String>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ResponseTemplate {
77 pub status_code: i32,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub headers: Option<HashMap<String, String>>,
82 #[serde(skip_serializing_if = "Option::is_none")]
84 pub body: Option<String>,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub content_type: Option<String>,
88}
89
90pub struct StubMappingConverter {
92 detect_dynamic_values: bool,
94}
95
96impl StubMappingConverter {
97 pub fn new(detect_dynamic_values: bool) -> Self {
99 Self {
100 detect_dynamic_values,
101 }
102 }
103
104 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 let request_matcher = self.extract_request_matcher(request)?;
113
114 let response_template = self.extract_response_template(response)?;
116
117 let identifier = self.generate_identifier(request);
119
120 let name = format!("{} {}", request.method, request.path);
122
123 let description = request.tags_vec().first().map(|s| format!("Recorded from {}", s));
125
126 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 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 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 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 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 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 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 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 fn process_path(&self, path: &str) -> String {
253 if self.detect_dynamic_values {
254 let path = UUID_PATTERN.replace_all(path, "{{uuid}}");
256 let path = NUMERIC_ID_PATTERN.replace_all(&path, "/{{id}}");
258 path.to_string()
259 } else {
260 path.to_string()
261 }
262 }
263
264 fn replace_dynamic_values(&self, text: &str) -> String {
266 let mut result = text.to_string();
267
268 result = UUID_PATTERN.replace_all(&result, "{{uuid}}").to_string();
270
271 result = TIMESTAMP_PATTERN.replace_all(&result, "{{now}}").to_string();
273
274 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 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 if UUID_PATTERN.is_match(s) {
305 Some(Value::String("{{uuid}}".to_string()))
306 }
307 else if TIMESTAMP_PATTERN.is_match(s) {
309 Some(Value::String("{{now}}".to_string()))
310 }
311 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 fn generate_identifier(&self, request: &crate::models::RecordedRequest) -> String {
324 let base = format!("{}-{}", request.method.to_lowercase(), request.path);
326 base.replace('/', "-").replace([':', '{', '}'], "").chars().take(50).collect()
327 }
328
329 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 pub fn to_json(&self, stub: &StubMapping) -> Result<String> {
338 serde_json::to_string_pretty(stub).map_err(crate::RecorderError::Serialization)
339 }
340
341 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}