1use crate::{models::RecordedExchange, Result};
6use regex::Regex;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::HashMap;
10use uuid::Uuid;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum StubFormat {
15 Yaml,
17 Json,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct StubMapping {
24 pub identifier: String,
26 pub name: String,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub description: Option<String>,
31 pub request: RequestMatcher,
33 pub response: ResponseTemplate,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub metadata: Option<HashMap<String, String>>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct RequestMatcher {
43 pub method: String,
45 pub path: String,
47 #[serde(skip_serializing_if = "Option::is_none")]
49 pub query_params: Option<HashMap<String, String>>,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub headers: Option<HashMap<String, String>>,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub body_pattern: Option<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ResponseTemplate {
61 pub status_code: i32,
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub headers: Option<HashMap<String, String>>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub body: Option<String>,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub content_type: Option<String>,
72}
73
74pub struct StubMappingConverter {
76 detect_dynamic_values: bool,
78 uuid_pattern: Regex,
80 timestamp_pattern: Regex,
82}
83
84impl StubMappingConverter {
85 pub fn new(detect_dynamic_values: bool) -> Self {
87 Self {
88 detect_dynamic_values,
89 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 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 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 let request_matcher = self.extract_request_matcher(request)?;
111
112 let response_template = self.extract_response_template(response)?;
114
115 let identifier = self.generate_identifier(request);
117
118 let name = format!("{} {}", request.method, request.path);
120
121 let description = request.tags_vec().first().map(|s| format!("Recorded from {}", s));
123
124 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 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 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 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 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 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 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 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 fn process_path(&self, path: &str) -> String {
251 if self.detect_dynamic_values {
252 let path = self.uuid_pattern.replace_all(path, "{{uuid}}");
254 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 fn replace_dynamic_values(&self, text: &str) -> String {
265 let mut result = text.to_string();
266
267 result = self.uuid_pattern.replace_all(&result, "{{uuid}}").to_string();
269
270 result = self.timestamp_pattern.replace_all(&result, "{{now}}").to_string();
272
273 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 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 if self.uuid_pattern.is_match(s) {
304 Some(Value::String("{{uuid}}".to_string()))
305 }
306 else if self.timestamp_pattern.is_match(s) {
308 Some(Value::String("{{now}}".to_string()))
309 }
310 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 fn generate_identifier(&self, request: &crate::models::RecordedRequest) -> String {
323 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 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 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 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}