Skip to main content

ferrin_core/
files.rs

1//! Provider file storage: [`upload_file`], [`get_file_metadata`],
2//! [`download_file`] and [`delete_file`].
3//!
4//! Design: `docs/01-architecture/11-other-modalities.md` ยง7.
5
6use std::fmt;
7use std::future::IntoFuture;
8
9use ferrin_provider_util::media_type::detect_media_type;
10use ferrin_spec::BoxFuture;
11use ferrin_spec::FilesRef;
12use ferrin_spec::MediaType;
13use ferrin_spec::ProviderReference;
14use ferrin_spec::error::ProviderError;
15pub use ferrin_spec::files::DeleteFileResult;
16pub use ferrin_spec::files::DownloadFileResult;
17pub use ferrin_spec::files::FileMetadataResult;
18use ferrin_spec::files::FileReferenceOptions;
19pub use ferrin_spec::files::UploadData;
20use ferrin_spec::files::UploadFileOptions;
21pub use ferrin_spec::files::UploadFileResult;
22use tracing::Instrument;
23
24use crate::error::Error;
25use crate::modality::ModalityOptions;
26use crate::modality::impl_modality_builder;
27use crate::telemetry::ModelIdentity;
28use crate::telemetry::spans;
29
30/// Number of leading bytes inspected by [`is_likely_text`].
31const TEXT_CHECK_LENGTH: usize = 512;
32
33/// Uploads a file to the provider's storage. The media type is detected
34/// from the data when not set: text data is `text/plain`, streams are
35/// `application/octet-stream`.
36#[must_use]
37pub fn upload_file(files: impl Into<FilesRef>, data: impl Into<UploadData>) -> UploadFile {
38    UploadFile {
39        files: files.into(),
40        data: data.into(),
41        media_type: None,
42        filename: None,
43        base: ModalityOptions::default(),
44    }
45}
46
47/// Builder returned by [`upload_file`]; `.await` runs the upload.
48pub struct UploadFile {
49    files: FilesRef,
50    data: UploadData,
51    media_type: Option<MediaType>,
52    filename: Option<String>,
53    base: ModalityOptions,
54}
55
56impl fmt::Debug for UploadFile {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        f.debug_struct("UploadFile")
59            .field("files", &self.files)
60            .field("data", &self.data)
61            .field("media_type", &self.media_type)
62            .field("filename", &self.filename)
63            .field("base", &self.base)
64            .finish()
65    }
66}
67
68impl UploadFile {
69    /// Sets the media type.
70    #[must_use]
71    pub fn media_type(mut self, media_type: impl Into<MediaType>) -> Self {
72        self.media_type = Some(media_type.into());
73        self
74    }
75
76    /// Sets the file name.
77    #[must_use]
78    pub fn filename(mut self, filename: impl Into<String>) -> Self {
79        self.filename = Some(filename.into());
80        self
81    }
82}
83
84impl_modality_builder!(@no_retry UploadFile);
85
86impl IntoFuture for UploadFile {
87    type Output = Result<UploadFileResult, Error>;
88    type IntoFuture = BoxFuture<'static, Self::Output>;
89
90    fn into_future(self) -> Self::IntoFuture {
91        Box::pin(async move {
92            let identity = service_identity(&self.files);
93            let span = spans::modality_span("upload_file", &identity);
94            let media_type = self
95                .media_type
96                .unwrap_or_else(|| default_media_type(&self.data));
97            let files = self.files.clone();
98            let base = self.base.clone();
99            let data = self.data;
100            let filename = self.filename;
101            base.run(|base, token| {
102                async move {
103                    let result = files
104                        .upload_file(UploadFileOptions {
105                            data,
106                            media_type,
107                            filename,
108                            headers: base.request_headers(),
109                            provider_options: base.provider_options.clone(),
110                            cancellation: token,
111                        })
112                        .await
113                        .map_err(Error::from)?;
114                    spans::log_warnings(&result.warnings, &identity);
115                    Ok(result)
116                }
117                .instrument(span)
118            })
119            .await
120        })
121    }
122}
123
124/// Returns `true` when the first bytes contain no NUL and no control
125/// characters other than tab, newline and carriage return.
126pub(crate) fn is_likely_text(bytes: &[u8]) -> bool {
127    let sample = &bytes[..bytes.len().min(TEXT_CHECK_LENGTH)];
128    !sample.is_empty()
129        && sample
130            .iter()
131            .all(|byte| *byte >= 0x20 || matches!(byte, 0x09 | 0x0a | 0x0d))
132}
133
134fn default_media_type(data: &UploadData) -> MediaType {
135    match data {
136        UploadData::Text(_) => MediaType::new("text/plain"),
137        UploadData::Bytes(bytes) => detect_media_type(bytes).unwrap_or_else(|| {
138            if is_likely_text(bytes) {
139                MediaType::new("text/plain")
140            } else {
141                MediaType::new("application/octet-stream")
142            }
143        }),
144        #[allow(unreachable_patterns, reason = "UploadData is non-exhaustive")]
145        _ => MediaType::new("application/octet-stream"),
146    }
147}
148
149fn service_identity(files: &FilesRef) -> ModelIdentity {
150    ModelIdentity::new(files.provider().clone(), "files")
151}
152
153macro_rules! file_operation {
154    ($(#[$meta:meta])* $name:ident, $ty:ident, $result:ty, $supports:ident, $method:ident, $label:literal) => {
155        $(#[$meta])*
156        #[must_use]
157        pub fn $name(files: impl Into<FilesRef>, file: ProviderReference) -> $ty {
158            $ty {
159                files: files.into(),
160                file,
161                base: ModalityOptions::default(),
162            }
163        }
164
165        #[doc = concat!("Builder returned by [`", stringify!($name), "`]; `.await` runs the call.")]
166        #[derive(Debug)]
167        pub struct $ty {
168            files: FilesRef,
169            file: ProviderReference,
170            base: ModalityOptions,
171        }
172
173        impl_modality_builder!(@no_retry $ty);
174
175        impl IntoFuture for $ty {
176            type Output = Result<$result, Error>;
177            type IntoFuture = BoxFuture<'static, Self::Output>;
178
179            fn into_future(self) -> Self::IntoFuture {
180                Box::pin(async move {
181                    let identity = service_identity(&self.files);
182                    let span = spans::modality_span($label, &identity);
183                    if !self.files.$supports() {
184                        return Err(Error::from(ProviderError::unsupported($label)));
185                    }
186                    let files = self.files.clone();
187                    let base = self.base.clone();
188                    let file = self.file;
189                    base.run(|base, token| {
190                        async move {
191                            files
192                                .$method(FileReferenceOptions {
193                                    file,
194                                    headers: base.request_headers(),
195                                    provider_options: base.provider_options.clone(),
196                                    cancellation: token,
197                                })
198                                .await
199                                .map_err(Error::from)
200                        }
201                        .instrument(span)
202                    })
203                    .await
204                })
205            }
206        }
207    };
208}
209
210file_operation!(
211    /// Fetches the metadata of an uploaded file. Fails with an unsupported
212    /// functionality error when the provider does not implement it.
213    get_file_metadata,
214    GetFileMetadata,
215    FileMetadataResult,
216    supports_get_file_metadata,
217    get_file_metadata,
218    "get_file_metadata"
219);
220
221file_operation!(
222    /// Downloads an uploaded file. Fails with an unsupported functionality
223    /// error when the provider does not implement it.
224    download_file,
225    DownloadFile,
226    DownloadFileResult,
227    supports_download_file,
228    download_file,
229    "download_file"
230);
231
232file_operation!(
233    /// Deletes an uploaded file. Fails with an unsupported functionality
234    /// error when the provider does not implement it.
235    delete_file,
236    DeleteFile,
237    DeleteFileResult,
238    supports_delete_file,
239    delete_file,
240    "delete_file"
241);