use axum::{
body::Body,
extract::Request,
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
};
use bytes::Bytes;
use serde_json::{Map, Value, json};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub fn dump_dir() -> Option<PathBuf> {
std::env::var("BYOKEY_DUMP").ok().map(PathBuf::from)
}
pub async fn dump_middleware(request: Request, next: Next) -> Response {
let Some(dir) = dump_dir() else {
return next.run(request).await;
};
if let Err(e) = std::fs::create_dir_all(&dir) {
tracing::warn!(error = %e, "failed to create dump directory");
return next.run(request).await;
}
let method = request.method().to_string();
let uri = request.uri().to_string();
let req_headers = format_headers(request.headers());
let (parts, body) = request.into_parts();
let req_bytes = match axum::body::to_bytes(body, 200 * 1024 * 1024).await {
Ok(bytes) => bytes,
Err(e) => {
tracing::warn!(error = %e, "failed to read request body for dump");
return (StatusCode::INTERNAL_SERVER_ERROR, "failed to read body").into_response();
}
};
let req_body_json = bytes_to_json(&req_bytes);
let request = Request::from_parts(parts, Body::from(req_bytes.clone()));
let response = next.run(request).await;
let resp_status = response.status().as_u16();
let resp_headers = format_headers(response.headers());
let content_type = response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let is_stream = content_type.contains("text/event-stream");
if is_stream {
let dump = json!({
"request": {
"method": method,
"uri": uri,
"headers": req_headers,
"body": req_body_json,
},
"response": {
"status": resp_status,
"headers": resp_headers,
"body": "__streaming__",
}
});
let filename = make_filename(&method, &uri);
let filepath = dir.join(filename);
tokio::spawn(async move {
write_dump(&filepath, &dump);
});
return response;
}
let (resp_parts, resp_body) = response.into_parts();
let resp_bytes = match axum::body::to_bytes(resp_body, 200 * 1024 * 1024).await {
Ok(bytes) => bytes,
Err(e) => {
tracing::warn!(error = %e, "failed to read response body for dump");
return (
StatusCode::INTERNAL_SERVER_ERROR,
"failed to read response body",
)
.into_response();
}
};
let resp_body_json = bytes_to_json(&resp_bytes);
let dump = json!({
"request": {
"method": method,
"uri": uri,
"headers": req_headers,
"body": req_body_json,
},
"response": {
"status": resp_status,
"headers": resp_headers,
"body": resp_body_json,
}
});
let filename = make_filename(&method, &uri);
let filepath = dir.join(filename);
tokio::spawn(async move {
write_dump(&filepath, &dump);
});
Response::from_parts(resp_parts, Body::from(resp_bytes))
}
fn format_headers(headers: &axum::http::HeaderMap) -> Value {
let mut map = Map::new();
for (name, value) in headers {
let key = name.as_str().to_string();
let val = value.to_str().unwrap_or("<binary>").to_string();
map.entry(key)
.and_modify(|existing| {
if let Value::Array(arr) = existing {
arr.push(Value::String(val.clone()));
} else {
let prev = existing.clone();
*existing = Value::Array(vec![prev, Value::String(val.clone())]);
}
})
.or_insert_with(|| Value::String(val));
}
Value::Object(map)
}
fn bytes_to_json(bytes: &Bytes) -> Value {
if bytes.is_empty() {
return Value::Null;
}
if let Ok(v) = serde_json::from_slice::<Value>(bytes) {
return v;
}
if let Ok(s) = std::str::from_utf8(bytes) {
return Value::String(s.to_string());
}
json!({"__binary_length": bytes.len()})
}
fn make_filename(method: &str, uri: &str) -> String {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let sanitized = uri
.trim_start_matches('/')
.replace('/', "_")
.replace('?', "_q_")
.chars()
.take(80)
.collect::<String>();
format!("{ts}_{method}_{sanitized}.json")
}
fn write_dump(path: &Path, dump: &Value) {
match serde_json::to_string_pretty(dump) {
Ok(content) => {
if let Err(e) = std::fs::write(path, content) {
tracing::warn!(path = %path.display(), error = %e, "failed to write dump file");
} else {
tracing::debug!(path = %path.display(), "request/response dumped");
}
}
Err(e) => {
tracing::warn!(error = %e, "failed to serialize dump");
}
}
}