Skip to main content

claude_api/
dry_run.rs

1//! `DryRun` -- preview the HTTP request that would be sent, without firing it.
2//!
3//! Renders the same body, URL, and headers (auth + version + user-agent +
4//! `anthropic-beta`) the live client would produce. Useful for:
5//!
6//! - inspecting the rendered JSON body during development,
7//! - reproducing a request as a curl command in support tickets,
8//! - asserting on the wire-shape of a request in tests.
9//!
10//! Obtain via [`Messages::dry_run`](crate::messages::Messages::dry_run) and
11//! related methods.
12
13#![cfg(feature = "async")]
14
15use http::HeaderMap;
16
17/// A rendered HTTP request that has not been sent.
18///
19/// Contains the exact method, URL, headers, and body the client would
20/// transmit. The [`Debug`](std::fmt::Debug) impl redacts auth headers; the
21/// raw values are still accessible via [`Self::headers`] for callers that
22/// need them.
23#[non_exhaustive]
24#[derive(Clone)]
25pub struct DryRun {
26    /// HTTP method.
27    pub method: reqwest::Method,
28    /// Fully-qualified URL the request would be sent to.
29    pub url: String,
30    /// All headers, including `x-api-key` and `anthropic-version`.
31    pub headers: HeaderMap,
32    /// Decoded JSON request body.
33    pub body: serde_json::Value,
34}
35
36impl DryRun {
37    /// The rendered JSON request body.
38    #[must_use]
39    pub fn body(&self) -> &serde_json::Value {
40        &self.body
41    }
42
43    /// Pretty-printed JSON body. Convenience over `serde_json::to_string_pretty`.
44    #[must_use]
45    pub fn body_pretty(&self) -> String {
46        // unwrap: serializing a serde_json::Value cannot fail
47        serde_json::to_string_pretty(&self.body).unwrap_or_default()
48    }
49
50    /// Render as a `curl` command. Auth headers (`x-api-key`,
51    /// `authorization`) are replaced with `<REDACTED>`. Suitable for sharing
52    /// in bug reports.
53    #[must_use]
54    pub fn to_curl(&self) -> String {
55        self.to_curl_inner(None)
56    }
57
58    /// Render as a `curl` command with the given API key inlined for
59    /// `x-api-key`. Use this when you intend to actually run the resulting
60    /// command. Anything in `authorization` is still redacted.
61    #[must_use]
62    pub fn to_curl_with_key(&self, api_key: &str) -> String {
63        self.to_curl_inner(Some(api_key))
64    }
65
66    fn to_curl_inner(&self, api_key: Option<&str>) -> String {
67        let mut out = String::with_capacity(256);
68        out.push_str("curl -X ");
69        out.push_str(self.method.as_str());
70        out.push(' ');
71        push_shell_quoted(&mut out, &self.url);
72        for (name, value) in &self.headers {
73            let name_str = name.as_str();
74            let value_str = match name_str {
75                "x-api-key" => api_key.unwrap_or("<REDACTED>"),
76                "authorization" => "<REDACTED>",
77                _ => value.to_str().unwrap_or("<binary>"),
78            };
79            out.push_str(" \\\n  -H ");
80            push_shell_quoted(&mut out, &format!("{name_str}: {value_str}"));
81        }
82        out.push_str(" \\\n  -d ");
83        push_shell_quoted(&mut out, &self.body_pretty());
84        out
85    }
86}
87
88impl std::fmt::Debug for DryRun {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        let mut redacted = self.headers.clone();
91        for name in ["x-api-key", "authorization"] {
92            if redacted.contains_key(name) {
93                redacted.insert(name, "<REDACTED>".parse().unwrap());
94            }
95        }
96        f.debug_struct("DryRun")
97            .field("method", &self.method)
98            .field("url", &self.url)
99            .field("headers", &redacted)
100            .field("body", &self.body)
101            .finish()
102    }
103}
104
105/// Single-quote a string for safe inclusion in a shell command.
106fn push_shell_quoted(out: &mut String, s: &str) {
107    out.push('\'');
108    for ch in s.chars() {
109        if ch == '\'' {
110            // Close, escape literal apostrophe, reopen.
111            out.push_str("'\\''");
112        } else {
113            out.push(ch);
114        }
115    }
116    out.push('\'');
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use pretty_assertions::assert_eq;
123    use serde_json::json;
124
125    fn sample() -> DryRun {
126        let mut headers = HeaderMap::new();
127        headers.insert("x-api-key", "sk-ant-secret".parse().unwrap());
128        headers.insert("anthropic-version", "2023-06-01".parse().unwrap());
129        headers.insert("content-type", "application/json".parse().unwrap());
130        DryRun {
131            method: reqwest::Method::POST,
132            url: "https://api.anthropic.com/v1/messages".into(),
133            headers,
134            body: json!({"model": "claude-sonnet-4-6", "max_tokens": 8}),
135        }
136    }
137
138    #[test]
139    fn body_pretty_returns_indented_json() {
140        let dr = sample();
141        let p = dr.body_pretty();
142        assert!(p.contains("\"model\": \"claude-sonnet-4-6\""));
143        assert!(p.contains('\n'));
144    }
145
146    #[test]
147    fn to_curl_redacts_api_key_by_default() {
148        let dr = sample();
149        let curl = dr.to_curl();
150        assert!(curl.contains("x-api-key: <REDACTED>"));
151        assert!(!curl.contains("sk-ant-secret"));
152        assert!(curl.contains("anthropic-version: 2023-06-01"));
153        assert!(curl.starts_with("curl -X POST 'https://api.anthropic.com/v1/messages'"));
154    }
155
156    #[test]
157    fn to_curl_with_key_inlines_key() {
158        let dr = sample();
159        let curl = dr.to_curl_with_key("sk-ant-real");
160        assert!(curl.contains("x-api-key: sk-ant-real"));
161    }
162
163    #[test]
164    fn debug_redacts_auth_headers() {
165        let dr = sample();
166        let s = format!("{dr:?}");
167        assert!(!s.contains("sk-ant-secret"));
168        assert!(s.contains("<REDACTED>"));
169    }
170
171    #[test]
172    fn debug_passes_through_non_auth_headers() {
173        let dr = sample();
174        let s = format!("{dr:?}");
175        assert!(s.contains("anthropic-version"));
176    }
177
178    #[test]
179    fn shell_quoting_escapes_single_quotes() {
180        let mut out = String::new();
181        push_shell_quoted(&mut out, "it's");
182        assert_eq!(out, "'it'\\''s'");
183    }
184}