Skip to main content

xapi_data/
attachment.rs

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