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}