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}