Skip to main content

ferrin_message/
file_source.rs

1//! Application-side file payloads.
2
3use std::path::PathBuf;
4
5use base64::Engine;
6use base64::prelude::BASE64_STANDARD;
7use bytes::Bytes;
8use ferrin_spec::FileData;
9use ferrin_spec::ProviderReference;
10use serde::Deserialize;
11use serde::Serialize;
12use url::Url;
13
14use crate::data_url;
15use crate::data_url::DataUrl;
16use crate::error::FileSourceError;
17use crate::error::InvalidDataContentError;
18
19/// Where the bytes of a file part come from.
20///
21/// Compared with the provider-level [`FileData`], applications may also pass
22/// base64 text (decoded during conversion) and a local path (read during
23/// conversion). Wire format is tagged by `type`: `data` (base64 bytes),
24/// `base64`, `url`, `reference`, `text`, `path`.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(tag = "type", rename_all = "kebab-case")]
27#[non_exhaustive]
28pub enum FileSource {
29    /// Inline bytes.
30    #[serde(rename = "data")]
31    Bytes {
32        /// The bytes (serialized as standard base64).
33        #[serde(with = "base64_codec")]
34        data: Bytes,
35    },
36    /// Base64 text, decoded during conversion.
37    Base64 {
38        /// Standard-alphabet base64, padding optional, whitespace ignored.
39        data: String,
40    },
41    /// A URL, including `data:` URLs.
42    Url {
43        /// The URL.
44        url: Url,
45    },
46    /// A file previously uploaded to one or more providers.
47    Reference {
48        /// Provider key → provider-specific file id.
49        reference: ProviderReference,
50    },
51    /// An inline text document.
52    Text {
53        /// The text.
54        text: String,
55    },
56    /// A local file read during conversion.
57    Path {
58        /// The path.
59        path: PathBuf,
60    },
61}
62
63impl FileSource {
64    /// Inline bytes.
65    #[must_use]
66    pub fn bytes(data: impl Into<Bytes>) -> Self {
67        Self::Bytes { data: data.into() }
68    }
69
70    /// Base64 text.
71    #[must_use]
72    pub fn base64(data: impl Into<String>) -> Self {
73        Self::Base64 { data: data.into() }
74    }
75
76    /// A URL.
77    #[must_use]
78    pub fn url(url: Url) -> Self {
79        Self::Url { url }
80    }
81
82    /// A single-provider reference.
83    #[must_use]
84    pub fn reference(provider: impl Into<String>, id: impl Into<String>) -> Self {
85        let mut map = ProviderReference::new();
86        map.insert(provider.into(), id.into());
87        Self::Reference { reference: map }
88    }
89
90    /// An inline text document.
91    #[must_use]
92    pub fn text(text: impl Into<String>) -> Self {
93        Self::Text { text: text.into() }
94    }
95
96    /// A local path.
97    #[must_use]
98    pub fn path(path: impl Into<PathBuf>) -> Self {
99        Self::Path { path: path.into() }
100    }
101
102    /// Parses a URL string into a URL source.
103    ///
104    /// # Errors
105    ///
106    /// Returns the parse error when `url` is not an absolute URL.
107    pub fn parse_url(url: &str) -> Result<Self, url::ParseError> {
108        Url::parse(url).map(|url| Self::Url { url })
109    }
110
111    /// Returns the inline bytes when this is a [`FileSource::Bytes`].
112    #[must_use]
113    pub fn as_bytes(&self) -> Option<&Bytes> {
114        match self {
115            Self::Bytes { data } => Some(data),
116            _ => None,
117        }
118    }
119
120    /// Returns the URL when this is a [`FileSource::Url`].
121    #[must_use]
122    pub fn as_url(&self) -> Option<&Url> {
123        match self {
124            Self::Url { url } => Some(url),
125            _ => None,
126        }
127    }
128
129    /// Returns the provider reference when this is a [`FileSource::Reference`].
130    #[must_use]
131    pub fn as_reference(&self) -> Option<&ProviderReference> {
132        match self {
133            Self::Reference { reference } => Some(reference),
134            _ => None,
135        }
136    }
137
138    /// Returns `true` for a `data:` URL.
139    #[must_use]
140    pub fn is_data_url(&self) -> bool {
141        matches!(self, Self::Url { url } if url.scheme().eq_ignore_ascii_case("data"))
142    }
143
144    /// Parses the `data:` URL when this is one.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`InvalidDataContentError`] when the data URL is malformed.
149    pub fn data_url(&self) -> Option<Result<DataUrl, InvalidDataContentError>> {
150        match self {
151            Self::Url { url } if url.scheme().eq_ignore_ascii_case("data") => {
152                Some(data_url::parse(url.as_str()))
153            }
154            _ => None,
155        }
156    }
157
158    /// Decodes inline content (`Bytes` or `Base64`) into bytes.
159    ///
160    /// Returns `None` for sources that are not inline binary content.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`InvalidDataContentError`] when base64 text does not decode.
165    pub fn decoded_bytes(&self) -> Result<Option<Bytes>, InvalidDataContentError> {
166        match self {
167            Self::Bytes { data } => Ok(Some(data.clone())),
168            Self::Base64 { data } => decode_base64(data).map(Some),
169            _ => Ok(None),
170        }
171    }
172}
173
174impl From<FileData> for FileSource {
175    fn from(data: FileData) -> Self {
176        match data {
177            FileData::Bytes { data } => Self::Bytes { data },
178            FileData::Url { url } => Self::Url { url },
179            FileData::Reference { reference } => Self::Reference { reference },
180            FileData::Text { text } => Self::Text { text },
181            // `FileData` is `#[non_exhaustive]`; both crates ship from the same
182            // workspace and a new variant is mirrored here in the same change.
183            _ => unreachable!("every FileData variant has a FileSource counterpart"),
184        }
185    }
186}
187
188impl TryFrom<FileSource> for FileData {
189    type Error = FileSourceError;
190
191    /// Converts without I/O: base64 is decoded, URLs (including `data:`) are
192    /// passed through, and paths are rejected with
193    /// [`FileSourceError::UnreadPath`].
194    fn try_from(source: FileSource) -> Result<Self, Self::Error> {
195        match source {
196            FileSource::Bytes { data } => Ok(Self::Bytes { data }),
197            FileSource::Base64 { data } => Ok(Self::Bytes {
198                data: decode_base64(&data)?,
199            }),
200            FileSource::Url { url } => Ok(Self::Url { url }),
201            FileSource::Reference { reference } => Ok(Self::Reference { reference }),
202            FileSource::Text { text } => Ok(Self::Text { text }),
203            FileSource::Path { path } => Err(FileSourceError::UnreadPath { path }),
204        }
205    }
206}
207
208impl From<Bytes> for FileSource {
209    fn from(data: Bytes) -> Self {
210        Self::Bytes { data }
211    }
212}
213
214impl From<Vec<u8>> for FileSource {
215    fn from(bytes: Vec<u8>) -> Self {
216        Self::Bytes {
217            data: Bytes::from(bytes),
218        }
219    }
220}
221
222impl From<Url> for FileSource {
223    fn from(url: Url) -> Self {
224        Self::Url { url }
225    }
226}
227
228impl From<ProviderReference> for FileSource {
229    fn from(reference: ProviderReference) -> Self {
230        Self::Reference { reference }
231    }
232}
233
234impl From<PathBuf> for FileSource {
235    fn from(path: PathBuf) -> Self {
236        Self::Path { path }
237    }
238}
239
240/// Decodes standard base64 with optional padding, ignoring ASCII whitespace.
241fn decode_base64(text: &str) -> Result<Bytes, InvalidDataContentError> {
242    use base64::alphabet::STANDARD;
243    use base64::engine::DecodePaddingMode;
244    use base64::engine::GeneralPurpose;
245    use base64::engine::GeneralPurposeConfig;
246
247    const LENIENT: GeneralPurpose = GeneralPurpose::new(
248        &STANDARD,
249        GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
250    );
251    let compact: Vec<u8> = text
252        .bytes()
253        .filter(|byte| !byte.is_ascii_whitespace())
254        .collect();
255    LENIENT.decode(compact).map(Bytes::from).map_err(|error| {
256        InvalidDataContentError::new("content is not valid base64").with_cause(error)
257    })
258}
259
260mod base64_codec {
261    use super::BASE64_STANDARD;
262    use super::Engine;
263    use bytes::Bytes;
264    use serde::Deserialize;
265    use serde::Deserializer;
266    use serde::Serializer;
267
268    pub(super) fn serialize<S: Serializer>(
269        bytes: &Bytes,
270        serializer: S,
271    ) -> Result<S::Ok, S::Error> {
272        serializer.serialize_str(&BASE64_STANDARD.encode(bytes))
273    }
274
275    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
276        deserializer: D,
277    ) -> Result<Bytes, D::Error> {
278        let text = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
279        super::decode_base64(&text).map_err(serde::de::Error::custom)
280    }
281}