Skip to main content

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 let Some(z_file_url) = self.file_url.as_ref() {
102            Some(z_file_url.as_ref())
103        } else {
104            None
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 let Some(z_description) = self.description.as_ref() {
129            vec.push(format!("description: {}", z_description));
130        }
131        vec.push(format!("contentType: \"{}\"", self.content_type));
132        vec.push(format!("length: {}", self.length));
133        vec.push(format!("sha2: \"{}\"", self.sha2));
134        if let Some(z_file_url) = self.file_url.as_ref() {
135            vec.push(format!("fileUrl: \"{}\"", z_file_url));
136        }
137
138        let res = vec
139            .iter()
140            .map(|x| x.to_string())
141            .collect::<Vec<_>>()
142            .join(", ");
143        write!(f, "Attachment{{ {res} }}")
144    }
145}
146
147impl Validate for Attachment {
148    fn validate(&self) -> Vec<ValidationError> {
149        let mut vec = vec![];
150
151        if self.display.is_empty() {
152            warn!("Attachment display dictionary is empty")
153        }
154        if self.content_type.type_().as_str().is_empty() {
155            vec.push(ValidationError::Empty("content_type".into()))
156        }
157        if self.usage_type.is_empty() {
158            vec.push(ValidationError::Empty("usage_type".into()))
159        } else {
160            // NOTE (rsn) 20241112 - before going further ensure if this is for
161            // a JWS Signature, both UT and CT properties are consistent...
162            if self.usage_type.as_str() == SIGNATURE_UT
163                && self.content_type.as_ref() != SIGNATURE_CT
164            {
165                vec.push(ValidationError::ConstraintViolation(
166                    "Attachment has a JWS Signature usage-type but not the expected content-type"
167                        .into(),
168                ));
169            }
170        }
171
172        if self.sha2.is_empty() {
173            vec.push(ValidationError::Empty("sha2".into()))
174        } else {
175            match validate_sha2(&self.sha2) {
176                Ok(_) => (),
177                Err(x) => vec.push(x),
178            }
179        }
180        // length must be greater than 0...
181        if self.length < 1 {
182            vec.push(ValidationError::ConstraintViolation(
183                "'length' should be > 0".into(),
184            ))
185        }
186        if let Some(file_url) = self.file_url.as_ref() {
187            if file_url.is_empty() {
188                vec.push(ValidationError::ConstraintViolation(
189                    "'file_url' when set, must not be empty".into(),
190                ))
191            } else {
192                match validate_irl(file_url) {
193                    Ok(_) => (),
194                    Err(x) => vec.push(x),
195                }
196            }
197        }
198
199        vec
200    }
201}
202
203/// A Type that knows how to construct an [Attachment].
204#[derive(Debug, Default)]
205pub struct AttachmentBuilder<'a> {
206    _usage_type: Option<&'a IriStr>,
207    _display: Option<LanguageMap>,
208    _description: Option<LanguageMap>,
209    _content_type: Option<Mime>,
210    _length: Option<i64>,
211    _sha2: &'a str,
212    _file_url: Option<&'a IriStr>,
213}
214
215impl<'a> AttachmentBuilder<'a> {
216    /// Set the `usage_type` field.
217    ///
218    /// Raise [DataError] if the input string is empty or when parsed as an
219    /// IRI yields an invalid value.
220    pub fn usage_type(mut self, val: &'a str) -> Result<Self, DataError> {
221        let usage_type = val.trim();
222        if usage_type.is_empty() {
223            emit_error!(DataError::Validation(ValidationError::Empty(
224                "usage_type".into()
225            )))
226        } else {
227            let usage_type = IriStr::new(usage_type)?;
228            self._usage_type = Some(usage_type);
229            Ok(self)
230        }
231    }
232
233    /// Add `label` tagged by the language `tag` to the `display` dictionary.
234    ///
235    /// Raise [DataError] if the `tag` was empty or invalid.
236    pub fn display(mut self, tag: &MyLanguageTag, label: &str) -> Result<Self, DataError> {
237        add_language!(self._display, tag, label);
238        Ok(self)
239    }
240
241    /// Set (as in replace) the `display` property for the instance being built
242    /// w/ the one passed as argument.
243    pub fn with_display(mut self, map: LanguageMap) -> Result<Self, DataError> {
244        self._display = Some(map);
245        Ok(self)
246    }
247
248    /// Add `label` tagged by the language `tag` to the `description` dictionary.
249    ///
250    /// Raise [DataError] if the `tag` was empty or invalid.
251    pub fn description(mut self, tag: &MyLanguageTag, label: &str) -> Result<Self, DataError> {
252        add_language!(self._description, tag, label);
253        Ok(self)
254    }
255
256    /// Set (as in replace) the `description` property for the instance being built
257    /// w/ the one passed as argument.
258    pub fn with_description(mut self, map: LanguageMap) -> Result<Self, DataError> {
259        self._description = Some(map);
260        Ok(self)
261    }
262
263    /// Set the `content_type` field.
264    ///
265    /// Raise [DataError] if the input string is empty, or is not a valid MIME
266    /// type string.
267    pub fn content_type(mut self, val: &str) -> Result<Self, DataError> {
268        let val = val.trim();
269        if val.is_empty() {
270            emit_error!(DataError::Validation(ValidationError::Empty(
271                "content_type".into()
272            )))
273        } else {
274            let content_type = Mime::from_str(val)?;
275            self._content_type = Some(content_type);
276            Ok(self)
277        }
278    }
279
280    /// Set the `length` field.
281    pub fn length(mut self, val: i64) -> Result<Self, DataError> {
282        if val < 1 {
283            emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
284                "'length' should be > 0".into()
285            )))
286        } else {
287            self._length = Some(val);
288            Ok(self)
289        }
290    }
291
292    /// Set the `sha2` field.
293    ///
294    /// Raise [DataError] if the input string is empty, has the wrong number
295    /// of characters, or contains non-hexadecimal characters.
296    pub fn sha2(mut self, val: &'a str) -> Result<Self, DataError> {
297        let val = val.trim();
298        if val.is_empty() {
299            emit_error!(DataError::Validation(ValidationError::Empty("sha2".into())))
300        } else {
301            validate_sha2(val)?;
302            self._sha2 = val;
303            Ok(self)
304        }
305    }
306
307    /// Set the `file_url` field.
308    ///
309    /// Raise [DataError] if the input string is empty, an error occurs while
310    /// parsing it as an IRI, or the resulting IRI is an invalid URL.
311    pub fn file_url(mut self, val: &'a str) -> Result<Self, DataError> {
312        let file_url = val.trim();
313        if file_url.is_empty() {
314            emit_error!(DataError::Validation(ValidationError::Empty(
315                "file_url".into()
316            )))
317        } else {
318            let x = IriStr::new(file_url)?;
319            validate_irl(x)?;
320            self._file_url = Some(x);
321            Ok(self)
322        }
323    }
324
325    /// Create an [Attachment] instance from set field values.
326    ///
327    /// Raise a [DataError] if any required field is missing.
328    pub fn build(&self) -> Result<Attachment, DataError> {
329        if self._usage_type.is_none() {
330            emit_error!(DataError::Validation(ValidationError::MissingField(
331                "usage_type".into()
332            )))
333        }
334        if self._length.is_none() {
335            emit_error!(DataError::Validation(ValidationError::MissingField(
336                "length".into()
337            )))
338        }
339        if self._content_type.is_none() {
340            emit_error!(DataError::Validation(ValidationError::MissingField(
341                "content_type".into()
342            )))
343        }
344        if self._sha2.is_empty() {
345            emit_error!(DataError::Validation(ValidationError::MissingField(
346                "sha2".into()
347            )))
348        }
349        Ok(Attachment {
350            usage_type: self._usage_type.unwrap().into(),
351            display: self._display.to_owned().unwrap_or_default(),
352            description: self._description.to_owned(),
353            content_type: self._content_type.clone().unwrap(),
354            length: self._length.unwrap(),
355            sha2: self._sha2.to_owned(),
356            file_url: self._file_url.map(|x| x.to_owned()),
357        })
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use tracing_test::traced_test;
365
366    #[traced_test]
367    #[test]
368    fn test_serde_rename() -> Result<(), DataError> {
369        const JSON: &str = r#"
370        {
371            "usageType": "http://adlnet.gov/expapi/attachments/signature",
372            "display": { "en-US": "Signature" },
373            "description": { "en-US": "A test signature" },
374            "contentType": "application/octet-stream",
375            "length": 4235,
376            "sha2": "672fa5fa658017f1b72d65036f13379c6ab05d4ab3b6664908d8acf0b6a0c634"
377        }"#;
378
379        let en = MyLanguageTag::from_str("en")?;
380        let us = MyLanguageTag::from_str("en-US")?;
381        let au = MyLanguageTag::from_str("en-AU")?;
382
383        let de_result = serde_json::from_str::<Attachment>(JSON);
384        assert!(de_result.is_ok());
385        let att = de_result.unwrap();
386
387        assert_eq!(
388            att.usage_type(),
389            "http://adlnet.gov/expapi/attachments/signature"
390        );
391        assert!(att.display(&en).is_none());
392        assert!(att.display(&us).is_some());
393        assert_eq!(att.display(&us).unwrap(), "Signature");
394        assert!(att.description(&au).is_none());
395        assert!(att.description(&us).is_some());
396        assert_eq!(att.description(&us).unwrap(), "A test signature");
397        assert_eq!(att.content_type().to_string(), "application/octet-stream");
398        assert_eq!(att.length(), 4235);
399        assert_eq!(
400            att.sha2(),
401            "672fa5fa658017f1b72d65036f13379c6ab05d4ab3b6664908d8acf0b6a0c634"
402        );
403        assert!(att.file_url().is_none());
404
405        Ok(())
406    }
407
408    #[traced_test]
409    #[test]
410    fn test_builder() -> Result<(), DataError> {
411        let en = MyLanguageTag::from_str("en")?;
412
413        let mut display = LanguageMap::new();
414        display.insert(&en, "zDisplay");
415
416        let mut description = LanguageMap::new();
417        description.insert(&en, "zDescription");
418
419        let builder = Attachment::builder()
420            .usage_type("http://somewhere.net/attachment-usage/test")?
421            .with_display(display)?
422            .with_description(description)?
423            .content_type("text/plain")?
424            .length(99)?
425            .sha2("495395e777cd98da653df9615d09c0fd6bb2f8d4788394cd53c56a3bfdcd848a")?;
426        let att = builder
427            .file_url("https://localhost/xapi/static/c44/sAZH2_GCudIGDdvf0xgHtLA/a1")?
428            .build()?;
429
430        assert_eq!(att.content_type, "text/plain");
431
432        Ok(())
433    }
434}