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::io_with_context(
85 format!("reading fixture {}", fixture_path.display()),
86 e.to_string(),
87 )
88 })?;
89
90 let recorded_request: RecordedRequest = serde_json::from_str(&content).map_err(|e| {
91 Error::config(format!("Failed to parse fixture {}: {}", fixture_path.display(), e))
92 })?;
93
94 Ok(Some(recorded_request))
95 }
96}
97
98pub struct RecordHandler {
100 fixtures_dir: PathBuf,
102 enabled: bool,
104 record_get_only: bool,
106}
107
108impl RecordHandler {
109 pub fn new(fixtures_dir: PathBuf, enabled: bool, record_get_only: bool) -> Self {
111 Self {
112 fixtures_dir,
113 enabled,
114 record_get_only,
115 }
116 }
117
118 pub fn should_record(&self, method: &Method) -> bool {
120 if !self.enabled {
121 return false;
122 }
123
124 if self.record_get_only {
125 method == Method::GET
126 } else {
127 true
128 }
129 }
130
131 pub async fn record_request(
133 &self,
134 fingerprint: &RequestFingerprint,
135 status_code: u16,
136 response_headers: &HeaderMap,
137 response_body: &str,
138 metadata: Option<HashMap<String, String>>,
139 ) -> Result<()> {
140 if !self.should_record(
141 &Method::from_bytes(fingerprint.method.as_bytes()).unwrap_or(Method::GET),
142 ) {
143 return Ok(());
144 }
145
146 let fixture_path = self.get_fixture_path(fingerprint);
147
148 if let Some(parent) = fixture_path.parent() {
150 fs::create_dir_all(parent).await.map_err(|e| {
151 Error::io_with_context(
152 format!("creating directory {}", parent.display()),
153 e.to_string(),
154 )
155 })?;
156 }
157
158 let mut response_headers_map = HashMap::new();
160 for (key, value) in response_headers.iter() {
161 let key_str = key.as_str();
162 if let Ok(value_str) = value.to_str() {
163 response_headers_map.insert(key_str.to_string(), value_str.to_string());
164 }
165 }
166
167 let recorded_request = RecordedRequest {
168 fingerprint: fingerprint.clone(),
169 timestamp: chrono::Utc::now(),
170 status_code,
171 response_headers: response_headers_map,
172 response_body: response_body.to_string(),
173 metadata: metadata.unwrap_or_default(),
174 };
175
176 let content = serde_json::to_string_pretty(&recorded_request)
177 .map_err(|e| Error::internal(format!("Failed to serialize recorded request: {}", e)))?;
178
179 fs::write(&fixture_path, content).await.map_err(|e| {
180 Error::io_with_context(
181 format!("writing fixture {}", fixture_path.display()),
182 e.to_string(),
183 )
184 })?;
185
186 tracing::info!("Recorded request to {}", fixture_path.display());
187 Ok(())
188 }
189
190 fn get_fixture_path(&self, fingerprint: &RequestFingerprint) -> PathBuf {
192 let hash = fingerprint.to_hash();
193 let method = fingerprint.method.to_lowercase();
194 let path_hash = fingerprint.path.replace(['/', ':'], "_");
195
196 self.fixtures_dir
197 .join("http")
198 .join(&method)
199 .join(&path_hash)
200 .join(format!("{}.json", hash))
201 }
202}
203
204pub struct RecordReplayHandler {
206 replay_handler: ReplayHandler,
207 record_handler: RecordHandler,
208}
209
210impl RecordReplayHandler {
211 pub fn new(
213 fixtures_dir: PathBuf,
214 replay_enabled: bool,
215 record_enabled: bool,
216 record_get_only: bool,
217 ) -> Self {
218 Self {
219 replay_handler: ReplayHandler::new(fixtures_dir.clone(), replay_enabled),
220 record_handler: RecordHandler::new(fixtures_dir, record_enabled, record_get_only),
221 }
222 }
223
224 pub fn replay_handler(&self) -> &ReplayHandler {
226 &self.replay_handler
227 }
228
229 pub fn record_handler(&self) -> &RecordHandler {
231 &self.record_handler
232 }
233}
234
235pub async fn list_fixtures(fixtures_dir: &Path) -> Result<Vec<RecordedRequest>> {
237 let mut fixtures = Vec::new();
238
239 if !fixtures_dir.exists() {
240 return Ok(fixtures);
241 }
242
243 let http_dir = fixtures_dir.join("http");
244 if !http_dir.exists() {
245 return Ok(fixtures);
246 }
247
248 let walker = globwalk::GlobWalkerBuilder::from_patterns(&http_dir, &["**/*.json"])
250 .build()
251 .map_err(|e| Error::io_with_context("building glob walker for fixtures", e.to_string()))?;
252
253 for entry in walker {
254 let entry =
255 entry.map_err(|e| Error::io_with_context("reading directory entry", e.to_string()))?;
256 let path = entry.path();
257
258 if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
259 if let Ok(content) = fs::read_to_string(&path).await {
260 if let Ok(recorded_request) = serde_json::from_str::<RecordedRequest>(&content) {
261 fixtures.push(recorded_request);
262 }
263 }
264 }
265 }
266
267 fixtures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
269
270 Ok(fixtures)
271}
272
273pub async fn clean_old_fixtures(fixtures_dir: &Path, older_than_days: u32) -> Result<usize> {
275 let cutoff_date = chrono::Utc::now() - chrono::Duration::days(older_than_days as i64);
276 let mut cleaned_count = 0;
277
278 if !fixtures_dir.exists() {
279 return Ok(0);
280 }
281
282 let http_dir = fixtures_dir.join("http");
283 if !http_dir.exists() {
284 return Ok(0);
285 }
286
287 let mut entries = fs::read_dir(&http_dir)
288 .await
289 .map_err(|e| Error::io_with_context("reading fixtures directory", e.to_string()))?;
290
291 while let Some(entry) = entries
292 .next_entry()
293 .await
294 .map_err(|e| Error::io_with_context("reading directory entry", e.to_string()))?
295 {
296 let path = entry.path();
297 if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
298 if let Ok(content) = fs::read_to_string(&path).await {
299 if let Ok(recorded_request) = serde_json::from_str::<RecordedRequest>(&content) {
300 if recorded_request.timestamp < cutoff_date {
301 if let Err(e) = fs::remove_file(&path).await {
302 tracing::warn!(
303 "Failed to remove old fixture {}: {}",
304 path.display(),
305 e
306 );
307 } else {
308 cleaned_count += 1;
309 }
310 }
311 }
312 }
313 }
314 }
315
316 Ok(cleaned_count)
317}
318
319pub async fn list_ready_fixtures(fixtures_dir: &Path) -> Result<Vec<RecordedRequest>> {
321 let mut fixtures = Vec::new();
322
323 if !fixtures_dir.exists() {
324 return Ok(fixtures);
325 }
326
327 let http_dir = fixtures_dir.join("http");
328 if !http_dir.exists() {
329 return Ok(fixtures);
330 }
331
332 let walker = globwalk::GlobWalkerBuilder::from_patterns(&http_dir, &["**/*.json"])
334 .build()
335 .map_err(|e| Error::io_with_context("building glob walker for fixtures", e.to_string()))?;
336
337 for entry in walker {
338 let entry =
339 entry.map_err(|e| Error::io_with_context("reading directory entry", e.to_string()))?;
340 let path = entry.path();
341
342 if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
343 if let Ok(content) = fs::read_to_string(&path).await {
344 if let Ok(recorded_request) = serde_json::from_str::<RecordedRequest>(&content) {
345 if recorded_request.metadata.get("smoke_test").is_some_and(|v| v == "true") {
347 fixtures.push(recorded_request);
348 }
349 }
350 }
351 }
352 }
353
354 fixtures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
356
357 Ok(fixtures)
358}
359
360pub async fn list_smoke_endpoints(fixtures_dir: &Path) -> Result<Vec<(String, String, String)>> {
362 let fixtures = list_ready_fixtures(fixtures_dir).await?;
363 let mut endpoints = Vec::new();
364
365 for fixture in fixtures {
366 let method = fixture.fingerprint.method.clone();
367 let path = fixture.fingerprint.path.clone();
368 let name = fixture
369 .metadata
370 .get("name")
371 .cloned()
372 .unwrap_or_else(|| format!("{} {}", method, path));
373
374 endpoints.push((method, path, name));
375 }
376
377 Ok(endpoints)
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use axum::http::Uri;
384 use tempfile::TempDir;
385
386 #[tokio::test]
387 async fn test_record_and_replay() {
388 let temp_dir = TempDir::new().unwrap();
389 let fixtures_dir = temp_dir.path().to_path_buf();
390
391 let handler = RecordReplayHandler::new(fixtures_dir.clone(), true, true, false);
392
393 let method = Method::GET;
395 let uri: Uri = "/api/users?page=1".parse().unwrap();
396 let headers = HeaderMap::new();
397 let fingerprint = RequestFingerprint::new(method, &uri, &headers, None);
398
399 let mut response_headers = HeaderMap::new();
401 response_headers.insert("content-type", "application/json".parse().unwrap());
402
403 handler
404 .record_handler()
405 .record_request(&fingerprint, 200, &response_headers, r#"{"users": []}"#, None)
406 .await
407 .unwrap();
408
409 assert!(handler.replay_handler().has_fixture(&fingerprint).await);
411
412 let recorded = handler.replay_handler().load_fixture(&fingerprint).await.unwrap().unwrap();
414 assert_eq!(recorded.status_code, 200);
415 assert_eq!(recorded.response_body, r#"{"users": []}"#);
416 }
417
418 #[tokio::test]
419 async fn test_list_fixtures() {
420 let temp_dir = TempDir::new().unwrap();
421 let fixtures_dir = temp_dir.path().to_path_buf();
422
423 let handler = RecordReplayHandler::new(fixtures_dir.clone(), true, true, false);
424
425 for i in 0..3 {
427 let method = Method::GET;
428 let uri: Uri = format!("/api/users/{}", i).parse().unwrap();
429 let headers = HeaderMap::new();
430 let fingerprint = RequestFingerprint::new(method, &uri, &headers, None);
431
432 handler
433 .record_handler()
434 .record_request(
435 &fingerprint,
436 200,
437 &HeaderMap::new(),
438 &format!(r#"{{"id": {}}}"#, i),
439 None,
440 )
441 .await
442 .unwrap();
443 }
444
445 let fixtures = list_fixtures(&fixtures_dir).await.unwrap();
447 assert_eq!(fixtures.len(), 3);
448 }
449}