mockforge_core/
record_replay.rs

1//! Record and replay functionality for HTTP requests and responses
2//! Implements the Replay and Record parts of the priority chain.
3
4use 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/// Recorded request/response pair
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct RecordedRequest {
14    /// Request fingerprint
15    pub fingerprint: RequestFingerprint,
16    /// Request timestamp
17    pub timestamp: chrono::DateTime<chrono::Utc>,
18    /// Response status code
19    pub status_code: u16,
20    /// Response headers
21    pub response_headers: HashMap<String, String>,
22    /// Response body
23    pub response_body: String,
24    /// Additional metadata
25    pub metadata: HashMap<String, String>,
26}
27
28/// Replay handler for serving recorded requests
29pub struct ReplayHandler {
30    /// Directory containing recorded fixtures
31    fixtures_dir: PathBuf,
32    /// Whether replay is enabled
33    enabled: bool,
34}
35
36impl ReplayHandler {
37    /// Create a new replay handler
38    pub fn new(fixtures_dir: PathBuf, enabled: bool) -> Self {
39        Self {
40            fixtures_dir,
41            enabled,
42        }
43    }
44
45    /// Get the fixture path for a request fingerprint
46    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    /// Check if a fixture exists for the given fingerprint
59    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    /// Load a recorded request from fixture
69    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
95/// Record handler for saving requests and responses
96pub struct RecordHandler {
97    /// Directory to save recorded fixtures
98    fixtures_dir: PathBuf,
99    /// Whether recording is enabled
100    enabled: bool,
101    /// Whether to record only GET requests
102    record_get_only: bool,
103}
104
105impl RecordHandler {
106    /// Create a new record handler
107    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    /// Check if a request should be recorded
116    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    /// Record a request and response
129    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        // Create directory if it doesn't exist
146        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        // Convert response headers to HashMap
153        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    /// Get the fixture path for a request fingerprint
182    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
195/// Combined record/replay handler
196pub struct RecordReplayHandler {
197    replay_handler: ReplayHandler,
198    record_handler: RecordHandler,
199}
200
201impl RecordReplayHandler {
202    /// Create a new record/replay handler
203    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    /// Get the replay handler
216    pub fn replay_handler(&self) -> &ReplayHandler {
217        &self.replay_handler
218    }
219
220    /// Get the record handler
221    pub fn record_handler(&self) -> &RecordHandler {
222        &self.record_handler
223    }
224}
225
226/// List all available fixtures
227pub 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    // Use globwalk to find all JSON files recursively
240    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    // Sort by timestamp (newest first)
259    fixtures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
260
261    Ok(fixtures)
262}
263
264/// Clean old fixtures (older than specified days)
265pub 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
310/// List ready-to-run fixtures (fixtures that can be used for smoke testing)
311pub 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    // Use globwalk to find all JSON files recursively
324    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                    // Check if this is a ready-to-run fixture (has a smoke_test metadata flag)
337                    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    // Sort by timestamp (newest first)
346    fixtures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
347
348    Ok(fixtures)
349}
350
351/// Create a smoke test endpoint listing
352pub 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        // Create a test fingerprint
385        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        // Record a request
391        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        // Check if fixture exists
401        assert!(handler.replay_handler().has_fixture(&fingerprint).await);
402
403        // Load the fixture
404        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        // Record a few requests
417        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        // List fixtures
437        let fixtures = list_fixtures(&fixtures_dir).await.unwrap();
438        assert_eq!(fixtures.len(), 3);
439    }
440}