use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct CassetteVersion(pub String);
impl CassetteVersion {
pub const CURRENT: &'static str = "0.1";
#[must_use]
pub fn current() -> Self {
Self(Self::CURRENT.to_string())
}
#[must_use]
pub fn is_current(&self) -> bool {
self.0 == Self::CURRENT
}
}
impl Default for CassetteVersion {
fn default() -> Self {
Self::current()
}
}
const KEPT_REQUEST_HEADERS: &[&str] = &["content-type", "accept"];
const KEPT_RESPONSE_HEADERS: &[&str] = &["content-type"];
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Body {
Json(Value),
Text(String),
}
impl Body {
#[must_use]
pub fn from_bytes(bytes: &[u8]) -> Self {
serde_json::from_slice(bytes).map_or_else(
|_| Self::Text(String::from_utf8_lossy(bytes).into_owned()),
Self::Json,
)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CapturedRequest {
pub method: String,
pub path: String,
#[serde(default)]
pub headers: Vec<(String, String)>,
pub body: Body,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CapturedResponse {
pub status: u16,
#[serde(default)]
pub headers: Vec<(String, String)>,
pub body: Body,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Exchange {
pub idx: usize,
pub request: CapturedRequest,
pub response: CapturedResponse,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Cassette {
pub cassette_version: CassetteVersion,
pub id: String,
#[serde(default)]
pub exchanges: Vec<Exchange>,
}
impl Cassette {
#[must_use]
pub fn new(id: impl Into<String>) -> Self {
Self {
cassette_version: CassetteVersion::current(),
id: id.into(),
exchanges: Vec::new(),
}
}
}
pub(crate) fn retain_headers<'a>(
headers: impl Iterator<Item = (&'a str, &'a [u8])>,
allowed: &[&str],
) -> Vec<(String, String)> {
headers
.filter_map(|(name, value)| {
let name = name.to_ascii_lowercase();
if !allowed.contains(&name.as_str()) {
return None;
}
let value = std::str::from_utf8(value).ok()?;
Some((name, value.to_string()))
})
.collect()
}
pub fn retain_request_headers<'a>(
headers: impl Iterator<Item = (&'a str, &'a [u8])>,
) -> Vec<(String, String)> {
retain_headers(headers, KEPT_REQUEST_HEADERS)
}
pub fn retain_response_headers<'a>(
headers: impl Iterator<Item = (&'a str, &'a [u8])>,
) -> Vec<(String, String)> {
retain_headers(headers, KEPT_RESPONSE_HEADERS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn credential_headers_never_survive_capture() {
let headers: Vec<(&str, &[u8])> = vec![
("content-type", b"application/json"),
("authorization", b"Bearer sk-secret"),
("x-api-key", b"sk-secret"),
("x-goog-api-key", b"sk-secret"),
("anthropic-auth-token", b"sk-secret"),
("cookie", b"session=sk-secret"),
];
let kept = retain_request_headers(headers.into_iter());
assert_eq!(
kept,
vec![("content-type".to_string(), "application/json".to_string())]
);
}
#[test]
fn unknown_headers_are_dropped_not_kept() {
let headers: Vec<(&str, &[u8])> = vec![("x-some-future-credential", b"sk-secret")];
assert!(retain_request_headers(headers.into_iter()).is_empty());
}
#[test]
fn header_names_are_lowercased() {
let headers: Vec<(&str, &[u8])> = vec![("Content-Type", b"application/json")];
let kept = retain_request_headers(headers.into_iter());
assert_eq!(kept[0].0, "content-type");
}
#[test]
fn non_json_body_is_preserved_as_text() {
let body = Body::from_bytes(b"<html>502 Bad Gateway</html>");
assert_eq!(body, Body::Text("<html>502 Bad Gateway</html>".to_string()));
}
#[test]
fn json_body_round_trips() {
let body = Body::from_bytes(br#"{"model":"claude-sonnet-5"}"#);
let Body::Json(value) = &body else {
panic!("expected a JSON body, got {body:?}");
};
assert_eq!(value["model"], "claude-sonnet-5");
}
#[test]
fn cassette_version_serializes_transparently() {
let json = serde_json::to_string(&CassetteVersion::current()).unwrap();
assert_eq!(json, "\"0.1\"");
}
}