Skip to main content

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::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
98/// Record handler for saving requests and responses
99pub struct RecordHandler {
100    /// Directory to save recorded fixtures
101    fixtures_dir: PathBuf,
102    /// Whether recording is enabled
103    enabled: bool,
104    /// Whether to record only GET requests
105    record_get_only: bool,
106}
107
108impl RecordHandler {
109    /// Create a new record handler
110    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    /// Check if a request should be recorded
119    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    /// Record a request and response
132    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        // Create directory if it doesn't exist
149        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        // Convert response headers to HashMap
159        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    /// Get the fixture path for a request fingerprint
191    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
204/// Combined record/replay handler
205pub struct RecordReplayHandler {
206    replay_handler: ReplayHandler,
207    record_handler: RecordHandler,
208}
209
210impl RecordReplayHandler {
211    /// Create a new record/replay handler
212    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    /// Get the replay handler
225    pub fn replay_handler(&self) -> &ReplayHandler {
226        &self.replay_handler
227    }
228
229    /// Get the record handler
230    pub fn record_handler(&self) -> &RecordHandler {
231        &self.record_handler
232    }
233}
234
235/// List all available fixtures
236pub 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    // Use globwalk to find all JSON files recursively
249    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    // Sort by timestamp (newest first)
268    fixtures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
269
270    Ok(fixtures)
271}
272
273/// Clean old fixtures (older than specified days)
274pub 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
319/// List ready-to-run fixtures (fixtures that can be used for smoke testing)
320pub 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    // Use globwalk to find all JSON files recursively
333    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                    // Check if this is a ready-to-run fixture (has a smoke_test metadata flag)
346                    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    // Sort by timestamp (newest first)
355    fixtures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
356
357    Ok(fixtures)
358}
359
360/// Create a smoke test endpoint listing
361pub 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        // Create a test fingerprint
394        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        // Record a request
400        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        // Check if fixture exists
410        assert!(handler.replay_handler().has_fixture(&fingerprint).await);
411
412        // Load the fixture
413        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        // Record a few requests
426        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        // List fixtures
446        let fixtures = list_fixtures(&fixtures_dir).await.unwrap();
447        assert_eq!(fixtures.len(), 3);
448    }
449}