Skip to main content

providers_kit/
files.rs

1//! Provider-owned file upload, metadata, pagination, and bounded downloads.
2//!
3//! [`FileUploadRequest`] owns validated bytes. Submit it through
4//! [`crate::ProviderBackend::upload_file`], retain the returned lifecycle id, and
5//! explicitly delete only caller-owned files. [`FileObject::input_reference`](crate::files::FileObject::input_reference) may
6//! differ from the lifecycle id. Processing readiness and operation support are
7//! provider-authored facts rather than assumptions following upload.
8//!
9//! # Retain ownership through deletion
10//!
11//! This requires an OpenAI key and upload/read/delete access. The exact upload id
12//! is the cleanup authority; neither a filename nor a list-difference establishes
13//! ownership. If upload fails without a returned id, retain the error's evidence of
14//! any uncertain remote outcome rather than deleting a guessed resource.
15//!
16//! ```no_run
17//! use std::num::NonZeroUsize;
18//! use providers_kit::{Provider, ProviderBackend, ProviderConfig};
19//! use providers_kit::config::{ApiKey, OpenAiConfig, ProviderKind, TransportConfig};
20//! use providers_kit::files::FileUploadRequest;
21//! # #[tokio::main(flavor = "current_thread")]
22//! # async fn main() -> Result<(), providers_kit::KitError> {
23//! let provider = Provider::from_config(ProviderConfig::new(
24//!     ProviderKind::OpenAi(OpenAiConfig::new(ApiKey::new("replace-with-your-key")?)),
25//!     TransportConfig::default(),
26//! ))?;
27//! let uploaded = provider.upload_file(FileUploadRequest::new("note.txt", "text/plain", b"owned note".to_vec())?).await;
28//! let work = async {
29//!     let file = uploaded.as_ref().map_err(Clone::clone)?;
30//!     let info = provider.file_info(file.id.clone()).await?;
31//!     assert_eq!(info.id, file.id);
32//!     let bytes = provider.download_file(file.id.clone(), NonZeroUsize::new(1024).unwrap()).await?;
33//!     assert_eq!(bytes, b"owned note");
34//!     Ok::<(), providers_kit::KitError>(())
35//! }.await;
36//! let deletion = match uploaded {
37//!     Ok(file) => provider.delete_file(file.id).await,
38//!     Err(_) => Ok(()),
39//! };
40//! let shutdown = provider.shutdown().await;
41//! // Retain cleanup errors as supplementary evidence on an earlier failure.
42//! let mut outcome = work;
43//! for cleanup in [deletion, shutdown] {
44//!     if let Err(error) = cleanup {
45//!         outcome = Err(match outcome { Err(primary) => primary.with_supplementary(error), Ok(()) => error });
46//!     }
47//! }
48//! outcome?;
49//! # Ok(())
50//! # }
51//! ```
52//!
53//! Provider shutdown settles local resources only. Failed deletion remains a remote
54//! cleanup obligation; it is not converted into success by shutdown.
55
56use std::num::NonZeroUsize;
57
58use reqwest::multipart::Part;
59use serde::{Deserialize, Deserializer, Serialize};
60
61use crate::KitError;
62use crate::core::content::base64_bytes;
63use crate::error::ValidationViolation;
64use crate::generation::FileReference;
65
66/// Validated provider-file upload request.
67///
68/// This owns bytes without uploading them. Submit it through [`crate::ProviderBackend::upload_file`]
69/// and retain the returned lifecycle id for explicit owned-resource cleanup.
70///
71/// # Examples
72///
73/// ```
74/// use providers_kit::files::FileUploadRequest;
75/// let request = FileUploadRequest::new("notes.txt", "text/plain", b"notes".to_vec())?;
76/// assert_eq!(request.file_name(), "notes.txt");
77/// assert_eq!(request.media_type(), "text/plain");
78/// assert_eq!(request.data(), b"notes");
79///
80/// # Ok::<(), providers_kit::KitError>(())
81/// ```
82///
83#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
84#[serde(deny_unknown_fields)]
85pub struct FileUploadRequest {
86    file_name: String,
87    media_type: String,
88    #[serde(with = "base64_bytes")]
89    data: Vec<u8>,
90}
91
92impl FileUploadRequest {
93    /// Creates a non-empty upload with a file name and media type.
94    ///
95    /// # Errors
96    ///
97    /// Returns an input error for an empty, padded, or control-containing file name; malformed or padded MIME type/parameters; or empty file bytes.
98    ///
99    /// # Examples
100    ///
101    /// ```
102    /// use providers_kit::files::FileUploadRequest;
103    /// let request = FileUploadRequest::new("notes.txt", "text/plain", b"notes".to_vec())?;
104    /// assert_eq!(request.file_name(), "notes.txt");
105    /// assert_eq!(request.media_type(), "text/plain");
106    /// assert_eq!(request.data(), b"notes");
107    ///
108    /// # Ok::<(), providers_kit::KitError>(())
109    /// ```
110    ///
111    pub fn new(
112        file_name: impl Into<String>,
113        media_type: impl Into<String>,
114        data: Vec<u8>,
115    ) -> Result<Self, KitError> {
116        let file_name = file_name.into();
117        let media_type = media_type.into();
118        if file_name.is_empty()
119            || file_name.trim() != file_name
120            || file_name.chars().any(char::is_control)
121        {
122            return Err(KitError::invalid_input(
123                ValidationViolation::InvalidFormat {
124                    field: "file.name",
125                    rule: "nonblank, unpadded, without control characters",
126                },
127                "upload file name must be nonblank, unpadded, and contain no control characters",
128            ));
129        }
130        let parameters_valid = media_type.split(';').skip(1).all(|parameter| {
131            parameter
132                .split_once('=')
133                .is_some_and(|(name, value)| !name.trim().is_empty() && !value.trim().is_empty())
134        });
135        if media_type.is_empty()
136            || media_type.trim() != media_type
137            || media_type.chars().any(char::is_control)
138            || !parameters_valid
139            || Part::bytes(Vec::new()).mime_str(&media_type).is_err()
140        {
141            return Err(KitError::invalid_input(
142                ValidationViolation::InvalidFormat {
143                    field: "file.media_type",
144                    rule: "valid type/subtype",
145                },
146                "upload media type must be a valid type/subtype",
147            ));
148        }
149        if data.is_empty() {
150            return Err(KitError::invalid_input(
151                ValidationViolation::Empty { field: "file.data" },
152                "upload data must not be empty",
153            ));
154        }
155        Ok(Self {
156            file_name,
157            media_type,
158            data,
159        })
160    }
161
162    #[must_use]
163    /// Returns the file name presented to the provider.
164    ///
165    /// # Examples
166    ///
167    /// ```
168    /// use providers_kit::files::FileUploadRequest;
169    /// let request = FileUploadRequest::new("notes.txt", "text/plain", b"notes".to_vec())?;
170    /// assert_eq!(request.file_name(), "notes.txt");
171    /// assert_eq!(request.media_type(), "text/plain");
172    /// assert_eq!(request.data(), b"notes");
173    ///
174    /// # Ok::<(), providers_kit::KitError>(())
175    /// ```
176    ///
177    pub fn file_name(&self) -> &str {
178        &self.file_name
179    }
180
181    #[must_use]
182    /// Returns the declared media type.
183    ///
184    /// # Examples
185    ///
186    /// ```
187    /// use providers_kit::files::FileUploadRequest;
188    /// let request = FileUploadRequest::new("notes.txt", "text/plain", b"notes".to_vec())?;
189    /// assert_eq!(request.file_name(), "notes.txt");
190    /// assert_eq!(request.media_type(), "text/plain");
191    /// assert_eq!(request.data(), b"notes");
192    ///
193    /// # Ok::<(), providers_kit::KitError>(())
194    /// ```
195    ///
196    pub fn media_type(&self) -> &str {
197        &self.media_type
198    }
199
200    #[must_use]
201    /// Returns the raw file bytes.
202    ///
203    /// # Examples
204    ///
205    /// ```
206    /// use providers_kit::files::FileUploadRequest;
207    /// let request = FileUploadRequest::new("notes.txt", "text/plain", b"notes".to_vec())?;
208    /// assert_eq!(request.file_name(), "notes.txt");
209    /// assert_eq!(request.media_type(), "text/plain");
210    /// assert_eq!(request.data(), b"notes");
211    ///
212    /// # Ok::<(), providers_kit::KitError>(())
213    /// ```
214    ///
215    pub fn data(&self) -> &[u8] {
216        &self.data
217    }
218
219    #[must_use]
220    /// Consumes the request into its file name, media type, and bytes.
221    ///
222    /// # Examples
223    ///
224    /// ```
225    /// use providers_kit::files::FileUploadRequest;
226    /// let request = FileUploadRequest::new("notes.txt", "text/plain", b"notes".to_vec())?;
227    /// let (name, media_type, bytes) = request.into_parts();
228    /// assert_eq!(name, "notes.txt");
229    /// assert_eq!(media_type, "text/plain");
230    /// assert_eq!(bytes, b"notes");
231    ///
232    /// # Ok::<(), providers_kit::KitError>(())
233    /// ```
234    ///
235    pub fn into_parts(self) -> (String, String, Vec<u8>) {
236        (self.file_name, self.media_type, self.data)
237    }
238}
239
240impl<'de> Deserialize<'de> for FileUploadRequest {
241    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
242    where
243        D: Deserializer<'de>,
244    {
245        #[derive(Deserialize)]
246        #[serde(deny_unknown_fields)]
247        struct Wire {
248            file_name: String,
249            media_type: String,
250            #[serde(with = "base64_bytes")]
251            data: Vec<u8>,
252        }
253        let wire = Wire::deserialize(deserializer)?;
254        Self::new(wire.file_name, wire.media_type, wire.data).map_err(serde::de::Error::custom)
255    }
256}
257
258/// Validated file-list pagination request.
259///
260/// Pagination cursors are opaque provider-owned values; a requested limit is not a
261/// guarantee that every provider supports the same pagination controls.
262///
263/// # Examples
264///
265/// ```
266/// use providers_kit::files::FileListRequest;
267/// let request = FileListRequest::default().with_limit(std::num::NonZeroUsize::new(10).unwrap()).with_cursor("opaque-next")?;
268/// assert_eq!(request.limit().unwrap().get(), 10);
269/// assert_eq!(request.cursor(), Some("opaque-next"));
270///
271/// # Ok::<(), providers_kit::KitError>(())
272/// ```
273///
274#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
275pub struct FileListRequest {
276    limit: Option<NonZeroUsize>,
277    cursor: Option<String>,
278}
279
280impl FileListRequest {
281    /// Sets a non-zero result limit.
282    ///
283    /// # Examples
284    ///
285    /// ```
286    /// use providers_kit::files::FileListRequest;
287    /// let request = FileListRequest::default().with_limit(std::num::NonZeroUsize::new(10).unwrap()).with_cursor("opaque-next")?;
288    /// assert_eq!(request.limit().unwrap().get(), 10);
289    /// assert_eq!(request.cursor(), Some("opaque-next"));
290    ///
291    /// # Ok::<(), providers_kit::KitError>(())
292    /// ```
293    ///
294    #[must_use]
295    pub fn with_limit(mut self, limit: NonZeroUsize) -> Self {
296        self.limit = Some(limit);
297        self
298    }
299
300    /// Sets a non-empty provider-native continuation cursor.
301    ///
302    /// # Errors
303    ///
304    /// Returns an input error when the cursor is empty or whitespace-only. Nonblank cursor bytes are retained exactly; use only a cursor returned by the same provider operation.
305    ///
306    /// # Examples
307    ///
308    /// ```
309    /// use providers_kit::files::FileListRequest;
310    /// let request = FileListRequest::default().with_limit(std::num::NonZeroUsize::new(10).unwrap()).with_cursor("opaque-next")?;
311    /// assert_eq!(request.limit().unwrap().get(), 10);
312    /// assert_eq!(request.cursor(), Some("opaque-next"));
313    ///
314    /// # Ok::<(), providers_kit::KitError>(())
315    /// ```
316    ///
317    pub fn with_cursor(mut self, cursor: impl Into<String>) -> Result<Self, KitError> {
318        let cursor = cursor.into();
319        if cursor.trim().is_empty() {
320            return Err(KitError::invalid_input(
321                ValidationViolation::Empty {
322                    field: "files.cursor",
323                },
324                "file cursor must not be empty",
325            ));
326        }
327        self.cursor = Some(cursor);
328        Ok(self)
329    }
330
331    /// Returns the requested result limit.
332    ///
333    /// # Examples
334    ///
335    /// ```
336    /// use providers_kit::files::FileListRequest;
337    /// let request = FileListRequest::default().with_limit(std::num::NonZeroUsize::new(10).unwrap()).with_cursor("opaque-next")?;
338    /// assert_eq!(request.limit().unwrap().get(), 10);
339    /// assert_eq!(request.cursor(), Some("opaque-next"));
340    ///
341    /// # Ok::<(), providers_kit::KitError>(())
342    /// ```
343    ///
344    #[must_use]
345    pub const fn limit(&self) -> Option<NonZeroUsize> {
346        self.limit
347    }
348
349    /// Returns the provider-native continuation cursor.
350    ///
351    /// # Examples
352    ///
353    /// ```
354    /// use providers_kit::files::FileListRequest;
355    /// let request = FileListRequest::default().with_limit(std::num::NonZeroUsize::new(10).unwrap()).with_cursor("opaque-next")?;
356    /// assert_eq!(request.limit().unwrap().get(), 10);
357    /// assert_eq!(request.cursor(), Some("opaque-next"));
358    ///
359    /// # Ok::<(), providers_kit::KitError>(())
360    /// ```
361    ///
362    #[must_use]
363    pub fn cursor(&self) -> Option<&str> {
364        self.cursor.as_deref()
365    }
366
367    /// Consumes the request into its limit and continuation cursor.
368    ///
369    /// # Examples
370    ///
371    /// ```
372    /// use providers_kit::files::FileListRequest;
373    /// let request = FileListRequest::default().with_limit(std::num::NonZeroUsize::new(10).unwrap()).with_cursor("opaque-next")?;
374    /// let (limit, cursor) = request.into_parts();
375    /// assert_eq!(limit.unwrap().get(), 10);
376    /// assert_eq!(cursor.as_deref(), Some("opaque-next"));
377    ///
378    /// # Ok::<(), providers_kit::KitError>(())
379    /// ```
380    ///
381    #[must_use]
382    pub fn into_parts(self) -> (Option<NonZeroUsize>, Option<String>) {
383        (self.limit, self.cursor)
384    }
385}
386
387impl<'de> Deserialize<'de> for FileListRequest {
388    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
389    where
390        D: Deserializer<'de>,
391    {
392        #[derive(Deserialize)]
393        #[serde(deny_unknown_fields)]
394        struct Wire {
395            limit: Option<NonZeroUsize>,
396            cursor: Option<String>,
397        }
398        let wire = Wire::deserialize(deserializer)?;
399        let mut request = Self {
400            limit: wire.limit,
401            cursor: None,
402        };
403        if let Some(cursor) = wire.cursor {
404            request = request
405                .with_cursor(cursor)
406                .map_err(serde::de::Error::custom)?;
407        }
408        Ok(request)
409    }
410}
411
412/// Provider-managed file metadata.
413///
414/// The lifecycle `id` can differ from `input_reference`; use the returned generation
415/// reference where supplied. Upload success does not establish processing readiness.
416///
417/// # Examples
418///
419/// ```
420/// use providers_kit::files::{FileObject, FileState};
421/// let file = FileObject { id: "owned-file".into(), file_name: Some("notes.txt".into()), size_bytes: Some(5), media_type: Some("text/plain".into()), created_at: None, state: Some(FileState::Processing), input_reference: None };
422/// assert!(matches!(file.state, Some(FileState::Processing)));
423/// ```
424///
425#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
426pub struct FileObject {
427    /// Exact provider-native lifecycle identifier.
428    pub id: String,
429    /// Provider-reported file name, when available.
430    pub file_name: Option<String>,
431    /// Provider-reported size in bytes, when available.
432    pub size_bytes: Option<u64>,
433    /// Provider-reported media type, when available.
434    pub media_type: Option<String>,
435    /// Provider-reported creation timestamp, when available.
436    pub created_at: Option<String>,
437    /// Current provider-authored lifecycle state, when available.
438    pub state: Option<FileState>,
439    /// Exact reference usable as generation input, when distinct from lifecycle `id`.
440    pub input_reference: Option<FileReference>,
441}
442
443/// Provider-managed file lifecycle state.
444#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
445#[serde(tag = "state", rename_all = "snake_case")]
446pub enum FileState {
447    /// The provider is processing the uploaded file.
448    Processing,
449    /// The file is ready for supported provider operations.
450    Active,
451    /// The provider failed to process the file.
452    Failed {
453        /// Provider error code, when available.
454        code: Option<String>,
455        /// Provider error message, when available.
456        message: Option<String>,
457    },
458}
459
460/// One page of provider-managed files.
461///
462/// # Examples
463///
464/// ```
465/// let page = providers_kit::files::FilePage { files: Vec::new(), next_cursor: None };
466/// assert!(page.files.is_empty());
467/// assert!(page.next_cursor.is_none());
468/// ```
469///
470#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
471pub struct FilePage {
472    /// Files returned in this page.
473    pub files: Vec<FileObject>,
474    /// Provider-native cursor for a subsequent page.
475    pub next_cursor: Option<String>,
476}