#![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 is_sse = ex.headers.iter().any(|(k, v)| {
k.eq_ignore_ascii_case("content-type") && v.contains("text/event-stream")
});
let mut response = if is_sse {
let body = ex.response.as_str().unwrap_or("").as_bytes().to_owned();
ResponseTemplate::new(ex.status).set_body_raw(body, "text/event-stream")
} else {
ResponseTemplate::new(ex.status).set_body_json(ex.response.clone())
};
for (k, v) in &ex.headers {
if is_sse && k.eq_ignore_ascii_case("content-type") {
continue;
}
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;
fn tiny_sse_corpus() -> &'static str {
concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_sse\",\"type\":\"message\",",
"\"role\":\"assistant\",\"content\":[],\"model\":\"claude-haiku-4-5-20251001\",",
"\"usage\":{\"input_tokens\":3,\"output_tokens\":0}}}\n",
"\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n",
"\n",
)
}
#[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);
}
#[test]
fn sse_exchange_round_trips_through_jsonl() {
let sse = tiny_sse_corpus();
let ex = RecordedExchange {
method: "POST".into(),
path: "/v1/messages".into(),
status: 200,
request: Some(json!({"stream": true})),
response: json!(sse),
headers: vec![
("content-type".into(), "text/event-stream".into()),
("request-id".into(), "req_sse_1".into()),
],
};
let cassette = Cassette::from_exchanges(vec![ex]);
let jsonl = cassette.to_jsonl().unwrap();
let again = Cassette::parse_jsonl(&jsonl).unwrap();
assert_eq!(again.len(), 1);
let entry = &again.exchanges()[0];
assert_eq!(entry.status, 200);
assert_eq!(entry.response.as_str().unwrap(), sse);
assert!(
entry
.headers
.iter()
.any(|(k, v)| k == "content-type" && v.contains("text/event-stream"))
);
}
#[tokio::test]
async fn mount_cassette_replays_sse_response() {
use claude_api::Client;
use claude_api::messages::CreateMessageRequest;
use claude_api::types::ModelId;
let sse = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_sse_replay\",\"type\":\"message\",",
"\"role\":\"assistant\",\"content\":[],\"model\":\"claude-haiku-4-5-20251001\",",
"\"usage\":{\"input_tokens\":3,\"output_tokens\":0}}}\n",
"\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n",
"\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n",
"\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n",
"\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}\n",
"\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n",
"\n",
);
let cassette = Cassette::from_exchanges(vec![RecordedExchange {
method: "POST".into(),
path: "/v1/messages".into(),
status: 200,
request: None,
response: json!(sse),
headers: vec![("content-type".into(), "text/event-stream".into())],
}]);
let server = wiremock::MockServer::start().await;
mount_cassette(&server, &cassette).await;
let client = Client::builder()
.api_key("sk-ant-test")
.base_url(server.uri())
.build()
.unwrap();
let req = CreateMessageRequest::builder()
.model(ModelId::HAIKU_4_5)
.max_tokens(8)
.user("hi")
.build()
.unwrap();
let stream = client.messages().create_stream(req).await.unwrap();
let msg = stream.aggregate().await.unwrap();
assert_eq!(msg.id, "msg_sse_replay");
assert_eq!(
msg.stop_reason,
Some(claude_api::types::StopReason::EndTurn)
);
assert_eq!(msg.usage.output_tokens, 1);
}
}