use crate::{Error, RequestFingerprint, Result};
use axum::http::{HeaderMap, Method};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordedRequest {
pub fingerprint: RequestFingerprint,
pub timestamp: chrono::DateTime<chrono::Utc>,
pub status_code: u16,
pub response_headers: HashMap<String, String>,
pub response_body: String,
pub metadata: HashMap<String, String>,
}
pub struct ReplayHandler {
fixtures_dir: PathBuf,
enabled: bool,
}
impl ReplayHandler {
pub fn new(fixtures_dir: PathBuf, enabled: bool) -> Self {
Self {
fixtures_dir,
enabled,
}
}
fn get_fixture_path(&self, fingerprint: &RequestFingerprint) -> PathBuf {
let hash = fingerprint.to_hash();
let method = fingerprint.method.to_lowercase();
let path_hash = fingerprint.path.replace(['/', ':'], "_");
self.fixtures_dir
.join("http")
.join(&method)
.join(&path_hash)
.join(format!("{}.json", hash))
}
pub async fn has_fixture(&self, fingerprint: &RequestFingerprint) -> bool {
if !self.enabled {
return false;
}
let fixture_path = self.get_fixture_path(fingerprint);
fixture_path.exists()
}
pub async fn load_fixture(
&self,
fingerprint: &RequestFingerprint,
) -> Result<Option<RecordedRequest>> {
if !self.enabled {
return Ok(None);
}
let fixture_path = self.get_fixture_path(fingerprint);
if !fixture_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&fixture_path).await.map_err(|e| {
Error::io_with_context(
format!("reading fixture {}", fixture_path.display()),
e.to_string(),
)
})?;
let recorded_request: RecordedRequest = serde_json::from_str(&content).map_err(|e| {
Error::config(format!("Failed to parse fixture {}: {}", fixture_path.display(), e))
})?;
Ok(Some(recorded_request))
}
}
pub struct RecordHandler {
fixtures_dir: PathBuf,
enabled: bool,
record_get_only: bool,
}
impl RecordHandler {
pub fn new(fixtures_dir: PathBuf, enabled: bool, record_get_only: bool) -> Self {
Self {
fixtures_dir,
enabled,
record_get_only,
}
}
pub fn should_record(&self, method: &Method) -> bool {
if !self.enabled {
return false;
}
if self.record_get_only {
method == Method::GET
} else {
true
}
}
pub async fn record_request(
&self,
fingerprint: &RequestFingerprint,
status_code: u16,
response_headers: &HeaderMap,
response_body: &str,
metadata: Option<HashMap<String, String>>,
) -> Result<()> {
if !self.should_record(
&Method::from_bytes(fingerprint.method.as_bytes()).unwrap_or(Method::GET),
) {
return Ok(());
}
let fixture_path = self.get_fixture_path(fingerprint);
if let Some(parent) = fixture_path.parent() {
fs::create_dir_all(parent).await.map_err(|e| {
Error::io_with_context(
format!("creating directory {}", parent.display()),
e.to_string(),
)
})?;
}
let mut response_headers_map = HashMap::new();
for (key, value) in response_headers.iter() {
let key_str = key.as_str();
if let Ok(value_str) = value.to_str() {
response_headers_map.insert(key_str.to_string(), value_str.to_string());
}
}
let recorded_request = RecordedRequest {
fingerprint: fingerprint.clone(),
timestamp: chrono::Utc::now(),
status_code,
response_headers: response_headers_map,
response_body: response_body.to_string(),
metadata: metadata.unwrap_or_default(),
};
let content = serde_json::to_string_pretty(&recorded_request)
.map_err(|e| Error::internal(format!("Failed to serialize recorded request: {}", e)))?;
fs::write(&fixture_path, content).await.map_err(|e| {
Error::io_with_context(
format!("writing fixture {}", fixture_path.display()),
e.to_string(),
)
})?;
tracing::info!("Recorded request to {}", fixture_path.display());
Ok(())
}
fn get_fixture_path(&self, fingerprint: &RequestFingerprint) -> PathBuf {
let hash = fingerprint.to_hash();
let method = fingerprint.method.to_lowercase();
let path_hash = fingerprint.path.replace(['/', ':'], "_");
self.fixtures_dir
.join("http")
.join(&method)
.join(&path_hash)
.join(format!("{}.json", hash))
}
}
pub struct RecordReplayHandler {
replay_handler: ReplayHandler,
record_handler: RecordHandler,
}
impl RecordReplayHandler {
pub fn new(
fixtures_dir: PathBuf,
replay_enabled: bool,
record_enabled: bool,
record_get_only: bool,
) -> Self {
Self {
replay_handler: ReplayHandler::new(fixtures_dir.clone(), replay_enabled),
record_handler: RecordHandler::new(fixtures_dir, record_enabled, record_get_only),
}
}
pub fn replay_handler(&self) -> &ReplayHandler {
&self.replay_handler
}
pub fn record_handler(&self) -> &RecordHandler {
&self.record_handler
}
}
pub async fn list_fixtures(fixtures_dir: &Path) -> Result<Vec<RecordedRequest>> {
let mut fixtures = Vec::new();
if !fixtures_dir.exists() {
return Ok(fixtures);
}
let http_dir = fixtures_dir.join("http");
if !http_dir.exists() {
return Ok(fixtures);
}
let walker = globwalk::GlobWalkerBuilder::from_patterns(&http_dir, &["**/*.json"])
.build()
.map_err(|e| Error::io_with_context("building glob walker for fixtures", e.to_string()))?;
for entry in walker {
let entry =
entry.map_err(|e| Error::io_with_context("reading directory entry", e.to_string()))?;
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
if let Ok(content) = fs::read_to_string(&path).await {
if let Ok(recorded_request) = serde_json::from_str::<RecordedRequest>(&content) {
fixtures.push(recorded_request);
}
}
}
}
fixtures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(fixtures)
}
pub async fn clean_old_fixtures(fixtures_dir: &Path, older_than_days: u32) -> Result<usize> {
let cutoff_date = chrono::Utc::now() - chrono::Duration::days(older_than_days as i64);
let mut cleaned_count = 0;
if !fixtures_dir.exists() {
return Ok(0);
}
let http_dir = fixtures_dir.join("http");
if !http_dir.exists() {
return Ok(0);
}
let mut entries = fs::read_dir(&http_dir)
.await
.map_err(|e| Error::io_with_context("reading fixtures directory", e.to_string()))?;
while let Some(entry) = entries
.next_entry()
.await
.map_err(|e| Error::io_with_context("reading directory entry", e.to_string()))?
{
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
if let Ok(content) = fs::read_to_string(&path).await {
if let Ok(recorded_request) = serde_json::from_str::<RecordedRequest>(&content) {
if recorded_request.timestamp < cutoff_date {
if let Err(e) = fs::remove_file(&path).await {
tracing::warn!(
"Failed to remove old fixture {}: {}",
path.display(),
e
);
} else {
cleaned_count += 1;
}
}
}
}
}
}
Ok(cleaned_count)
}
pub async fn list_ready_fixtures(fixtures_dir: &Path) -> Result<Vec<RecordedRequest>> {
let mut fixtures = Vec::new();
if !fixtures_dir.exists() {
return Ok(fixtures);
}
let http_dir = fixtures_dir.join("http");
if !http_dir.exists() {
return Ok(fixtures);
}
let walker = globwalk::GlobWalkerBuilder::from_patterns(&http_dir, &["**/*.json"])
.build()
.map_err(|e| Error::io_with_context("building glob walker for fixtures", e.to_string()))?;
for entry in walker {
let entry =
entry.map_err(|e| Error::io_with_context("reading directory entry", e.to_string()))?;
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "json") {
if let Ok(content) = fs::read_to_string(&path).await {
if let Ok(recorded_request) = serde_json::from_str::<RecordedRequest>(&content) {
if recorded_request.metadata.get("smoke_test").is_some_and(|v| v == "true") {
fixtures.push(recorded_request);
}
}
}
}
}
fixtures.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(fixtures)
}
pub async fn list_smoke_endpoints(fixtures_dir: &Path) -> Result<Vec<(String, String, String)>> {
let fixtures = list_ready_fixtures(fixtures_dir).await?;
let mut endpoints = Vec::new();
for fixture in fixtures {
let method = fixture.fingerprint.method.clone();
let path = fixture.fingerprint.path.clone();
let name = fixture
.metadata
.get("name")
.cloned()
.unwrap_or_else(|| format!("{} {}", method, path));
endpoints.push((method, path, name));
}
Ok(endpoints)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Uri;
use tempfile::TempDir;
#[tokio::test]
async fn test_record_and_replay() {
let temp_dir = TempDir::new().unwrap();
let fixtures_dir = temp_dir.path().to_path_buf();
let handler = RecordReplayHandler::new(fixtures_dir.clone(), true, true, false);
let method = Method::GET;
let uri: Uri = "/api/users?page=1".parse().unwrap();
let headers = HeaderMap::new();
let fingerprint = RequestFingerprint::new(method, &uri, &headers, None);
let mut response_headers = HeaderMap::new();
response_headers.insert("content-type", "application/json".parse().unwrap());
handler
.record_handler()
.record_request(&fingerprint, 200, &response_headers, r#"{"users": []}"#, None)
.await
.unwrap();
assert!(handler.replay_handler().has_fixture(&fingerprint).await);
let recorded = handler.replay_handler().load_fixture(&fingerprint).await.unwrap().unwrap();
assert_eq!(recorded.status_code, 200);
assert_eq!(recorded.response_body, r#"{"users": []}"#);
}
#[tokio::test]
async fn test_list_fixtures() {
let temp_dir = TempDir::new().unwrap();
let fixtures_dir = temp_dir.path().to_path_buf();
let handler = RecordReplayHandler::new(fixtures_dir.clone(), true, true, false);
for i in 0..3 {
let method = Method::GET;
let uri: Uri = format!("/api/users/{}", i).parse().unwrap();
let headers = HeaderMap::new();
let fingerprint = RequestFingerprint::new(method, &uri, &headers, None);
handler
.record_handler()
.record_request(
&fingerprint,
200,
&HeaderMap::new(),
&format!(r#"{{"id": {}}}"#, i),
None,
)
.await
.unwrap();
}
let fixtures = list_fixtures(&fixtures_dir).await.unwrap();
assert_eq!(fixtures.len(), 3);
}
}