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