Skip to main content

claude_api/files/
api.rs

1//! The async `Files<'a>` namespace.
2
3#![cfg(feature = "async")]
4
5use std::path::Path;
6
7use bytes::Bytes;
8use futures_util::stream::TryStreamExt;
9use tokio::io::{AsyncRead, AsyncWrite};
10use tokio_util::io::{ReaderStream, StreamReader};
11
12use crate::client::Client;
13use crate::error::{Error, Result};
14use crate::pagination::Paginated;
15
16use super::types::{FileDeleted, FileMetadata, ListFilesParams};
17
18/// Beta version tag attached to every Files-API request.
19const FILES_BETA: &[&str] = &["files-api-2025-04-14"];
20
21/// Namespace handle for the Files API.
22pub struct Files<'a> {
23    client: &'a Client,
24}
25
26impl<'a> Files<'a> {
27    pub(crate) fn new(client: &'a Client) -> Self {
28        Self { client }
29    }
30
31    /// Upload a file from disk.
32    ///
33    /// Streams the file body through the request without buffering it in
34    /// memory; suitable for large PDFs.
35    ///
36    /// `media_type` defaults to `application/octet-stream`. The filename is
37    /// taken from the path's file name component.
38    pub async fn upload_path(&self, path: impl AsRef<Path>) -> Result<FileMetadata> {
39        let path = path.as_ref();
40        let filename = path
41            .file_name()
42            .and_then(|s| s.to_str())
43            .ok_or_else(|| {
44                Error::InvalidConfig(format!("invalid filename in path {}", path.display()))
45            })?
46            .to_owned();
47        let media_type = guess_media_type(&filename).unwrap_or("application/octet-stream");
48        let file = tokio::fs::File::open(path).await?;
49        self.upload_stream(file, filename, media_type).await
50    }
51
52    /// Upload from any [`AsyncRead`] source. The body is streamed; not
53    /// buffered. Retries are *not* applied to uploads -- the source is
54    /// consumed.
55    pub async fn upload_stream<R>(
56        &self,
57        reader: R,
58        filename: impl Into<String>,
59        media_type: impl Into<String>,
60    ) -> Result<FileMetadata>
61    where
62        R: AsyncRead + Send + Sync + 'static,
63    {
64        let filename = filename.into();
65        let media_type = media_type.into();
66        let stream = ReaderStream::new(Box::pin(reader));
67        let body = reqwest::Body::wrap_stream(stream);
68        let part = reqwest::multipart::Part::stream(body)
69            .file_name(filename)
70            .mime_str(&media_type)
71            .map_err(|e| Error::InvalidConfig(format!("invalid media_type for upload: {e}")))?;
72        self.upload_with_part(part).await
73    }
74
75    /// Upload from a `Bytes` buffer (or anything that converts to `Bytes`).
76    /// Suitable for small payloads where streaming is overkill.
77    pub async fn upload_bytes(
78        &self,
79        bytes: impl Into<Bytes>,
80        filename: impl Into<String>,
81        media_type: impl Into<String>,
82    ) -> Result<FileMetadata> {
83        let filename = filename.into();
84        let media_type = media_type.into();
85        let part = reqwest::multipart::Part::bytes(bytes.into().to_vec())
86            .file_name(filename)
87            .mime_str(&media_type)
88            .map_err(|e| Error::InvalidConfig(format!("invalid media_type for upload: {e}")))?;
89        self.upload_with_part(part).await
90    }
91
92    async fn upload_with_part(&self, part: reqwest::multipart::Part) -> Result<FileMetadata> {
93        let form = reqwest::multipart::Form::new().part("file", part);
94        // No retry: multipart bodies built from streaming sources are
95        // single-use. Users who need retry should wrap their own loop
96        // around upload_path / upload_bytes.
97        let builder = self
98            .client
99            .request_builder(reqwest::Method::POST, "/v1/files")
100            .multipart(form);
101        self.client.execute(builder, FILES_BETA).await
102    }
103
104    /// Fetch metadata for a single file by ID.
105    pub async fn get(&self, id: &str) -> Result<FileMetadata> {
106        let path = format!("/v1/files/{id}");
107        self.client
108            .execute_with_retry(
109                || self.client.request_builder(reqwest::Method::GET, &path),
110                FILES_BETA,
111            )
112            .await
113    }
114
115    /// Fetch one page of file metadata.
116    pub async fn list(&self, params: ListFilesParams) -> Result<Paginated<FileMetadata>> {
117        let params_ref = &params;
118        self.client
119            .execute_with_retry(
120                || {
121                    self.client
122                        .request_builder(reqwest::Method::GET, "/v1/files")
123                        .query(params_ref)
124                },
125                FILES_BETA,
126            )
127            .await
128    }
129
130    /// Fetch every file's metadata, transparently paging.
131    pub async fn list_all(&self) -> Result<Vec<FileMetadata>> {
132        let mut all = Vec::new();
133        let mut params = ListFilesParams::default();
134        loop {
135            let page = self.list(params.clone()).await?;
136            let next_cursor = page.next_after().map(str::to_owned);
137            all.extend(page.data);
138            match next_cursor {
139                Some(cursor) => params.after_id = Some(cursor),
140                None => break,
141            }
142        }
143        Ok(all)
144    }
145
146    /// Delete a file by ID. Returns the deletion confirmation.
147    pub async fn delete(&self, id: &str) -> Result<FileDeleted> {
148        let path = format!("/v1/files/{id}");
149        self.client
150            .execute_with_retry(
151                || self.client.request_builder(reqwest::Method::DELETE, &path),
152                FILES_BETA,
153            )
154            .await
155    }
156
157    /// Download a file's bytes into memory. Suitable for small files; for
158    /// streaming to disk or a network sink, use [`Self::download_to`].
159    pub async fn download(&self, id: &str) -> Result<Bytes> {
160        let path = format!("/v1/files/{id}/content");
161        let response = self
162            .client
163            .execute_streaming(
164                self.client.request_builder(reqwest::Method::GET, &path),
165                FILES_BETA,
166            )
167            .await?;
168        Ok(response.bytes().await?)
169    }
170
171    /// Stream a file's bytes into any [`AsyncWrite`] sink. Returns the
172    /// total number of bytes written.
173    pub async fn download_to<W>(&self, id: &str, writer: &mut W) -> Result<u64>
174    where
175        W: AsyncWrite + Unpin,
176    {
177        let path = format!("/v1/files/{id}/content");
178        let response = self
179            .client
180            .execute_streaming(
181                self.client.request_builder(reqwest::Method::GET, &path),
182                FILES_BETA,
183            )
184            .await?;
185        let stream = response
186            .bytes_stream()
187            .map_err(|e| std::io::Error::other(e.to_string()));
188        let mut reader = StreamReader::new(stream);
189        let copied = tokio::io::copy(&mut reader, writer).await?;
190        Ok(copied)
191    }
192}
193
194/// Best-effort MIME type from a filename extension. Returns `None` for
195/// extensions not in the small built-in table; callers can still pass any
196/// `media_type` explicitly via [`Files::upload_stream`] / [`Files::upload_bytes`].
197fn guess_media_type(filename: &str) -> Option<&'static str> {
198    let ext = filename.rsplit('.').next()?.to_ascii_lowercase();
199    Some(match ext.as_str() {
200        "pdf" => "application/pdf",
201        "txt" | "md" | "log" => "text/plain",
202        "json" => "application/json",
203        "csv" => "text/csv",
204        "html" | "htm" => "text/html",
205        "xml" => "application/xml",
206        "png" => "image/png",
207        "jpg" | "jpeg" => "image/jpeg",
208        "gif" => "image/gif",
209        "webp" => "image/webp",
210        _ => return None,
211    })
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use pretty_assertions::assert_eq;
218    use serde_json::json;
219    use wiremock::matchers::{header_exists, method, path};
220    use wiremock::{Mock, MockServer, ResponseTemplate};
221
222    fn client_for(mock: &MockServer) -> Client {
223        Client::builder()
224            .api_key("sk-ant-test")
225            .base_url(mock.uri())
226            .build()
227            .unwrap()
228    }
229
230    fn file_metadata_json(id: &str) -> serde_json::Value {
231        json!({
232            "id": id,
233            "type": "file",
234            "filename": "test.pdf",
235            "mime_type": "application/pdf",
236            "size_bytes": 4,
237            "created_at": "2026-04-30T00:00:00Z",
238            "downloadable": true
239        })
240    }
241
242    #[tokio::test]
243    async fn upload_bytes_sends_multipart_and_decodes_metadata() {
244        let mock = MockServer::start().await;
245        Mock::given(method("POST"))
246            .and(path("/v1/files"))
247            .and(header_exists("anthropic-beta"))
248            .respond_with(ResponseTemplate::new(200).set_body_json(file_metadata_json("file_b1")))
249            .mount(&mock)
250            .await;
251
252        let client = client_for(&mock);
253        let meta = client
254            .files()
255            .upload_bytes(Bytes::from_static(b"abcd"), "test.pdf", "application/pdf")
256            .await
257            .unwrap();
258        assert_eq!(meta.id, "file_b1");
259        assert_eq!(meta.size_bytes, 4);
260
261        // Verify the beta header is the Files-API tag.
262        let req = &mock.received_requests().await.unwrap()[0];
263        let beta = req.headers.get("anthropic-beta").unwrap().to_str().unwrap();
264        assert!(beta.contains("files-api-"), "{beta}");
265    }
266
267    #[tokio::test]
268    async fn upload_path_streams_real_file_from_disk() {
269        let mock = MockServer::start().await;
270        Mock::given(method("POST"))
271            .and(path("/v1/files"))
272            .respond_with(ResponseTemplate::new(200).set_body_json(file_metadata_json("file_p1")))
273            .mount(&mock)
274            .await;
275
276        let dir = std::env::temp_dir();
277        let path = dir.join(format!("claude_api_test_{}.txt", std::process::id()));
278        std::fs::write(&path, b"hello from disk").unwrap();
279
280        let client = client_for(&mock);
281        let meta = client.files().upload_path(&path).await.unwrap();
282        assert_eq!(meta.id, "file_p1");
283
284        std::fs::remove_file(&path).ok();
285    }
286
287    #[tokio::test]
288    async fn upload_stream_accepts_any_async_read() {
289        let mock = MockServer::start().await;
290        Mock::given(method("POST"))
291            .and(path("/v1/files"))
292            .respond_with(ResponseTemplate::new(200).set_body_json(file_metadata_json("file_s1")))
293            .mount(&mock)
294            .await;
295
296        let client = client_for(&mock);
297        // &[u8] satisfies AsyncRead through tokio's blanket impl, but we need
298        // owned + 'static. Wrap in a Cursor over Vec<u8>.
299        let reader = std::io::Cursor::new(b"streamed bytes".to_vec());
300        let meta = client
301            .files()
302            .upload_stream(reader, "stream.txt", "text/plain")
303            .await
304            .unwrap();
305        assert_eq!(meta.id, "file_s1");
306    }
307
308    #[tokio::test]
309    async fn get_returns_metadata_for_id() {
310        let mock = MockServer::start().await;
311        Mock::given(method("GET"))
312            .and(path("/v1/files/file_g1"))
313            .and(header_exists("anthropic-beta"))
314            .respond_with(ResponseTemplate::new(200).set_body_json(file_metadata_json("file_g1")))
315            .mount(&mock)
316            .await;
317
318        let client = client_for(&mock);
319        let meta = client.files().get("file_g1").await.unwrap();
320        assert_eq!(meta.id, "file_g1");
321    }
322
323    #[tokio::test]
324    async fn list_returns_paginated_envelope() {
325        let mock = MockServer::start().await;
326        Mock::given(method("GET"))
327            .and(path("/v1/files"))
328            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
329                "data": [file_metadata_json("file_l1"), file_metadata_json("file_l2")],
330                "has_more": false,
331                "first_id": "file_l1",
332                "last_id": "file_l2"
333            })))
334            .mount(&mock)
335            .await;
336
337        let client = client_for(&mock);
338        let page = client
339            .files()
340            .list(ListFilesParams::default())
341            .await
342            .unwrap();
343        assert_eq!(page.data.len(), 2);
344    }
345
346    #[tokio::test]
347    async fn delete_returns_typed_confirmation() {
348        let mock = MockServer::start().await;
349        Mock::given(method("DELETE"))
350            .and(path("/v1/files/file_d1"))
351            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
352                "id": "file_d1",
353                "type": "file_deleted"
354            })))
355            .mount(&mock)
356            .await;
357
358        let client = client_for(&mock);
359        let confirm = client.files().delete("file_d1").await.unwrap();
360        assert_eq!(confirm.id, "file_d1");
361        assert_eq!(confirm.kind, "file_deleted");
362    }
363
364    #[tokio::test]
365    async fn download_returns_file_bytes() {
366        let mock = MockServer::start().await;
367        Mock::given(method("GET"))
368            .and(path("/v1/files/file_dl1/content"))
369            .respond_with(ResponseTemplate::new(200).set_body_bytes(b"file payload bytes".to_vec()))
370            .mount(&mock)
371            .await;
372
373        let client = client_for(&mock);
374        let bytes = client.files().download("file_dl1").await.unwrap();
375        assert_eq!(&bytes[..], b"file payload bytes");
376    }
377
378    #[tokio::test]
379    async fn download_to_streams_into_async_write() {
380        let mock = MockServer::start().await;
381        Mock::given(method("GET"))
382            .and(path("/v1/files/file_dl2/content"))
383            .respond_with(ResponseTemplate::new(200).set_body_bytes(b"streamed download".to_vec()))
384            .mount(&mock)
385            .await;
386
387        let client = client_for(&mock);
388        let mut sink: Vec<u8> = Vec::new();
389        let bytes_written = client
390            .files()
391            .download_to("file_dl2", &mut sink)
392            .await
393            .unwrap();
394        assert_eq!(bytes_written, b"streamed download".len() as u64);
395        assert_eq!(&sink[..], b"streamed download");
396    }
397
398    #[tokio::test]
399    async fn download_propagates_404_with_request_id() {
400        let mock = MockServer::start().await;
401        Mock::given(method("GET"))
402            .and(path("/v1/files/missing/content"))
403            .respond_with(
404                ResponseTemplate::new(404)
405                    .insert_header("request-id", "req_404")
406                    .set_body_json(json!({
407                        "type": "error",
408                        "error": {"type": "not_found_error", "message": "no such file"}
409                    })),
410            )
411            .mount(&mock)
412            .await;
413
414        let client = client_for(&mock);
415        let err = client.files().download("missing").await.unwrap_err();
416        assert_eq!(err.status(), Some(http::StatusCode::NOT_FOUND));
417        assert_eq!(err.request_id(), Some("req_404"));
418    }
419
420    #[test]
421    fn guess_media_type_handles_common_extensions() {
422        assert_eq!(guess_media_type("doc.pdf"), Some("application/pdf"));
423        assert_eq!(guess_media_type("notes.MD"), Some("text/plain"));
424        assert_eq!(guess_media_type("photo.jpg"), Some("image/jpeg"));
425        assert_eq!(guess_media_type("photo.JPEG"), Some("image/jpeg"));
426        assert_eq!(guess_media_type("data.unknown"), None);
427        assert_eq!(guess_media_type("noext"), None);
428    }
429}