chat_gpt_lib_rs/api_resources/
files.rs

1//! This module provides functionality for working with files using the
2//! [OpenAI Files API](https://platform.openai.com/docs/api-reference/files).
3//!
4//! Typical usage includes uploading a JSONL file for fine-tuning or other purposes,
5//! listing all files, retrieving file metadata, deleting a file, or even downloading
6//! its contents.
7//!
8//! # Workflow
9//!
10//! 1. **Upload a file** with [`upload_file`] (usually a `.jsonl` file for fine-tuning data).
11//! 2. **List files** with [`list_files`], which returns metadata for all uploaded files.
12//! 3. **Retrieve file metadata** with [`retrieve_file_metadata`] for a specific file ID.
13//! 4. **Delete a file** you no longer need with [`delete_file`].
14//! 5. **Download file content** with [`retrieve_file_content`], if necessary for debugging or reuse.
15//!
16//! # Example
17//!
18//! ```rust,no_run
19//! use chat_gpt_lib_rs::api_resources::files::{upload_file, UploadFilePurpose};
20//! use chat_gpt_lib_rs::OpenAIClient;
21//! use chat_gpt_lib_rs::error::OpenAIError;
22//! use std::path::PathBuf;
23//!
24//! #[tokio::main]
25//! async fn main() -> Result<(), OpenAIError> {
26//!     let client = OpenAIClient::new(None)?;
27//!
28//!     // Suppose you have a JSONL file at "./training_data.jsonl" for fine-tuning
29//!     let file_path = PathBuf::from("./training_data.jsonl");
30//!
31//!     // Upload the file with purpose "fine-tune"
32//!     let file_obj = upload_file(&client, &file_path, UploadFilePurpose::FineTune).await?;
33//!     println!("Uploaded file ID: {}", file_obj.id);
34//!
35//!     Ok(())
36//! }
37//! ```
38
39use std::path::Path;
40
41use reqwest::multipart::{Form, Part};
42use serde::{Deserialize, Serialize};
43
44use crate::config::OpenAIClient;
45use crate::error::OpenAIError;
46
47/// The "purpose" parameter you must supply when uploading a file.
48///
49/// For fine-tuning, use `UploadFilePurpose::FineTune`.
50/// For other potential upload workflows, consult the OpenAI docs.
51#[derive(Debug, Clone, Serialize)]
52#[serde(rename_all = "kebab-case")]
53pub enum UploadFilePurpose {
54    /// Indicates that this file will be used for fine-tuning.
55    FineTune,
56    /// If you want to specify another purpose (not commonly used).
57    Other(String),
58}
59
60impl std::fmt::Display for UploadFilePurpose {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        match self {
63            UploadFilePurpose::FineTune => write!(f, "fine-tune"),
64            UploadFilePurpose::Other(val) => write!(f, "{val}"),
65        }
66    }
67}
68
69/// Represents a file object in OpenAI.
70///
71/// For example, when you upload a file via `POST /v1/files`, the API responds with
72/// this structure containing metadata about the file.
73#[derive(Debug, Deserialize)]
74pub struct FileObject {
75    /// The ID of the file, e.g. "file-abc123".
76    pub id: String,
77    /// The object type, usually "file".
78    pub object: String,
79    /// The size of the file in bytes.
80    pub bytes: u64,
81    /// The time (in epoch seconds) when the file was uploaded.
82    pub created_at: u64,
83    /// The filename you provided during upload.
84    pub filename: String,
85    /// The purpose for which the file was uploaded (e.g. "fine-tune").
86    pub purpose: String,
87    /// The current status of the file, e.g. "uploaded".
88    pub status: Option<String>,
89    /// More detailed status information, if available.
90    pub status_details: Option<String>,
91}
92
93/// A response type for listing files. Contains an array of [`FileObject`].
94#[derive(Debug, Deserialize)]
95pub struct FileListResponse {
96    /// The object type, typically "list".
97    pub object: String,
98    /// The actual list of files.
99    pub data: Vec<FileObject>,
100}
101
102/// Represents the response returned by the Delete File endpoint.
103#[derive(Debug, Deserialize)]
104pub struct DeleteFileResponse {
105    /// The file ID that was deleted.
106    pub id: String,
107    /// The object type, usually "file".
108    pub object: String,
109    /// Indicates that the file was deleted.
110    pub deleted: bool,
111}
112
113/// Uploads a file to OpenAI.
114///
115/// This requires multipart form data:
116/// - A "file" field with the actual file bytes
117/// - A "purpose" field with the reason for upload (e.g. "fine-tune")
118///
119/// The purpose is required by the API.
120///
121/// # Parameters
122/// * `client` - The OpenAI client.
123/// * `file_path` - Path to the local file to upload.
124/// * `purpose` - The file's intended usage (e.g. `UploadFilePurpose::FineTune`).
125///
126/// # Returns
127/// A [`FileObject`] containing metadata about the newly uploaded file.
128///
129/// # Errors
130/// Returns [`OpenAIError`] if the network request fails, the file can’t be read,
131/// or the API returns an error.
132pub async fn upload_file(
133    client: &OpenAIClient,
134    file_path: &Path,
135    purpose: UploadFilePurpose,
136) -> Result<FileObject, OpenAIError> {
137    let endpoint = "files";
138    let url = format!("{}/{}", client.base_url().trim_end_matches('/'), endpoint);
139
140    // Prepare the multipart form
141    let file_bytes = tokio::fs::read(file_path)
142        .await
143        .map_err(|e| OpenAIError::ConfigError(format!("Failed to read file: {}", e)))?;
144    let filename = file_path
145        .file_name()
146        .map(|os| os.to_string_lossy().into_owned())
147        .unwrap_or_else(|| "upload.bin".to_string());
148
149    let file_part = Part::bytes(file_bytes)
150        .file_name(filename)
151        .mime_str("application/octet-stream")
152        .unwrap_or_else(|_| {
153            // In a real scenario, if mime_str fails, we fallback to a default
154            Part::bytes(Vec::new()).file_name("default.bin")
155        });
156
157    // The "purpose" must be a string field in the form
158    let form = Form::new()
159        .part("file", file_part)
160        .text("purpose", purpose.to_string());
161
162    // Send the request
163    let response = client
164        .http_client
165        .post(&url)
166        .bearer_auth(client.api_key())
167        .multipart(form)
168        .send()
169        .await?;
170
171    handle_file_response(response).await
172}
173
174/// Lists all files stored in your OpenAI account.
175///
176/// # Returns
177/// A [`FileListResponse`] containing metadata for each file.
178///
179/// # Errors
180/// Returns [`OpenAIError`] if the request fails or the API returns an error.
181pub async fn list_files(client: &OpenAIClient) -> Result<FileListResponse, OpenAIError> {
182    let endpoint = "files";
183    let url = format!("{}/{}", client.base_url().trim_end_matches('/'), endpoint);
184
185    let response = client
186        .http_client
187        .get(&url)
188        .bearer_auth(client.api_key())
189        .send()
190        .await?;
191
192    let status = response.status();
193    if status.is_success() {
194        let files = response.json::<FileListResponse>().await?;
195        Ok(files)
196    } else {
197        crate::api::parse_error_response(response).await
198    }
199}
200
201/// Retrieves metadata about a specific file by its ID.
202///
203/// # Parameters
204/// * `file_id` - The file ID, e.g. "file-abc123"
205///
206/// # Returns
207/// A [`FileObject`] representing the file's metadata.
208pub async fn retrieve_file_metadata(
209    client: &OpenAIClient,
210    file_id: &str,
211) -> Result<FileObject, OpenAIError> {
212    let endpoint = format!("files/{}", file_id);
213    let url = format!("{}/{}", client.base_url().trim_end_matches('/'), endpoint);
214
215    let response = client
216        .http_client
217        .get(&url)
218        .bearer_auth(client.api_key())
219        .send()
220        .await?;
221
222    handle_file_response(response).await
223}
224
225/// Downloads the content of a file by its ID.
226///
227/// **Note**: For fine-tuning `.jsonl` files, you can retrieve the training data
228/// to verify or reuse it.
229///
230/// # Parameters
231/// * `file_id` - The file ID to download.
232///
233/// # Returns
234/// A `Vec<u8>` containing the raw file data.
235pub async fn retrieve_file_content(
236    client: &OpenAIClient,
237    file_id: &str,
238) -> Result<Vec<u8>, OpenAIError> {
239    // The official docs:
240    // GET /v1/files/{file_id}/content
241    let endpoint = format!("files/{}/content", file_id);
242    let url = format!("{}/{}", client.base_url().trim_end_matches('/'), endpoint);
243
244    let response = client
245        .http_client
246        .get(&url)
247        .bearer_auth(client.api_key())
248        .send()
249        .await?;
250
251    if response.status().is_success() {
252        let bytes = response.bytes().await?;
253        Ok(bytes.to_vec())
254    } else {
255        crate::api::parse_error_response(response).await
256    }
257}
258
259/// Deletes a file by its ID.
260///
261/// # Parameters
262/// * `file_id` - The file ID, e.g. "file-abc123"
263///
264/// # Returns
265/// A [`DeleteFileResponse`] indicating success or failure (the `deleted` field
266/// should be true if it succeeds).
267pub async fn delete_file(
268    client: &OpenAIClient,
269    file_id: &str,
270) -> Result<DeleteFileResponse, OpenAIError> {
271    let endpoint = format!("files/{}", file_id);
272    let url = format!("{}/{}", client.base_url().trim_end_matches('/'), endpoint);
273
274    let response = client
275        .http_client
276        .delete(&url)
277        .bearer_auth(client.api_key())
278        .send()
279        .await?;
280
281    let status = response.status();
282    if status.is_success() {
283        let res_body = response.json::<DeleteFileResponse>().await?;
284        Ok(res_body)
285    } else {
286        crate::api::parse_error_response(response).await
287    }
288}
289
290/// Helper to handle responses that should yield a [`FileObject`].
291async fn handle_file_response(response: reqwest::Response) -> Result<FileObject, OpenAIError> {
292    let status = response.status();
293    if status.is_success() {
294        let file_obj = response.json::<FileObject>().await?;
295        Ok(file_obj)
296    } else {
297        crate::api::parse_error_response(response).await
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    //! # Tests for the `files` module
304    //!
305    //! Here we use [`wiremock`](https://crates.io/crates/wiremock) to simulate OpenAI's Files API:
306    //! 1. **Upload** a file with multipart form data
307    //! 2. **List** files
308    //! 3. **Retrieve** file metadata
309    //! 4. **Delete** a file
310    //! 5. **Retrieve** file content
311    //!
312    //! We test both **successful** (2xx) cases and **error** cases (non-2xx with an OpenAI-style error).
313    //! For the file upload, we use a temporary in-memory file to emulate reading bytes.
314
315    use super::*;
316    use crate::config::OpenAIClient;
317    use crate::error::OpenAIError;
318    use serde_json::json;
319    use std::io::Write as _;
320    use tempfile::NamedTempFile;
321    use wiremock::matchers::{method, path, path_regex};
322    use wiremock::{Mock, MockServer, ResponseTemplate};
323
324    /// Creates a temporary file with specified contents for testing upload.
325    /// Returns the `NamedTempFile` handle so it gets cleaned up automatically.
326    fn create_temp_file(contents: &str) -> NamedTempFile {
327        let mut file = NamedTempFile::new().expect("Failed to create temp file");
328        write!(file, "{}", contents).expect("Unable to write to temp file");
329        file
330    }
331
332    #[tokio::test]
333    async fn test_upload_file_success() {
334        // Start a local mock server
335        let mock_server = MockServer::start().await;
336
337        // Simulate a successful 200 JSON response with file metadata
338        let success_body = json!({
339            "id": "file-abc123",
340            "object": "file",
341            "bytes": 1024,
342            "created_at": 1673643147,
343            "filename": "mydata.jsonl",
344            "purpose": "fine-tune",
345            "status": "uploaded",
346            "status_details": null
347        });
348
349        Mock::given(method("POST"))
350            .and(path("/files"))
351            .respond_with(ResponseTemplate::new(200).set_body_json(success_body))
352            .mount(&mock_server)
353            .await;
354
355        // Create a test client
356        let client = OpenAIClient::builder()
357            .with_api_key("test-key")
358            .with_base_url(&mock_server.uri())
359            .build()
360            .unwrap();
361
362        // Create a temp file to mock reading local data
363        let temp_file = create_temp_file("some jsonl contents");
364        let result = upload_file(&client, temp_file.path(), UploadFilePurpose::FineTune).await;
365        assert!(result.is_ok(), "Expected success, got: {:?}", result);
366
367        let file_obj = result.unwrap();
368        assert_eq!(file_obj.id, "file-abc123");
369        assert_eq!(file_obj.object, "file");
370        assert_eq!(file_obj.bytes, 1024);
371        assert_eq!(file_obj.filename, "mydata.jsonl");
372        assert_eq!(file_obj.purpose, "fine-tune");
373        assert_eq!(file_obj.status.as_deref(), Some("uploaded"));
374    }
375
376    #[tokio::test]
377    async fn test_upload_file_api_error() {
378        let mock_server = MockServer::start().await;
379
380        let error_body = json!({
381            "error": {
382                "message": "File size too large",
383                "type": "invalid_request_error",
384                "code": "file_size_exceeded"
385            }
386        });
387
388        // Return a 400 for the file upload
389        Mock::given(method("POST"))
390            .and(path("/files"))
391            .respond_with(ResponseTemplate::new(400).set_body_json(error_body))
392            .mount(&mock_server)
393            .await;
394
395        let client = OpenAIClient::builder()
396            .with_api_key("test-key")
397            .with_base_url(&mock_server.uri())
398            .build()
399            .unwrap();
400
401        let temp_file = create_temp_file("some jsonl contents");
402        let result = upload_file(&client, temp_file.path(), UploadFilePurpose::FineTune).await;
403
404        match result {
405            Err(OpenAIError::APIError { message, .. }) => {
406                assert!(message.contains("File size too large"));
407            }
408            other => panic!("Expected APIError, got {:?}", other),
409        }
410    }
411
412    #[tokio::test]
413    async fn test_upload_file_config_error_when_file_missing() {
414        // Test reading a non-existent file, which triggers a ConfigError from `upload_file`.
415        let mock_server = MockServer::start().await;
416        let client = OpenAIClient::builder()
417            .with_api_key("test-key")
418            .with_base_url(&mock_server.uri())
419            .build()
420            .unwrap();
421
422        let non_existent_path = std::path::Path::new("/some/path/that/does/not/exist.jsonl");
423        let result = upload_file(&client, non_existent_path, UploadFilePurpose::FineTune).await;
424        match result {
425            Err(OpenAIError::ConfigError(msg)) => {
426                assert!(
427                    msg.contains("Failed to read file:"),
428                    "Expected a file read error, got: {}",
429                    msg
430                );
431            }
432            other => panic!("Expected ConfigError, got {:?}", other),
433        }
434    }
435
436    #[tokio::test]
437    async fn test_list_files_success() {
438        let mock_server = MockServer::start().await;
439
440        let success_body = json!({
441            "object": "list",
442            "data": [
443                {
444                    "id": "file-xyz789",
445                    "object": "file",
446                    "bytes": 999,
447                    "created_at": 1673644000,
448                    "filename": "data.jsonl",
449                    "purpose": "fine-tune",
450                    "status": "uploaded",
451                    "status_details": null
452                }
453            ]
454        });
455
456        Mock::given(method("GET"))
457            .and(path("/files"))
458            .respond_with(ResponseTemplate::new(200).set_body_json(success_body))
459            .mount(&mock_server)
460            .await;
461
462        let client = OpenAIClient::builder()
463            .with_api_key("test-key")
464            .with_base_url(&mock_server.uri())
465            .build()
466            .unwrap();
467
468        let result = list_files(&client).await;
469        assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
470
471        let files = result.unwrap();
472        assert_eq!(files.object, "list");
473        assert_eq!(files.data.len(), 1);
474        let file_obj = &files.data[0];
475        assert_eq!(file_obj.id, "file-xyz789");
476        assert_eq!(file_obj.bytes, 999);
477    }
478
479    #[tokio::test]
480    async fn test_list_files_api_error() {
481        let mock_server = MockServer::start().await;
482        let error_body = json!({
483            "error": {
484                "message": "Could not list files",
485                "type": "internal_server_error",
486                "code": null
487            }
488        });
489
490        Mock::given(method("GET"))
491            .and(path("/files"))
492            .respond_with(ResponseTemplate::new(500).set_body_json(error_body))
493            .mount(&mock_server)
494            .await;
495
496        let client = OpenAIClient::builder()
497            .with_api_key("test-key")
498            .with_base_url(&mock_server.uri())
499            .build()
500            .unwrap();
501
502        let result = list_files(&client).await;
503        match result {
504            Err(OpenAIError::APIError { message, .. }) => {
505                assert!(message.contains("Could not list files"));
506            }
507            other => panic!("Expected APIError, got {:?}", other),
508        }
509    }
510
511    #[tokio::test]
512    async fn test_retrieve_file_metadata_success() {
513        let mock_server = MockServer::start().await;
514        let success_body = json!({
515            "id": "file-abc123",
516            "object": "file",
517            "bytes": 2048,
518            "created_at": 1673645000,
519            "filename": "info.jsonl",
520            "purpose": "fine-tune",
521            "status": "uploaded",
522            "status_details": null
523        });
524
525        Mock::given(method("GET"))
526            .and(path_regex(r"^/files/file-abc123$"))
527            .respond_with(ResponseTemplate::new(200).set_body_json(success_body))
528            .mount(&mock_server)
529            .await;
530
531        let client = OpenAIClient::builder()
532            .with_api_key("test-key")
533            .with_base_url(&mock_server.uri())
534            .build()
535            .unwrap();
536
537        let result = retrieve_file_metadata(&client, "file-abc123").await;
538        assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
539
540        let file_obj = result.unwrap();
541        assert_eq!(file_obj.id, "file-abc123");
542        assert_eq!(file_obj.bytes, 2048);
543        assert_eq!(file_obj.filename, "info.jsonl");
544    }
545
546    #[tokio::test]
547    async fn test_retrieve_file_metadata_api_error() {
548        let mock_server = MockServer::start().await;
549        let error_body = json!({
550            "error": {
551                "message": "File not found",
552                "type": "invalid_request_error",
553                "code": null
554            }
555        });
556
557        Mock::given(method("GET"))
558            .and(path_regex(r"^/files/file-999$"))
559            .respond_with(ResponseTemplate::new(404).set_body_json(error_body))
560            .mount(&mock_server)
561            .await;
562
563        let client = OpenAIClient::builder()
564            .with_api_key("test-key")
565            .with_base_url(&mock_server.uri())
566            .build()
567            .unwrap();
568
569        let result = retrieve_file_metadata(&client, "file-999").await;
570        match result {
571            Err(OpenAIError::APIError { message, .. }) => {
572                assert!(message.contains("File not found"));
573            }
574            other => panic!("Expected APIError, got {:?}", other),
575        }
576    }
577
578    #[tokio::test]
579    async fn test_retrieve_file_content_success() {
580        let mock_server = MockServer::start().await;
581        let file_data = b"this is the file content";
582
583        Mock::given(method("GET"))
584            .and(path_regex(r"^/files/file-abc123/content$"))
585            .respond_with(
586                ResponseTemplate::new(200).set_body_raw(file_data, "application/octet-stream"),
587            )
588            .mount(&mock_server)
589            .await;
590
591        let client = OpenAIClient::builder()
592            .with_api_key("test-key")
593            .with_base_url(&mock_server.uri())
594            .build()
595            .unwrap();
596
597        let result = retrieve_file_content(&client, "file-abc123").await;
598        assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
599
600        let content = result.unwrap();
601        assert_eq!(content, file_data);
602    }
603
604    #[tokio::test]
605    async fn test_retrieve_file_content_api_error() {
606        let mock_server = MockServer::start().await;
607        let error_body = json!({
608            "error": {
609                "message": "Content not found",
610                "type": "invalid_request_error",
611                "code": null
612            }
613        });
614
615        Mock::given(method("GET"))
616            .and(path_regex(r"^/files/file-000/content$"))
617            .respond_with(ResponseTemplate::new(404).set_body_json(error_body))
618            .mount(&mock_server)
619            .await;
620
621        let client = OpenAIClient::builder()
622            .with_api_key("test-key")
623            .with_base_url(&mock_server.uri())
624            .build()
625            .unwrap();
626
627        let result = retrieve_file_content(&client, "file-000").await;
628        match result {
629            Err(OpenAIError::APIError { message, .. }) => {
630                assert!(message.contains("Content not found"));
631            }
632            other => panic!("Expected APIError, got {:?}", other),
633        }
634    }
635
636    #[tokio::test]
637    async fn test_delete_file_success() {
638        let mock_server = MockServer::start().await;
639        let success_body = json!({
640            "id": "file-abc123",
641            "object": "file",
642            "deleted": true
643        });
644
645        Mock::given(method("DELETE"))
646            .and(path_regex(r"^/files/file-abc123$"))
647            .respond_with(ResponseTemplate::new(200).set_body_json(success_body))
648            .mount(&mock_server)
649            .await;
650
651        let client = OpenAIClient::builder()
652            .with_api_key("test-key")
653            .with_base_url(&mock_server.uri())
654            .build()
655            .unwrap();
656
657        let result = delete_file(&client, "file-abc123").await;
658        assert!(result.is_ok(), "Expected Ok, got: {:?}", result);
659
660        let del_resp = result.unwrap();
661        assert_eq!(del_resp.id, "file-abc123");
662        assert_eq!(del_resp.object, "file");
663        assert!(del_resp.deleted);
664    }
665
666    #[tokio::test]
667    async fn test_delete_file_api_error() {
668        let mock_server = MockServer::start().await;
669        let error_body = json!({
670            "error": {
671                "message": "No file with ID file-xyz found",
672                "type": "invalid_request_error",
673                "code": null
674            }
675        });
676
677        Mock::given(method("DELETE"))
678            .and(path_regex(r"^/files/file-xyz$"))
679            .respond_with(ResponseTemplate::new(404).set_body_json(error_body))
680            .mount(&mock_server)
681            .await;
682
683        let client = OpenAIClient::builder()
684            .with_api_key("test-key")
685            .with_base_url(&mock_server.uri())
686            .build()
687            .unwrap();
688
689        let result = delete_file(&client, "file-xyz").await;
690        match result {
691            Err(OpenAIError::APIError { message, .. }) => {
692                assert!(message.contains("No file with ID file-xyz found"));
693            }
694            other => panic!("Expected APIError, got {:?}", other),
695        }
696    }
697}