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