1use crate::{Error, RequestFingerprint, Result};
5use axum::http::{HeaderMap, Method};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9use tokio::fs;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct RecordedRequest {
14 pub fingerprint: RequestFingerprint,
16 pub timestamp: chrono::DateTime<chrono::Utc>,
18 pub status_code: u16,
20 pub response_headers: HashMap<String, String>,
22 pub response_body: String,
24 pub metadata: HashMap<String, String>,
26}
27
28pub struct ReplayHandler {
30 fixtures_dir: PathBuf,
32 enabled: bool,
34}
35
36impl ReplayHandler {
37 pub fn new(fixtures_dir: PathBuf, enabled: bool) -> Self {
39 Self {
40 fixtures_dir,
41 enabled,
42 }
43 }
44
45 fn get_fixture_path(&self, fingerprint: &RequestFingerprint) -> PathBuf {
47 let hash = fingerprint.to_hash();
48 let method = fingerprint.method.to_lowercase();
49 let path_hash = fingerprint.path.replace(['/', ':'], "_");
50
51 self.fixtures_dir
52 .join("http")
53 .join(&method)
54 .join(&path_hash)
55 .join(format!("{}.json", hash))
56 }
57
58 pub async fn has_fixture(&self, fingerprint: &RequestFingerprint) -> bool {
60 if !self.enabled {
61 return false;
62 }
63
64 let fixture_path = self.get_fixture_path(fingerprint);
65 fixture_path.exists()
66 }
67
68 pub async fn load_fixture(
70 &self,
71 fingerprint: &RequestFingerprint,
72 ) -> Result<Option<RecordedRequest>> {
73 if !self.enabled {
74 return Ok(None);
75 }
76
77 let fixture_path = self.get_fixture_path(fingerprint);
78
79 if !fixture_path.exists() {
80 return Ok(None);
81 }
82
83 let content = fs::read_to_string(&fixture_path).await.map_err(|e| {
84 Error::generic(format!("Failed to read fixture {}: {}", fixture_path.display(), e))
85 })?;
86
87 let recorded_request: RecordedRequest = serde_json::from_str(&content).map_err(|e| {
88 Error::generic(format!("Failed to parse fixture {}: {}", fixture_path.display(), e))
89 })?;
90
91 Ok(Some(recorded_request))
92 }
93}
94
95pub struct RecordHandler {
97 fixtures_dir: PathBuf,
99 enabled: bool,
101 record_get_only: bool,
103}
104
105impl RecordHandler {
106 pub fn new(fixtures_dir: PathBuf, enabled: bool, record_get_only: bool) -> Self {
108 Self {
109 fixtures_dir,
110 enabled,
111 record_get_only,
112 }
113 }
114
115 pub fn should_record(&self, method: &Method) -> bool {
117 if !self.enabled {
118 return false;
119 }
120
121 if self.record_get_only {
122 method == Method::GET
123 } else {
124 true
125 }
126 }
127
128 pub async fn record_request(
130 &self,
131 fingerprint: &RequestFingerprint,
132 status_code: u16,
133 response_headers: &HeaderMap,
134 response_body: &str,
135 metadata: Option<HashMap<String, String>>,
136 ) -> Result<()> {
137 if !self.should_record(
138 &Method::from_bytes(fingerprint.method.as_bytes()).unwrap_or(Method::GET),
139 ) {
140 return Ok(());
141 }
142
143 let fixture_path = self.get_fixture_path(fingerprint);
144
145 if let Some(parent) = fixture_path.parent() {
147 fs::create_dir_all(parent).await.map_err(|e| {
148 Error::generic(format!("Failed to create directory {}: {}", parent.display(), e))
149 })?;
150 }
151
152 let mut response_headers_map = HashMap::new();
154 for (key, value) in response_headers.iter() {
155 let key_str = key.as_str();
156 if let Ok(value_str) = value.to_str() {
157 response_headers_map.insert(key_str.to_string(), value_str.to_string());
158 }
159 }
160
161 let recorded_request = RecordedRequest {
162 fingerprint: fingerprint.clone(),
163 timestamp: chrono::Utc::now(),
164 status_code,
165 response_headers: response_headers_map,
166 response_body: response_body.to_string(),
167 metadata: metadata.unwrap_or_default(),
168 };
169
170 let content = serde_json::to_string_pretty(&recorded_request)
171 .map_err(|e| Error::generic(format!("Failed to serialize recorded request: {}", e)))?;
172
173 fs::write(&fixture_path, content).await.map_err(|e| {
174 Error::generic(format!("Failed to write fixture {}: {}", fixture_path.display(), e))
175 })?;
176
177 tracing::info!("Recorded request to {}", fixture_path.display());
178 Ok(())
179 }
180
181 fn get_fixture_path(&self, fingerprint: &RequestFingerprint) -> PathBuf {
183 let hash = fingerprint.to_hash();
184 let method = fingerprint.method.to_lowercase();
185 let path_hash = fingerprint.path.replace(['/', ':'], "_");
186
187 self.fixtures_dir
188 .join("http")
189 .join(&method)
190 .join(&path_hash)
191 .join(format!("{}.json", hash))
192 }
193}
194
195pub struct RecordReplayHandler {
197 replay_handler: ReplayHandler,
198 record_handler: RecordHandler,
199}
200
201impl RecordReplayHandler {
202 pub fn new(
204 fixtures_dir: PathBuf,
205 replay_enabled: bool,
206 record_enabled: bool,
207 record_get_only: bool,
208 ) -> Self {
209 Self {
210 replay_handler: ReplayHandler::new(fixtures_dir.clone(), replay_enabled),
211 record_handler: RecordHandler::new(fixtures_dir, record_enabled, record_get_only),
212 }
213 }
214
215 pub fn replay_handler(&self) -> &ReplayHandler {
217 &self.replay_handler
218 }
219
220 pub fn record_handler(&self) -> &RecordHandler {
222 &self.record_handler
223 }
224}
225
226pub async fn list_fixtures(fixtures_dir: &Path) -> Result<Vec<RecordedRequest>> {
228 let mut fixtures = Vec::new();
229
230 if !fixtures_dir.exists() {
231 return Ok(fixtures);
232 }
233
234 let http_dir = fixtures_dir.join("http");
235 if !http_dir.exists() {
236 return Ok(fixtures);
237 }
238
239 let walker = globwalk::GlobWalkerBuilder::from_patterns(&http_dir, &["**/*.json"])
241 .build()
242 .map_err(|e| Error::generic(format!("Failed to build glob walker: {}", e)))?;
243
244 for entry in walker {
245 let entry =
246 entry.map_err(|e| Error::generic(format!("Failed to read directory entry: {}", e)))?;
247 let path = entry.path();
248
249 if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
250 if let Ok(content) = fs::read_to_string(&path).await {
251 if let Ok(recorded_request) = serde_json::from_str::<RecordedRequest>(&content) {
252 fixtures.push(recorded_request);
253 }
254 }
255 }
256 }
257
258 fixtures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
260
261 Ok(fixtures)
262}
263
264pub async fn clean_old_fixtures(fixtures_dir: &Path, older_than_days: u32) -> Result<usize> {
266 let cutoff_date = chrono::Utc::now() - chrono::Duration::days(older_than_days as i64);
267 let mut cleaned_count = 0;
268
269 if !fixtures_dir.exists() {
270 return Ok(0);
271 }
272
273 let http_dir = fixtures_dir.join("http");
274 if !http_dir.exists() {
275 return Ok(0);
276 }
277
278 let mut entries = fs::read_dir(&http_dir)
279 .await
280 .map_err(|e| Error::generic(format!("Failed to read fixtures directory: {}", e)))?;
281
282 while let Some(entry) = entries
283 .next_entry()
284 .await
285 .map_err(|e| Error::generic(format!("Failed to read directory entry: {}", e)))?
286 {
287 let path = entry.path();
288 if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
289 if let Ok(content) = fs::read_to_string(&path).await {
290 if let Ok(recorded_request) = serde_json::from_str::<RecordedRequest>(&content) {
291 if recorded_request.timestamp < cutoff_date {
292 if let Err(e) = fs::remove_file(&path).await {
293 tracing::warn!(
294 "Failed to remove old fixture {}: {}",
295 path.display(),
296 e
297 );
298 } else {
299 cleaned_count += 1;
300 }
301 }
302 }
303 }
304 }
305 }
306
307 Ok(cleaned_count)
308}
309
310pub async fn list_ready_fixtures(fixtures_dir: &Path) -> Result<Vec<RecordedRequest>> {
312 let mut fixtures = Vec::new();
313
314 if !fixtures_dir.exists() {
315 return Ok(fixtures);
316 }
317
318 let http_dir = fixtures_dir.join("http");
319 if !http_dir.exists() {
320 return Ok(fixtures);
321 }
322
323 let walker = globwalk::GlobWalkerBuilder::from_patterns(&http_dir, &["**/*.json"])
325 .build()
326 .map_err(|e| Error::generic(format!("Failed to build glob walker: {}", e)))?;
327
328 for entry in walker {
329 let entry =
330 entry.map_err(|e| Error::generic(format!("Failed to read directory entry: {}", e)))?;
331 let path = entry.path();
332
333 if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
334 if let Ok(content) = fs::read_to_string(&path).await {
335 if let Ok(recorded_request) = serde_json::from_str::<RecordedRequest>(&content) {
336 if recorded_request.metadata.get("smoke_test").is_some_and(|v| v == "true") {
338 fixtures.push(recorded_request);
339 }
340 }
341 }
342 }
343 }
344
345 fixtures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
347
348 Ok(fixtures)
349}
350
351pub async fn list_smoke_endpoints(fixtures_dir: &Path) -> Result<Vec<(String, String, String)>> {
353 let fixtures = list_ready_fixtures(fixtures_dir).await?;
354 let mut endpoints = Vec::new();
355
356 for fixture in fixtures {
357 let method = fixture.fingerprint.method.clone();
358 let path = fixture.fingerprint.path.clone();
359 let name = fixture
360 .metadata
361 .get("name")
362 .cloned()
363 .unwrap_or_else(|| format!("{} {}", method, path));
364
365 endpoints.push((method, path, name));
366 }
367
368 Ok(endpoints)
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use axum::http::Uri;
375 use tempfile::TempDir;
376
377 #[tokio::test]
378 async fn test_record_and_replay() {
379 let temp_dir = TempDir::new().unwrap();
380 let fixtures_dir = temp_dir.path().to_path_buf();
381
382 let handler = RecordReplayHandler::new(fixtures_dir.clone(), true, true, false);
383
384 let method = Method::GET;
386 let uri: Uri = "/api/users?page=1".parse().unwrap();
387 let headers = HeaderMap::new();
388 let fingerprint = RequestFingerprint::new(method, &uri, &headers, None);
389
390 let mut response_headers = HeaderMap::new();
392 response_headers.insert("content-type", "application/json".parse().unwrap());
393
394 handler
395 .record_handler()
396 .record_request(&fingerprint, 200, &response_headers, r#"{"users": []}"#, None)
397 .await
398 .unwrap();
399
400 assert!(handler.replay_handler().has_fixture(&fingerprint).await);
402
403 let recorded = handler.replay_handler().load_fixture(&fingerprint).await.unwrap().unwrap();
405 assert_eq!(recorded.status_code, 200);
406 assert_eq!(recorded.response_body, r#"{"users": []}"#);
407 }
408
409 #[tokio::test]
410 async fn test_list_fixtures() {
411 let temp_dir = TempDir::new().unwrap();
412 let fixtures_dir = temp_dir.path().to_path_buf();
413
414 let handler = RecordReplayHandler::new(fixtures_dir.clone(), true, true, false);
415
416 for i in 0..3 {
418 let method = Method::GET;
419 let uri: Uri = format!("/api/users/{}", i).parse().unwrap();
420 let headers = HeaderMap::new();
421 let fingerprint = RequestFingerprint::new(method, &uri, &headers, None);
422
423 handler
424 .record_handler()
425 .record_request(
426 &fingerprint,
427 200,
428 &HeaderMap::new(),
429 &format!(r#"{{"id": {}}}"#, i),
430 None,
431 )
432 .await
433 .unwrap();
434 }
435
436 let fixtures = list_fixtures(&fixtures_dir).await.unwrap();
438 assert_eq!(fixtures.len(), 3);
439 }
440}