Skip to main content

lash_sansio/
attachment.rs

1use std::fmt;
2use std::str::FromStr;
3
4#[derive(
5    Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, PartialOrd, Ord,
6)]
7#[serde(transparent)]
8pub struct AttachmentId(String);
9
10impl AttachmentId {
11    pub fn new(id: impl Into<String>) -> Self {
12        Self(id.into())
13    }
14
15    pub fn as_str(&self) -> &str {
16        &self.0
17    }
18}
19
20impl fmt::Display for AttachmentId {
21    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22        f.write_str(&self.0)
23    }
24}
25
26impl From<String> for AttachmentId {
27    fn from(value: String) -> Self {
28        Self::new(value)
29    }
30}
31
32impl From<&str> for AttachmentId {
33    fn from(value: &str) -> Self {
34        Self::new(value)
35    }
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct InvalidMediaType {
40    value: String,
41}
42
43impl fmt::Display for InvalidMediaType {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        write!(
46            f,
47            "invalid media type `{}`: expected a syntactically valid type/subtype",
48            self.value
49        )
50    }
51}
52
53impl std::error::Error for InvalidMediaType {}
54
55/// A syntactically validated MIME media type.
56///
57/// Lash deliberately does not maintain a closed media catalog. Provider
58/// adapters own the MIME families and exact values they can materialize.
59#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
60pub struct MediaType(String);
61
62impl MediaType {
63    pub fn parse(value: impl AsRef<str>) -> Result<Self, InvalidMediaType> {
64        let original = value.as_ref();
65        let normalized = original.trim().to_ascii_lowercase();
66        let mut pieces = normalized.split('/');
67        let type_name = pieces.next().unwrap_or_default();
68        let subtype = pieces.next().unwrap_or_default();
69        if pieces.next().is_some() || !is_mime_token(type_name) || !is_mime_token(subtype) {
70            return Err(InvalidMediaType {
71                value: original.to_string(),
72            });
73        }
74        Ok(Self(normalized))
75    }
76
77    pub fn as_str(&self) -> &str {
78        &self.0
79    }
80
81    pub fn family(&self) -> &str {
82        self.0.split_once('/').map_or("", |(family, _)| family)
83    }
84
85    pub fn is_image(&self) -> bool {
86        self.family() == "image"
87    }
88}
89
90fn is_mime_token(value: &str) -> bool {
91    !value.is_empty()
92        && value.bytes().all(|byte| {
93            byte.is_ascii_alphanumeric()
94                || matches!(
95                    byte,
96                    b'!' | b'#'
97                        | b'$'
98                        | b'%'
99                        | b'&'
100                        | b'\''
101                        | b'*'
102                        | b'+'
103                        | b'-'
104                        | b'.'
105                        | b'^'
106                        | b'_'
107                        | b'`'
108                        | b'|'
109                        | b'~'
110                )
111        })
112}
113
114impl fmt::Display for MediaType {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.write_str(&self.0)
117    }
118}
119
120impl FromStr for MediaType {
121    type Err = InvalidMediaType;
122
123    fn from_str(value: &str) -> Result<Self, Self::Err> {
124        Self::parse(value)
125    }
126}
127
128impl TryFrom<String> for MediaType {
129    type Error = InvalidMediaType;
130
131    fn try_from(value: String) -> Result<Self, Self::Error> {
132        Self::parse(value)
133    }
134}
135
136impl serde::Serialize for MediaType {
137    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
138    where
139        S: serde::Serializer,
140    {
141        serializer.serialize_str(self.as_str())
142    }
143}
144
145impl<'de> serde::Deserialize<'de> for MediaType {
146    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
147    where
148        D: serde::Deserializer<'de>,
149    {
150        let value = String::deserialize(deserializer)?;
151        Self::parse(value).map_err(serde::de::Error::custom)
152    }
153}
154
155#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
156#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
157pub enum AttachmentTypeMetadata {
158    Image {
159        #[serde(default, skip_serializing_if = "Option::is_none")]
160        width: Option<u32>,
161        #[serde(default, skip_serializing_if = "Option::is_none")]
162        height: Option<u32>,
163    },
164}
165
166impl AttachmentTypeMetadata {
167    pub fn image(width: Option<u32>, height: Option<u32>) -> Self {
168        Self::Image { width, height }
169    }
170}
171
172#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
173pub struct AttachmentCreateMeta {
174    pub media_type: MediaType,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub type_metadata: Option<AttachmentTypeMetadata>,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub label: Option<String>,
179}
180
181impl AttachmentCreateMeta {
182    pub fn new(
183        media_type: MediaType,
184        type_metadata: Option<AttachmentTypeMetadata>,
185        label: Option<String>,
186    ) -> Self {
187        Self {
188            media_type,
189            type_metadata,
190            label,
191        }
192    }
193}
194
195#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
196pub struct AttachmentMeta {
197    pub id: AttachmentId,
198    pub media_type: MediaType,
199    pub byte_len: u64,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub type_metadata: Option<AttachmentTypeMetadata>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub label: Option<String>,
204}
205
206impl AttachmentMeta {
207    pub fn new(
208        id: AttachmentId,
209        media_type: MediaType,
210        byte_len: u64,
211        type_metadata: Option<AttachmentTypeMetadata>,
212        label: Option<String>,
213    ) -> Self {
214        Self {
215            id,
216            media_type,
217            byte_len,
218            type_metadata,
219            label,
220        }
221    }
222
223    pub fn as_ref(&self) -> AttachmentRef {
224        AttachmentRef {
225            id: self.id.clone(),
226            media_type: self.media_type.clone(),
227            byte_len: self.byte_len,
228            type_metadata: self.type_metadata.clone(),
229            label: self.label.clone(),
230        }
231    }
232}
233
234#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
235pub struct AttachmentRef {
236    pub id: AttachmentId,
237    pub media_type: MediaType,
238    pub byte_len: u64,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub type_metadata: Option<AttachmentTypeMetadata>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub label: Option<String>,
243}
244
245impl AttachmentRef {
246    pub fn meta(&self) -> AttachmentMeta {
247        AttachmentMeta {
248            id: self.id.clone(),
249            media_type: self.media_type.clone(),
250            byte_len: self.byte_len,
251            type_metadata: self.type_metadata.clone(),
252            label: self.label.clone(),
253        }
254    }
255
256    pub fn media_type(&self) -> &MediaType {
257        &self.media_type
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn media_type_accepts_any_valid_type_and_normalizes_case() {
267        assert_eq!(
268            MediaType::parse(" IMAGE/PNG ").unwrap().as_str(),
269            "image/png"
270        );
271        assert_eq!(
272            MediaType::parse("application/vnd.example+json")
273                .unwrap()
274                .as_str(),
275            "application/vnd.example+json"
276        );
277        assert!(MediaType::parse("application/x.foo~bar").is_ok());
278    }
279
280    #[test]
281    fn media_type_rejects_parameters_and_malformed_values() {
282        for invalid in [
283            "image",
284            "/png",
285            "image/",
286            "image/png/extra",
287            "text/plain; charset=utf-8",
288        ] {
289            assert!(MediaType::parse(invalid).is_err(), "accepted {invalid}");
290        }
291    }
292
293    #[test]
294    fn serde_cannot_bypass_media_type_validation() {
295        assert!(serde_json::from_str::<MediaType>(r#""not a mime""#).is_err());
296    }
297}