1#![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
18const FILES_BETA: &[&str] = &["files-api-2025-04-14"];
20
21pub 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 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 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 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 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 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 pub async fn list(&self, params: ListFilesParams) -> Result<Paginated<FileMetadata>> {
117 let params_ref = ¶ms;
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 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 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 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 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
194fn 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 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 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}