1use crate::{models::RecordedExchange, Result};
6use regex::Regex;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum StubFormat {
14 Yaml,
16 Json,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct StubMapping {
23 pub identifier: String,
25 pub name: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub description: Option<String>,
30 pub request: RequestMatcher,
32 pub response: ResponseTemplate,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub metadata: Option<HashMap<String, String>>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct RequestMatcher {
42 pub method: String,
44 pub path: String,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub query_params: Option<HashMap<String, String>>,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub headers: Option<HashMap<String, String>>,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub body_pattern: Option<String>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ResponseTemplate {
60 pub status_code: i32,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 pub headers: Option<HashMap<String, String>>,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub body: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub content_type: Option<String>,
71}
72
73pub struct StubMappingConverter {
75 detect_dynamic_values: bool,
77 uuid_pattern: Regex,
79 timestamp_pattern: Regex,
81}
82
83impl StubMappingConverter {
84 pub fn new(detect_dynamic_values: bool) -> Self {
86 Self {
87 detect_dynamic_values,
88 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 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 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 let request_matcher = self.extract_request_matcher(request)?;
110
111 let response_template = self.extract_response_template(response)?;
113
114 let identifier = self.generate_identifier(request);
116
117 let name = format!("{} {}", request.method, request.path);
119
120 let description = request.tags_vec().first().map(|s| format!("Recorded from {}", s));
122
123 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 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 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 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 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 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 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 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 fn process_path(&self, path: &str) -> String {
250 if self.detect_dynamic_values {
251 let path = self.uuid_pattern.replace_all(path, "{{uuid}}");
253 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 fn replace_dynamic_values(&self, text: &str) -> String {
264 let mut result = text.to_string();
265
266 result = self.uuid_pattern.replace_all(&result, "{{uuid}}").to_string();
268
269 result = self.timestamp_pattern.replace_all(&result, "{{now}}").to_string();
271
272 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 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 if self.uuid_pattern.is_match(s) {
303 Some(Value::String("{{uuid}}".to_string()))
304 }
305 else if self.timestamp_pattern.is_match(s) {
307 Some(Value::String("{{now}}".to_string()))
308 }
309 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 fn generate_identifier(&self, request: &crate::models::RecordedRequest) -> String {
322 let base = format!("{}-{}", request.method.to_lowercase(), request.path);
324 base.replace('/', "-").replace([':', '{', '}'], "").chars().take(50).collect()
325 }
326
327 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 pub fn to_json(&self, stub: &StubMapping) -> Result<String> {
336 serde_json::to_string_pretty(stub).map_err(crate::RecorderError::Serialization)
337 }
338
339 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}