xapi_rs/data/
attachment.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{
4    MyLanguageTag, add_language,
5    data::{
6        DataError, LanguageMap, Validate, ValidationError,
7        validate::{validate_irl, validate_sha2},
8    },
9    emit_error,
10};
11use core::fmt;
12use iri_string::types::{IriStr, IriString};
13use mime::Mime;
14use serde::{Deserialize, Serialize};
15use serde_with::{serde_as, skip_serializing_none};
16use std::str::FromStr;
17use tracing::warn;
18
19/// Mandated 'usageTpe' to use when an [Attachment] is a JWS signature.
20pub const SIGNATURE_UT: &str = "http://adlnet.gov/expapi/attachments/signature";
21/// Mandated 'contentType' to use when an [Attachment] is a JWS signature.
22pub const SIGNATURE_CT: &str = "application/octet-stream";
23
24/// Structure representing an important piece of data that is part of a
25/// _Learning Record_. Could be an essay, a video, etc...
26///
27/// Another example could be the image of a certificate that was granted as a
28/// result of an experience.
29#[serde_as]
30#[skip_serializing_none]
31#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct Attachment {
34    usage_type: IriString,
35    display: LanguageMap,
36    description: Option<LanguageMap>,
37    #[serde_as(as = "serde_with::DisplayFromStr")]
38    content_type: Mime,
39    length: i64,
40    sha2: String,
41    file_url: Option<IriString>,
42}
43
44impl Attachment {
45    /// Return an [Attachment] _Builder_.
46    pub fn builder() -> AttachmentBuilder<'static> {
47        AttachmentBuilder::default()
48    }
49
50    /// Return `usage_type` as an IRI.
51    pub fn usage_type(&self) -> &IriStr {
52        self.usage_type.as_ref()
53    }
54
55    /// Return `display` for the given language `tag` if it exists; `None` otherwise.
56    pub fn display(&self, tag: &MyLanguageTag) -> Option<&str> {
57        self.display.get(tag)
58    }
59
60    /// Return a reference to [`display`][LanguageMap].
61    pub fn display_as_map(&self) -> &LanguageMap {
62        &self.display
63    }
64
65    /// Return `description` for the given language `tag` if it exists; `None`
66    /// otherwise.
67    pub fn description(&self, tag: &MyLanguageTag) -> Option<&str> {
68        match &self.description {
69            Some(map) => map.get(tag),
70            None => None,
71        }
72    }
73
74    /// Return a reference to [`description`][LanguageMap] if set; `None` otherwise.
75    pub fn description_as_map(&self) -> Option<&LanguageMap> {
76        self.description.as_ref()
77    }
78
79    /// Return `content_type`.
80    pub fn content_type(&self) -> &Mime {
81        &self.content_type
82    }
83
84    /// Return `length` (in bytes).
85    pub fn length(&self) -> i64 {
86        self.length
87    }
88
89    /// Return `sha2` (hash sum).
90    pub fn sha2(&self) -> &str {
91        self.sha2.as_str()
92    }
93
94    /// Return `file_url` if set; `None` otherwise.
95    pub fn file_url(&self) -> Option<&IriStr> {
96        self.file_url.as_deref()
97    }
98
99    /// Return `file_url` as string reference if set; `None` otherwise.
100    pub fn file_url_as_str(&self) -> Option<&str> {
101        if self.file_url.is_none() {
102            None
103        } else {
104            Some(self.file_url.as_ref().unwrap().as_str())
105        }
106    }
107
108    /// Set the `file_url` field to the given value.
109    pub fn set_file_url(&mut self, url: &str) {
110        self.file_url = Some(IriString::from_str(url).unwrap());
111    }
112
113    /// Return TRUE if this is a JWS signature; FALSE otherwise.
114    pub fn is_signature(&self) -> bool {
115        // an Attachment is considered a potential JWS Signature iff its
116        // usage-type is equal to SIGNATURE_UT and its Content-Type is
117        // equal to SIGNATURE_CT
118        self.usage_type.as_str() == SIGNATURE_UT && self.content_type.as_ref() == SIGNATURE_CT
119    }
120}
121
122impl fmt::Display for Attachment {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        let mut vec = vec![];
125
126        vec.push(format!("usageType: \"{}\"", self.usage_type));
127        vec.push(format!("display: {}", self.display));
128        if self.description.is_some() {
129            vec.push(format!(
130                "description: {}",
131                self.description.as_ref().unwrap()
132            ));
133        }
134        vec.push(format!("contentType: \"{}\"", self.content_type));
135        vec.push(format!("length: {}", self.length));
136        vec.push(format!("sha2: \"{}\"", self.sha2));
137        if self.file_url.is_some() {
138            vec.push(format!("fileUrl: \"{}\"", self.file_url.as_ref().unwrap()));
139        }
140
141        let res = vec
142            .iter()
143            .map(|x| x.to_string())
144            .collect::<Vec<_>>()
145            .join(", ");
146        write!(f, "Attachment{{ {res} }}")
147    }
148}
149
150impl Validate for Attachment {
151    fn validate(&self) -> Vec<ValidationError> {
152        let mut vec = vec![];
153
154        if self.display.is_empty() {
155            warn!("Attachment display dictionary is empty")
156        }
157        if self.content_type.type_().as_str().is_empty() {
158            vec.push(ValidationError::Empty("content_type".into()))
159        }
160        if self.usage_type.is_empty() {
161            vec.push(ValidationError::Empty("usage_type".into()))
162        } else {
163            // NOTE (rsn) 20241112 - before going further ensure if this is for
164            // a JWS Signature, both UT and CT properties are consistent...
165            if self.usage_type.as_str() == SIGNATURE_UT
166                && self.content_type.as_ref() != SIGNATURE_CT
167            {
168                vec.push(ValidationError::ConstraintViolation(
169                    "Attachment has a JWS Signature usage-type but not the expected content-type"
170                        .into(),
171                ));
172            }
173        }
174
175        if self.sha2.is_empty() {
176            vec.push(ValidationError::Empty("sha2".into()))
177        } else {
178            match validate_sha2(&self.sha2) {
179                Ok(_) => (),
180                Err(x) => vec.push(x),
181            }
182        }
183        // length must be greater than 0...
184        if self.length < 1 {
185            vec.push(ValidationError::ConstraintViolation(
186                "'length' should be > 0".into(),
187            ))
188        }
189        if self.file_url.is_some() {
190            let file_url = self.file_url.as_ref().unwrap();
191            if file_url.is_empty() {
192                vec.push(ValidationError::ConstraintViolation(
193                    "'file_url' when set, must not be empty".into(),
194                ))
195            } else {
196                match validate_irl(file_url) {
197                    Ok(_) => (),
198                    Err(x) => vec.push(x),
199                }
200            }
201        }
202
203        vec
204    }
205}
206
207/// A Type that knows how to construct an [Attachment].
208#[derive(Debug, Default)]
209pub struct AttachmentBuilder<'a> {
210    _usage_type: Option<&'a IriStr>,
211    _display: Option<LanguageMap>,
212    _description: Option<LanguageMap>,
213    _content_type: Option<Mime>,
214    _length: Option<i64>,
215    _sha2: &'a str,
216    _file_url: Option<&'a IriStr>,
217}
218
219impl<'a> AttachmentBuilder<'a> {
220    /// Set the `usage_type` field.
221    ///
222    /// Raise [DataError] if the input string is empty or when parsed as an
223    /// IRI yields an invalid value.
224    pub fn usage_type(mut self, val: &'a str) -> Result<Self, DataError> {
225        let usage_type = val.trim();
226        if usage_type.is_empty() {
227            emit_error!(DataError::Validation(ValidationError::Empty(
228                "usage_type".into()
229            )))
230        } else {
231            let usage_type = IriStr::new(usage_type)?;
232            self._usage_type = Some(usage_type);
233            Ok(self)
234        }
235    }
236
237    /// Add `label` tagged by the language `tag` to the `display` dictionary.
238    ///
239    /// Raise [DataError] if the `tag` was empty or invalid.
240    pub fn display(mut self, tag: &MyLanguageTag, label: &str) -> Result<Self, DataError> {
241        add_language!(self._display, tag, label);
242        Ok(self)
243    }
244
245    /// Set (as in replace) the `display` property for the instance being built
246    /// w/ the one passed as argument.
247    pub fn with_display(mut self, map: LanguageMap) -> Result<Self, DataError> {
248        self._display = Some(map);
249        Ok(self)
250    }
251
252    /// Add `label` tagged by the language `tag` to the `description` dictionary.
253    ///
254    /// Raise [DataError] if the `tag` was empty or invalid.
255    pub fn description(mut self, tag: &MyLanguageTag, label: &str) -> Result<Self, DataError> {
256        add_language!(self._description, tag, label);
257        Ok(self)
258    }
259
260    /// Set (as in replace) the `description` property for the instance being built
261    /// w/ the one passed as argument.
262    pub fn with_description(mut self, map: LanguageMap) -> Result<Self, DataError> {
263        self._description = Some(map);
264        Ok(self)
265    }
266
267    /// Set the `content_type` field.
268    ///
269    /// Raise [DataError] if the input string is empty, or is not a valid MIME
270    /// type string.
271    pub fn content_type(mut self, val: &str) -> Result<Self, DataError> {
272        let val = val.trim();
273        if val.is_empty() {
274            emit_error!(DataError::Validation(ValidationError::Empty(
275                "content_type".into()
276            )))
277        } else {
278            let content_type = Mime::from_str(val)?;
279            self._content_type = Some(content_type);
280            Ok(self)
281        }
282    }
283
284    /// Set the `length` field.
285    pub fn length(mut self, val: i64) -> Result<Self, DataError> {
286        if val < 1 {
287            emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
288                "'length' should be > 0".into()
289            )))
290        } else {
291            self._length = Some(val);
292            Ok(self)
293        }
294    }
295
296    /// Set the `sha2` field.
297    ///
298    /// Raise [DataError] if the input string is empty, has the wrong number
299    /// of characters, or contains non-hexadecimal characters.
300    pub fn sha2(mut self, val: &'a str) -> Result<Self, DataError> {
301        let val = val.trim();
302        if val.is_empty() {
303            emit_error!(DataError::Validation(ValidationError::Empty("sha2".into())))
304        } else {
305            validate_sha2(val)?;
306            self._sha2 = val;
307            Ok(self)
308        }
309    }
310
311    /// Set the `file_url` field.
312    ///
313    /// Raise [DataError] if the input string is empty, an error occurs while
314    /// parsing it as an IRI, or the resulting IRI is an invalid URL.
315    pub fn file_url(mut self, val: &'a str) -> Result<Self, DataError> {
316        let file_url = val.trim();
317        if file_url.is_empty() {
318            emit_error!(DataError::Validation(ValidationError::Empty(
319                "file_url".into()
320            )))
321        } else {
322            let x = IriStr::new(file_url)?;
323            validate_irl(x)?;
324            self._file_url = Some(x);
325            Ok(self)
326        }
327    }
328
329    /// Create an [Attachment] instance from set field values.
330    ///
331    /// Raise a [DataError] if any required field is missing.
332    pub fn build(&self) -> Result<Attachment, DataError> {
333        if self._usage_type.is_none() {
334            emit_error!(DataError::Validation(ValidationError::MissingField(
335                "usage_type".into()
336            )))
337        }
338        if self._length.is_none() {
339            emit_error!(DataError::Validation(ValidationError::MissingField(
340                "length".into()
341            )))
342        }
343        if self._content_type.is_none() {
344            emit_error!(DataError::Validation(ValidationError::MissingField(
345                "content_type".into()
346            )))
347        }
348        if self._sha2.is_empty() {
349            emit_error!(DataError::Validation(ValidationError::MissingField(
350                "sha2".into()
351            )))
352        }
353        Ok(Attachment {
354            usage_type: self._usage_type.unwrap().into(),
355            display: self._display.to_owned().unwrap_or_default(),
356            description: self._description.to_owned(),
357            content_type: self._content_type.clone().unwrap(),
358            length: self._length.unwrap(),
359            sha2: self._sha2.to_owned(),
360            file_url: if self._file_url.is_none() {
361                None
362            } else {
363                Some(self._file_url.unwrap().to_owned())
364            },
365        })
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372    use tracing_test::traced_test;
373
374    #[traced_test]
375    #[test]
376    fn test_serde_rename() -> Result<(), DataError> {
377        const JSON: &str = r#"
378        {
379            "usageType": "http://adlnet.gov/expapi/attachments/signature",
380            "display": { "en-US": "Signature" },
381            "description": { "en-US": "A test signature" },
382            "contentType": "application/octet-stream",
383            "length": 4235,
384            "sha2": "672fa5fa658017f1b72d65036f13379c6ab05d4ab3b6664908d8acf0b6a0c634"
385        }"#;
386
387        let en = MyLanguageTag::from_str("en")?;
388        let us = MyLanguageTag::from_str("en-US")?;
389        let au = MyLanguageTag::from_str("en-AU")?;
390
391        let de_result = serde_json::from_str::<Attachment>(JSON);
392        assert!(de_result.is_ok());
393        let att = de_result.unwrap();
394
395        assert_eq!(
396            att.usage_type(),
397            "http://adlnet.gov/expapi/attachments/signature"
398        );
399        assert!(att.display(&en).is_none());
400        assert!(att.display(&us).is_some());
401        assert_eq!(att.display(&us).unwrap(), "Signature");
402        assert!(att.description(&au).is_none());
403        assert!(att.description(&us).is_some());
404        assert_eq!(att.description(&us).unwrap(), "A test signature");
405        assert_eq!(att.content_type().to_string(), "application/octet-stream");
406        assert_eq!(att.length(), 4235);
407        assert_eq!(
408            att.sha2(),
409            "672fa5fa658017f1b72d65036f13379c6ab05d4ab3b6664908d8acf0b6a0c634"
410        );
411        assert!(att.file_url().is_none());
412
413        Ok(())
414    }
415
416    #[traced_test]
417    #[test]
418    fn test_builder() -> Result<(), DataError> {
419        let en = MyLanguageTag::from_str("en")?;
420
421        let mut display = LanguageMap::new();
422        display.insert(&en, "zDisplay");
423
424        let mut description = LanguageMap::new();
425        description.insert(&en, "zDescription");
426
427        let builder = Attachment::builder()
428            .usage_type("http://somewhere.net/attachment-usage/test")?
429            .with_display(display)?
430            .with_description(description)?
431            .content_type("text/plain")?
432            .length(99)?
433            .sha2("495395e777cd98da653df9615d09c0fd6bb2f8d4788394cd53c56a3bfdcd848a")?;
434        let att = builder
435            .file_url("https://localhost/xapi/static/c44/sAZH2_GCudIGDdvf0xgHtLA/a1")?
436            .build()?;
437
438        assert_eq!(att.content_type, "text/plain");
439
440        Ok(())
441    }
442}