1use 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
19pub const SIGNATURE_UT: &str = "http://adlnet.gov/expapi/attachments/signature";
21pub const SIGNATURE_CT: &str = "application/octet-stream";
23
24#[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 pub fn builder() -> AttachmentBuilder<'static> {
47 AttachmentBuilder::default()
48 }
49
50 pub fn usage_type(&self) -> &IriStr {
52 self.usage_type.as_ref()
53 }
54
55 pub fn display(&self, tag: &MyLanguageTag) -> Option<&str> {
57 self.display.get(tag)
58 }
59
60 pub fn display_as_map(&self) -> &LanguageMap {
62 &self.display
63 }
64
65 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 pub fn description_as_map(&self) -> Option<&LanguageMap> {
76 self.description.as_ref()
77 }
78
79 pub fn content_type(&self) -> &Mime {
81 &self.content_type
82 }
83
84 pub fn length(&self) -> i64 {
86 self.length
87 }
88
89 pub fn sha2(&self) -> &str {
91 self.sha2.as_str()
92 }
93
94 pub fn file_url(&self) -> Option<&IriStr> {
96 self.file_url.as_deref()
97 }
98
99 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 pub fn set_file_url(&mut self, url: &str) {
110 self.file_url = Some(IriString::from_str(url).unwrap());
111 }
112
113 pub fn is_signature(&self) -> bool {
115 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 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 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#[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 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 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 pub fn with_display(mut self, map: LanguageMap) -> Result<Self, DataError> {
248 self._display = Some(map);
249 Ok(self)
250 }
251
252 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 pub fn with_description(mut self, map: LanguageMap) -> Result<Self, DataError> {
263 self._description = Some(map);
264 Ok(self)
265 }
266
267 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 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 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 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 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}