1use 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
15pub const SIGNATURE_UT: &str = "http://adlnet.gov/expapi/attachments/signature";
17pub const SIGNATURE_CT: &str = "application/octet-stream";
19
20#[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 pub fn builder() -> AttachmentBuilder<'static> {
43 AttachmentBuilder::default()
44 }
45
46 pub fn usage_type(&self) -> &IriStr {
48 self.usage_type.as_ref()
49 }
50
51 pub fn display(&self, tag: &MyLanguageTag) -> Option<&str> {
53 self.display.get(tag)
54 }
55
56 pub fn display_as_map(&self) -> &LanguageMap {
58 &self.display
59 }
60
61 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 pub fn description_as_map(&self) -> Option<&LanguageMap> {
72 self.description.as_ref()
73 }
74
75 pub fn content_type(&self) -> &Mime {
77 &self.content_type
78 }
79
80 pub fn length(&self) -> i64 {
82 self.length
83 }
84
85 pub fn sha2(&self) -> &str {
87 self.sha2.as_str()
88 }
89
90 pub fn file_url(&self) -> Option<&IriStr> {
92 self.file_url.as_deref()
93 }
94
95 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 pub fn set_file_url(&mut self, url: &str) {
106 self.file_url = Some(IriString::from_str(url).unwrap());
107 }
108
109 pub fn is_signature(&self) -> bool {
111 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 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 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#[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 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 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 pub fn with_display(mut self, map: LanguageMap) -> Result<Self, DataError> {
240 self._display = Some(map);
241 Ok(self)
242 }
243
244 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 pub fn with_description(mut self, map: LanguageMap) -> Result<Self, DataError> {
255 self._description = Some(map);
256 Ok(self)
257 }
258
259 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 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 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 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 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}