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.
235///
236/// SSE responses (exchanges whose `headers` list contains
237/// `content-type: text/event-stream`) are served as raw text so the
238/// `eventsource-stream` parser on the client side sees the SSE wire
239/// format rather than a JSON-encoded body.
240pub async fn mount_cassette(server: &wiremock::MockServer, cassette: &Cassette) {
241    use wiremock::matchers::{body_json, method, path};
242    use wiremock::{Mock, ResponseTemplate};
243
244    for ex in &cassette.exchanges {
245        // Detect SSE exchanges: header list contains content-type:
246        // text/event-stream.  In that case the `response` field holds the
247        // raw SSE wire text as a JSON string; serve it as raw bytes so
248        // the client's `eventsource-stream` parser receives the expected
249        // wire format.
250        let is_sse = ex.headers.iter().any(|(k, v)| {
251            k.eq_ignore_ascii_case("content-type") && v.contains("text/event-stream")
252        });
253
254        let mut response = if is_sse {
255            // `response` is a JSON String containing the SSE wire text.
256            // Use `set_body_raw` so wiremock serves the body with
257            // `content-type: text/event-stream` rather than the default
258            // `text/plain` that `set_body_string` produces. This ensures
259            // the `eventsource-stream` parser on the client side receives
260            // the wire format it expects.
261            let body = ex.response.as_str().unwrap_or("").as_bytes().to_owned();
262            ResponseTemplate::new(ex.status).set_body_raw(body, "text/event-stream")
263        } else {
264            ResponseTemplate::new(ex.status).set_body_json(ex.response.clone())
265        };
266
267        // Apply extra headers. Skip the `content-type` for SSE exchanges:
268        // `set_body_raw` already set the correct MIME and wiremock's
269        // `generate_response` would overwrite it anyway (insert wins over
270        // our earlier `insert_header` call).
271        for (k, v) in &ex.headers {
272            if is_sse && k.eq_ignore_ascii_case("content-type") {
273                // Already handled by set_body_raw above.
274                continue;
275            }
276            response = response.insert_header(k.as_str(), v.as_str());
277        }
278
279        let mock_builder = Mock::given(method(ex.method.as_str())).and(path(ex.path.as_str()));
280        let mock = match (&ex.request, cassette.skip_request_match) {
281            (Some(body), false) => mock_builder.and(body_json(body)).respond_with(response),
282            _ => mock_builder.respond_with(response),
283        };
284        mock.mount(server).await;
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use serde_json::json;
292
293    /// A minimal SSE corpus: one `message_start` + one `message_stop`.
294    fn tiny_sse_corpus() -> &'static str {
295        concat!(
296            "event: message_start\n",
297            "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_sse\",\"type\":\"message\",",
298            "\"role\":\"assistant\",\"content\":[],\"model\":\"claude-haiku-4-5-20251001\",",
299            "\"usage\":{\"input_tokens\":3,\"output_tokens\":0}}}\n",
300            "\n",
301            "event: message_stop\n",
302            "data: {\"type\":\"message_stop\"}\n",
303            "\n",
304        )
305    }
306
307    #[test]
308    fn parse_jsonl_round_trips() {
309        let jsonl = r#"
310# leading comment, ignored
311{"method":"POST","path":"/v1/messages","status":200,"request":{"model":"x"},"response":{"id":"msg_1"}}
312{"method":"GET","path":"/v1/models","status":200,"request":null,"response":{"data":[]}}
313"#;
314        let c = Cassette::parse_jsonl(jsonl).unwrap();
315        assert_eq!(c.len(), 2);
316        assert_eq!(c.exchanges()[0].method, "POST");
317        assert_eq!(c.exchanges()[1].path, "/v1/models");
318
319        let serialized = c.to_jsonl().unwrap();
320        let again = Cassette::parse_jsonl(&serialized).unwrap();
321        assert_eq!(again.len(), 2);
322    }
323
324    #[test]
325    fn empty_cassette_is_empty() {
326        let c = Cassette::new();
327        assert!(c.is_empty());
328        assert_eq!(c.len(), 0);
329    }
330
331    #[test]
332    fn cassette_parse_error_includes_line_number() {
333        let jsonl = "not-json\n";
334        let err = Cassette::parse_jsonl(jsonl).unwrap_err();
335        assert!(format!("{err}").contains("line 1"));
336    }
337
338    #[test]
339    fn skip_request_match_flag_is_set() {
340        let c = Cassette::new().skip_request_match();
341        assert!(c.skip_request_match);
342    }
343
344    #[test]
345    fn from_exchanges_constructs_directly() {
346        let ex = RecordedExchange {
347            method: "POST".into(),
348            path: "/v1/x".into(),
349            status: 200,
350            request: Some(json!({"k": 1})),
351            response: json!({"ok": true}),
352            headers: vec![("request-id".into(), "req_1".into())],
353        };
354        let c = Cassette::from_exchanges(vec![ex]);
355        assert_eq!(c.len(), 1);
356    }
357
358    // ------------------------------------------------------------------
359    // SSE cassette round-trip tests
360    // ------------------------------------------------------------------
361
362    /// An SSE exchange serialises the wire text as a JSON string, and
363    /// round-trips through `to_jsonl` / `parse_jsonl` without corruption.
364    #[test]
365    fn sse_exchange_round_trips_through_jsonl() {
366        let sse = tiny_sse_corpus();
367        let ex = RecordedExchange {
368            method: "POST".into(),
369            path: "/v1/messages".into(),
370            status: 200,
371            request: Some(json!({"stream": true})),
372            response: json!(sse),
373            headers: vec![
374                ("content-type".into(), "text/event-stream".into()),
375                ("request-id".into(), "req_sse_1".into()),
376            ],
377        };
378
379        let cassette = Cassette::from_exchanges(vec![ex]);
380        let jsonl = cassette.to_jsonl().unwrap();
381        let again = Cassette::parse_jsonl(&jsonl).unwrap();
382
383        assert_eq!(again.len(), 1);
384        let entry = &again.exchanges()[0];
385        assert_eq!(entry.status, 200);
386        // SSE body survives the round-trip intact.
387        assert_eq!(entry.response.as_str().unwrap(), sse);
388        // Content-type header is preserved.
389        assert!(
390            entry
391                .headers
392                .iter()
393                .any(|(k, v)| k == "content-type" && v.contains("text/event-stream"))
394        );
395    }
396
397    /// `mount_cassette` on a wiremock server, then a real `Client` drives
398    /// `create_stream` against it and receives the expected SSE events.
399    #[tokio::test]
400    async fn mount_cassette_replays_sse_response() {
401        use claude_api::Client;
402        use claude_api::messages::CreateMessageRequest;
403        use claude_api::types::ModelId;
404
405        let sse = concat!(
406            "event: message_start\n",
407            "data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_sse_replay\",\"type\":\"message\",",
408            "\"role\":\"assistant\",\"content\":[],\"model\":\"claude-haiku-4-5-20251001\",",
409            "\"usage\":{\"input_tokens\":3,\"output_tokens\":0}}}\n",
410            "\n",
411            "event: content_block_start\n",
412            "data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n",
413            "\n",
414            "event: content_block_delta\n",
415            "data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"hi\"}}\n",
416            "\n",
417            "event: content_block_stop\n",
418            "data: {\"type\":\"content_block_stop\",\"index\":0}\n",
419            "\n",
420            "event: message_delta\n",
421            "data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":3,\"output_tokens\":1}}\n",
422            "\n",
423            "event: message_stop\n",
424            "data: {\"type\":\"message_stop\"}\n",
425            "\n",
426        );
427
428        let cassette = Cassette::from_exchanges(vec![RecordedExchange {
429            method: "POST".into(),
430            path: "/v1/messages".into(),
431            status: 200,
432            request: None,
433            response: json!(sse),
434            headers: vec![("content-type".into(), "text/event-stream".into())],
435        }]);
436
437        let server = wiremock::MockServer::start().await;
438        mount_cassette(&server, &cassette).await;
439
440        let client = Client::builder()
441            .api_key("sk-ant-test")
442            .base_url(server.uri())
443            .build()
444            .unwrap();
445
446        let req = CreateMessageRequest::builder()
447            .model(ModelId::HAIKU_4_5)
448            .max_tokens(8)
449            .user("hi")
450            .build()
451            .unwrap();
452
453        let stream = client.messages().create_stream(req).await.unwrap();
454        let msg = stream.aggregate().await.unwrap();
455
456        assert_eq!(msg.id, "msg_sse_replay");
457        assert_eq!(
458            msg.stop_reason,
459            Some(claude_api::types::StopReason::EndTurn)
460        );
461        assert_eq!(msg.usage.output_tokens, 1);
462    }
463}