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 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 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 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 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 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#[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 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 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 pub fn with_display(mut self, map: LanguageMap) -> Result<Self, DataError> {
244 self._display = Some(map);
245 Ok(self)
246 }
247
248 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 pub fn with_description(mut self, map: LanguageMap) -> Result<Self, DataError> {
259 self._description = Some(map);
260 Ok(self)
261 }
262
263 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 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 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 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 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}