openai_ergonomic/builders/
uploads.rs

1//! Uploads API builders.
2//!
3//! Provides a simple builder for preparing multipart upload sessions with
4//! validation on file size and expiration settings.
5
6use openai_client_base::models::create_upload_request::Purpose;
7use openai_client_base::models::{file_expiration_after, CreateUploadRequest, FileExpirationAfter};
8
9use crate::{Builder, Error, Result};
10
11/// Builder for creating upload sessions.
12///
13/// # Examples
14///
15/// ```rust
16/// use openai_ergonomic::{Builder, UploadBuilder, UploadPurpose};
17///
18/// let request = UploadBuilder::new(
19///         "dataset.jsonl",
20///         UploadPurpose::Assistants,
21///         2048,
22///         "application/json",
23///     )
24///     .expires_after_seconds(3600)
25///     .build()
26///     .unwrap();
27///
28/// assert_eq!(request.filename, "dataset.jsonl");
29/// assert_eq!(request.purpose, UploadPurpose::Assistants);
30/// ```
31#[derive(Debug, Clone)]
32pub struct UploadBuilder {
33    filename: String,
34    purpose: Purpose,
35    bytes: i32,
36    mime_type: String,
37    expires_after: Option<FileExpirationAfter>,
38}
39
40impl UploadBuilder {
41    /// Create a new upload builder.
42    #[must_use]
43    pub fn new(
44        filename: impl Into<String>,
45        purpose: Purpose,
46        bytes: i32,
47        mime_type: impl Into<String>,
48    ) -> Self {
49        Self {
50            filename: filename.into(),
51            purpose,
52            bytes,
53            mime_type: mime_type.into(),
54            expires_after: None,
55        }
56    }
57
58    /// Override the filename before building the request.
59    #[must_use]
60    pub fn filename(mut self, filename: impl Into<String>) -> Self {
61        self.filename = filename.into();
62        self
63    }
64
65    /// Override the number of bytes expected for the upload.
66    #[must_use]
67    pub fn bytes(mut self, bytes: i32) -> Self {
68        self.bytes = bytes;
69        self
70    }
71
72    /// Override the MIME type.
73    #[must_use]
74    pub fn mime_type(mut self, mime_type: impl Into<String>) -> Self {
75        self.mime_type = mime_type.into();
76        self
77    }
78
79    /// Override the purpose for this upload.
80    #[must_use]
81    pub fn purpose(mut self, purpose: Purpose) -> Self {
82        self.purpose = purpose;
83        self
84    }
85
86    /// Set the expiration policy directly.
87    #[must_use]
88    pub fn expires_after(mut self, expiration: FileExpirationAfter) -> Self {
89        self.expires_after = Some(expiration);
90        self
91    }
92
93    /// Set the expiration in seconds using the default `created_at` anchor.
94    #[must_use]
95    pub fn expires_after_seconds(mut self, seconds: i32) -> Self {
96        let expiration =
97            FileExpirationAfter::new(file_expiration_after::Anchor::CreatedAt, seconds);
98        self.expires_after = Some(expiration);
99        self
100    }
101
102    /// Access the configured purpose.
103    #[must_use]
104    pub fn purpose_ref(&self) -> Purpose {
105        self.purpose
106    }
107
108    /// Access the configured expiration policy.
109    #[must_use]
110    pub fn expires_after_ref(&self) -> Option<&FileExpirationAfter> {
111        self.expires_after.as_ref()
112    }
113
114    fn validate(&self) -> Result<()> {
115        if self.bytes <= 0 {
116            return Err(Error::InvalidRequest(
117                "Upload byte size must be positive".to_string(),
118            ));
119        }
120
121        if let Some(expiration) = &self.expires_after {
122            if !(3600..=2_592_000).contains(&expiration.seconds) {
123                return Err(Error::InvalidRequest(format!(
124                    "Expiration seconds must be between 3600 and 2592000 (got {})",
125                    expiration.seconds
126                )));
127            }
128        }
129
130        Ok(())
131    }
132}
133
134impl Builder<CreateUploadRequest> for UploadBuilder {
135    fn build(self) -> Result<CreateUploadRequest> {
136        self.validate()?;
137        let mut request =
138            CreateUploadRequest::new(self.filename, self.purpose, self.bytes, self.mime_type);
139        request.expires_after = self.expires_after.map(Box::new);
140        Ok(request)
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn builds_valid_request() {
150        let builder = UploadBuilder::new(
151            "transcript.zip",
152            Purpose::Assistants,
153            1024,
154            "application/zip",
155        )
156        .expires_after_seconds(7200);
157
158        let request = builder.build().expect("builder should succeed");
159        assert_eq!(request.filename, "transcript.zip");
160        assert_eq!(request.bytes, 1024);
161        assert_eq!(request.mime_type, "application/zip");
162        assert!(request.expires_after.is_some());
163    }
164
165    #[test]
166    fn enforces_positive_bytes() {
167        let builder = UploadBuilder::new("file", Purpose::Assistants, 0, "text/plain");
168        let error = builder.build().expect_err("should fail validation");
169        assert!(matches!(error, Error::InvalidRequest(message) if message.contains("positive")));
170    }
171
172    #[test]
173    fn validates_expiration_range() {
174        let builder = UploadBuilder::new("file", Purpose::Assistants, 1_024, "text/plain")
175            .expires_after_seconds(10);
176        let error = builder.build().expect_err("should enforce range");
177        assert!(matches!(
178            error,
179            Error::InvalidRequest(message) if message.contains("3600")
180        ));
181    }
182}