Skip to main content

claude_api_test/
lib.rs

1//! Cassette-based replay for `claude-api` integration tests.
2//!
3//! Records of `request → response` exchanges are stored as JSONL on disk
4//! and served via [`wiremock`]. Tests point a `claude_api::Client` at the
5//! wiremock server's URL and exercise the live code paths against the
6//! canned responses -- no network calls, deterministic, reviewable in
7//! version control.
8//!
9//! # Format
10//!
11//! Each line of a cassette file is one [`RecordedExchange`]:
12//!
13//! ```jsonl
14//! {"method":"POST","path":"/v1/messages","status":200,"request":{...},"response":{...}}
15//! {"method":"GET","path":"/v1/models","status":200,"request":null,"response":{...}}
16//! ```
17//!
18//! `request` is the optional decoded JSON body; `response` is the
19//! response body. The matcher pairs a live request with the *first*
20//! cassette entry whose `(method, path)` and `request` match. Use
21//! [`Cassette::skip_request_match`] to disable body matching when you
22//! only care about the URL.
23//!
24//! # Quick start
25//!
26//! ```ignore
27//! use claude_api::{Client, messages::CreateMessageRequest, types::ModelId};
28//! use claude_api_test::{mount_cassette, Cassette};
29//! use wiremock::MockServer;
30//!
31//! #[tokio::test]
32//! async fn replay_messages_create() {
33//!     let cassette = Cassette::from_path("tests/cassettes/messages_create.jsonl")
34//!         .await
35//!         .unwrap();
36//!     let server = MockServer::start().await;
37//!     mount_cassette(&server, &cassette).await;
38//!
39//!     let client = Client::builder()
40//!         .api_key("sk-ant-test")
41//!         .base_url(server.uri())
42//!         .build()
43//!         .unwrap();
44//!     let req = CreateMessageRequest::builder()
45//!         .model(ModelId::SONNET_4_6)
46//!         .max_tokens(64)
47//!         .user("hi")
48//!         .build()
49//!         .unwrap();
50//!     let resp = client.messages().create(req).await.unwrap();
51//!     assert_eq!(resp.id, "msg_replay");
52//! }
53//! ```
54
55#![cfg_attr(docsrs, feature(doc_cfg))]
56
57use std::path::Path;
58
59use serde::{Deserialize, Serialize};
60
61pub mod recorder;
62pub use recorder::{DEFAULT_REDACT_HEADERS, Recorder, RecorderConfig};
63
64/// One recorded HTTP exchange. Preserved on disk as one JSONL line.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[non_exhaustive]
67pub struct RecordedExchange {
68    /// HTTP method (`GET`, `POST`, etc.).
69    pub method: String,
70    /// URL path (e.g. `/v1/messages`).
71    pub path: String,
72    /// HTTP status code returned.
73    pub status: u16,
74    /// Decoded JSON request body, or `None` if the original request had
75    /// no body. Used as a matching constraint in
76    /// [`mount_cassette`] unless `skip_request_match` is set.
77    #[serde(default)]
78    pub request: Option<serde_json::Value>,
79    /// Decoded JSON response body. Stored as `Value` so the cassette
80    /// stays human-readable and diffable.
81    pub response: serde_json::Value,
82    /// Optional response headers to set when serving (e.g.
83    /// `request-id`, `retry-after`). Defaults to none.
84    #[serde(default)]
85    pub headers: Vec<(String, String)>,
86}
87
88impl RecordedExchange {
89    /// Build a `RecordedExchange` with no request-body match constraint
90    /// and no extra response headers. Use the field setters to refine.
91    #[must_use]
92    pub fn new(
93        method: impl Into<String>,
94        path: impl Into<String>,
95        status: u16,
96        response: serde_json::Value,
97    ) -> Self {
98        Self {
99            method: method.into(),
100            path: path.into(),
101            status,
102            request: None,
103            response,
104            headers: Vec::new(),
105        }
106    }
107
108    /// Add a request-body match constraint.
109    #[must_use]
110    pub fn with_request(mut self, body: serde_json::Value) -> Self {
111        self.request = Some(body);
112        self
113    }
114
115    /// Add a single response header.
116    #[must_use]
117    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
118        self.headers.push((name.into(), value.into()));
119        self
120    }
121}
122
123/// A collection of [`RecordedExchange`]s, typically loaded from a JSONL
124/// file. Mount on a [`wiremock::MockServer`] via [`mount_cassette`].
125#[derive(Debug, Clone, Default)]
126pub struct Cassette {
127    exchanges: Vec<RecordedExchange>,
128    skip_request_match: bool,
129}
130
131impl Cassette {
132    /// Build an empty cassette.
133    #[must_use]
134    pub fn new() -> Self {
135        Self::default()
136    }
137
138    /// Build from an in-memory list of exchanges. Useful for tests that
139    /// inline their fixtures.
140    #[must_use]
141    pub fn from_exchanges(exchanges: Vec<RecordedExchange>) -> Self {
142        Self {
143            exchanges,
144            skip_request_match: false,
145        }
146    }
147
148    /// Async-load a cassette from a JSONL file at `path`. Lines that are
149    /// blank or start with `#` are skipped (so cassettes can carry
150    /// human-readable comments).
151    pub async fn from_path(path: impl AsRef<Path>) -> std::io::Result<Self> {
152        let text = tokio::fs::read_to_string(path).await?;
153        Self::parse_jsonl(&text).map_err(std::io::Error::other)
154    }
155
156    /// Synchronous version of [`Self::from_path`]. Convenient when you
157    /// don't have a runtime in scope.
158    pub fn from_path_sync(path: impl AsRef<Path>) -> std::io::Result<Self> {
159        let text = std::fs::read_to_string(path)?;
160        Self::parse_jsonl(&text).map_err(std::io::Error::other)
161    }
162
163    /// Parse a JSONL string into a cassette. Renamed from `from_str`
164    /// to avoid clashing with `std::str::FromStr::from_str`.
165    pub fn parse_jsonl(jsonl: &str) -> serde_json::Result<Self> {
166        let mut exchanges = Vec::new();
167        for (line_no, line) in jsonl.lines().enumerate() {
168            let trimmed = line.trim();
169            if trimmed.is_empty() || trimmed.starts_with('#') {
170                continue;
171            }
172            let exchange: RecordedExchange = serde_json::from_str(trimmed).map_err(|e| {
173                let msg = format!("cassette parse failed at line {}: {}", line_no + 1, e);
174                serde::de::Error::custom(msg)
175            })?;
176            exchanges.push(exchange);
177        }
178        Ok(Self {
179            exchanges,
180            skip_request_match: false,
181        })
182    }
183
184    /// Append an exchange.
185    pub fn push(&mut self, exchange: RecordedExchange) -> &mut Self {
186        self.exchanges.push(exchange);
187        self
188    }
189
190    /// Disable request-body matching. The wiremock matcher will pair
191    /// requests by `(method, path)` only. Useful when the request body
192    /// includes nondeterministic fields (timestamps, request IDs).
193    #[must_use]
194    pub fn skip_request_match(mut self) -> Self {
195        self.skip_request_match = true;
196        self
197    }
198
199    /// Borrow the underlying exchange list.
200    #[must_use]
201    pub fn exchanges(&self) -> &[RecordedExchange] {
202        &self.exchanges
203    }
204
205    /// Total number of exchanges in this cassette.
206    #[must_use]
207    pub fn len(&self) -> usize {
208        self.exchanges.len()
209    }
210
211    /// `true` if the cassette has no exchanges.
212    #[must_use]
213    pub fn is_empty(&self) -> bool {
214        self.exchanges.is_empty()
215    }
216
217    /// Serialize back to JSONL. Round-trips with [`Self::parse_jsonl`].
218    pub fn to_jsonl(&self) -> serde_json::Result<String> {
219        let mut out = String::new();
220        for ex in &self.exchanges {
221            out.push_str(&serde_json::to_string(ex)?);
222            out.push('\n');
223        }
224        Ok(out)
225    }
226}
227
228/// Mount every exchange in `cassette` on `server`. Each exchange becomes
229/// one [`wiremock::Mock`] that matches `(method, path)` (and the request
230/// body, unless [`Cassette::skip_request_match`] was set).
231///
232/// Mocks are mounted in cassette order. wiremock's first-match semantics
233/// mean that for two exchanges with the same `(method, path)`, the
234/// earlier one wins -- match by request body to disambiguate.
235pub async fn mount_cassette(server: &wiremock::MockServer, cassette: &Cassette) {
236    use wiremock::matchers::{body_json, method, path};
237    use wiremock::{Mock, ResponseTemplate};
238
239    for ex in &cassette.exchanges {
240        let mut response = ResponseTemplate::new(ex.status).set_body_json(ex.response.clone());
241        for (k, v) in &ex.headers {
242            response = response.insert_header(k.as_str(), v.as_str());
243        }
244
245        let mock_builder = Mock::given(method(ex.method.as_str())).and(path(ex.path.as_str()));
246        let mock = match (&ex.request, cassette.skip_request_match) {
247            (Some(body), false) => mock_builder.and(body_json(body)).respond_with(response),
248            _ => mock_builder.respond_with(response),
249        };
250        mock.mount(server).await;
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use serde_json::json;
258
259    #[test]
260    fn parse_jsonl_round_trips() {
261        let jsonl = r#"
262# leading comment, ignored
263{"method":"POST","path":"/v1/messages","status":200,"request":{"model":"x"},"response":{"id":"msg_1"}}
264{"method":"GET","path":"/v1/models","status":200,"request":null,"response":{"data":[]}}
265"#;
266        let c = Cassette::parse_jsonl(jsonl).unwrap();
267        assert_eq!(c.len(), 2);
268        assert_eq!(c.exchanges()[0].method, "POST");
269        assert_eq!(c.exchanges()[1].path, "/v1/models");
270
271        let serialized = c.to_jsonl().unwrap();
272        let again = Cassette::parse_jsonl(&serialized).unwrap();
273        assert_eq!(again.len(), 2);
274    }
275
276    #[test]
277    fn empty_cassette_is_empty() {
278        let c = Cassette::new();
279        assert!(c.is_empty());
280        assert_eq!(c.len(), 0);
281    }
282
283    #[test]
284    fn cassette_parse_error_includes_line_number() {
285        let jsonl = "not-json\n";
286        let err = Cassette::parse_jsonl(jsonl).unwrap_err();
287        assert!(format!("{err}").contains("line 1"));
288    }
289
290    #[test]
291    fn skip_request_match_flag_is_set() {
292        let c = Cassette::new().skip_request_match();
293        assert!(c.skip_request_match);
294    }
295
296    #[test]
297    fn from_exchanges_constructs_directly() {
298        let ex = RecordedExchange {
299            method: "POST".into(),
300            path: "/v1/x".into(),
301            status: 200,
302            request: Some(json!({"k": 1})),
303            response: json!({"ok": true}),
304            headers: vec![("request-id".into(), "req_1".into())],
305        };
306        let c = Cassette::from_exchanges(vec![ex]);
307        assert_eq!(c.len(), 1);
308    }
309}