Skip to main content

claude_api/files/
api.rs

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