#![cfg_attr(docsrs, feature(doc_cfg))]
use std::path::Path;
use serde::{Deserialize, Serialize};
pub mod recorder;
pub use recorder::{DEFAULT_REDACT_HEADERS, Recorder, RecorderConfig};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RecordedExchange {
pub method: String,
pub path: String,
pub status: u16,
#[serde(default)]
pub request: Option<serde_json::Value>,
pub response: serde_json::Value,
#[serde(default)]
pub headers: Vec<(String, String)>,
}
impl RecordedExchange {
#[must_use]
pub fn new(
method: impl Into<String>,
path: impl Into<String>,
status: u16,
response: serde_json::Value,
) -> Self {
Self {
method: method.into(),
path: path.into(),
status,
request: None,
response,
headers: Vec::new(),
}
}
#[must_use]
pub fn with_request(mut self, body: serde_json::Value) -> Self {
self.request = Some(body);
self
}
#[must_use]
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.push((name.into(), value.into()));
self
}
}
#[derive(Debug, Clone, Default)]
pub struct Cassette {
exchanges: Vec<RecordedExchange>,
skip_request_match: bool,
}
impl Cassette {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn from_exchanges(exchanges: Vec<RecordedExchange>) -> Self {
Self {
exchanges,
skip_request_match: false,
}
}
pub async fn from_path(path: impl AsRef<Path>) -> std::io::Result<Self> {
let text = tokio::fs::read_to_string(path).await?;
Self::parse_jsonl(&text).map_err(std::io::Error::other)
}
pub fn from_path_sync(path: impl AsRef<Path>) -> std::io::Result<Self> {
let text = std::fs::read_to_string(path)?;
Self::parse_jsonl(&text).map_err(std::io::Error::other)
}
pub fn parse_jsonl(jsonl: &str) -> serde_json::Result<Self> {
let mut exchanges = Vec::new();
for (line_no, line) in jsonl.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let exchange: RecordedExchange = serde_json::from_str(trimmed).map_err(|e| {
let msg = format!("cassette parse failed at line {}: {}", line_no + 1, e);
serde::de::Error::custom(msg)
})?;
exchanges.push(exchange);
}
Ok(Self {
exchanges,
skip_request_match: false,
})
}
pub fn push(&mut self, exchange: RecordedExchange) -> &mut Self {
self.exchanges.push(exchange);
self
}
#[must_use]
pub fn skip_request_match(mut self) -> Self {
self.skip_request_match = true;
self
}
#[must_use]
pub fn exchanges(&self) -> &[RecordedExchange] {
&self.exchanges
}
#[must_use]
pub fn len(&self) -> usize {
self.exchanges.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.exchanges.is_empty()
}
pub fn to_jsonl(&self) -> serde_json::Result<String> {
let mut out = String::new();
for ex in &self.exchanges {
out.push_str(&serde_json::to_string(ex)?);
out.push('\n');
}
Ok(out)
}
}
pub async fn mount_cassette(server: &wiremock::MockServer, cassette: &Cassette) {
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, ResponseTemplate};
for ex in &cassette.exchanges {
let mut response = ResponseTemplate::new(ex.status).set_body_json(ex.response.clone());
for (k, v) in &ex.headers {
response = response.insert_header(k.as_str(), v.as_str());
}
let mock_builder = Mock::given(method(ex.method.as_str())).and(path(ex.path.as_str()));
let mock = match (&ex.request, cassette.skip_request_match) {
(Some(body), false) => mock_builder.and(body_json(body)).respond_with(response),
_ => mock_builder.respond_with(response),
};
mock.mount(server).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_jsonl_round_trips() {
let jsonl = r#"
# leading comment, ignored
{"method":"POST","path":"/v1/messages","status":200,"request":{"model":"x"},"response":{"id":"msg_1"}}
{"method":"GET","path":"/v1/models","status":200,"request":null,"response":{"data":[]}}
"#;
let c = Cassette::parse_jsonl(jsonl).unwrap();
assert_eq!(c.len(), 2);
assert_eq!(c.exchanges()[0].method, "POST");
assert_eq!(c.exchanges()[1].path, "/v1/models");
let serialized = c.to_jsonl().unwrap();
let again = Cassette::parse_jsonl(&serialized).unwrap();
assert_eq!(again.len(), 2);
}
#[test]
fn empty_cassette_is_empty() {
let c = Cassette::new();
assert!(c.is_empty());
assert_eq!(c.len(), 0);
}
#[test]
fn cassette_parse_error_includes_line_number() {
let jsonl = "not-json\n";
let err = Cassette::parse_jsonl(jsonl).unwrap_err();
assert!(format!("{err}").contains("line 1"));
}
#[test]
fn skip_request_match_flag_is_set() {
let c = Cassette::new().skip_request_match();
assert!(c.skip_request_match);
}
#[test]
fn from_exchanges_constructs_directly() {
let ex = RecordedExchange {
method: "POST".into(),
path: "/v1/x".into(),
status: 200,
request: Some(json!({"k": 1})),
response: json!({"ok": true}),
headers: vec![("request-id".into(), "req_1".into())],
};
let c = Cassette::from_exchanges(vec![ex]);
assert_eq!(c.len(), 1);
}
}