xapi_rs/data/
agent.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{
4    data::{
5        Account, CIString, DataError, Fingerprint, MyEmailAddress, ObjectType, Validate,
6        ValidationError, check_for_nulls, fingerprint_it, validate_sha1sum,
7    },
8    emit_error, set_email,
9};
10use core::fmt;
11use iri_string::types::{UriStr, UriString};
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14use serde_with::skip_serializing_none;
15use std::{
16    cmp::Ordering,
17    hash::{Hash, Hasher},
18    str::FromStr,
19};
20
21/// Structure that provides combined information about an individual derived
22/// from an outside service, such as a _Directory Service_.
23#[skip_serializing_none]
24#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
25#[serde(deny_unknown_fields)]
26pub struct Agent {
27    #[serde(rename = "objectType")]
28    object_type: Option<ObjectType>,
29    name: Option<CIString>,
30    mbox: Option<MyEmailAddress>,
31    mbox_sha1sum: Option<String>,
32    openid: Option<UriString>,
33    account: Option<Account>,
34}
35
36#[skip_serializing_none]
37#[derive(Debug, Serialize)]
38pub(crate) struct AgentId {
39    mbox: Option<MyEmailAddress>,
40    mbox_sha1sum: Option<String>,
41    openid: Option<UriString>,
42    account: Option<Account>,
43}
44
45impl From<Agent> for AgentId {
46    fn from(value: Agent) -> Self {
47        AgentId {
48            mbox: value.mbox,
49            mbox_sha1sum: value.mbox_sha1sum,
50            openid: value.openid,
51            account: value.account,
52        }
53    }
54}
55
56impl From<AgentId> for Agent {
57    fn from(value: AgentId) -> Self {
58        Agent {
59            object_type: None,
60            name: None,
61            mbox: value.mbox,
62            mbox_sha1sum: value.mbox_sha1sum,
63            openid: value.openid,
64            account: value.account,
65        }
66    }
67}
68
69impl Agent {
70    /// Construct and validate an [Agent] from a JSON Object.
71    pub fn from_json_obj(map: Map<String, Value>) -> Result<Self, DataError> {
72        for (k, v) in &map {
73            if v.is_null() {
74                emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
75                    format!("Key '{k}' is null").into()
76                )))
77            } else {
78                check_for_nulls(v)?
79            }
80        }
81        // finally convert it to an agent...
82        let agent: Agent = serde_json::from_value(Value::Object(map))?;
83        agent.check_validity()?;
84        Ok(agent)
85    }
86
87    /// Return an [Agent] _Builder_.
88    pub fn builder() -> AgentBuilder {
89        AgentBuilder::default()
90    }
91
92    /// Return TRUE if the `objectType` property is as expected; FALSE otherwise.
93    pub fn check_object_type(&self) -> bool {
94        if self.object_type.is_none() {
95            true
96        } else {
97            self.object_type.as_ref().unwrap() == &ObjectType::Agent
98        }
99    }
100
101    /// Return `name` if set; `None` otherwise.
102    pub fn name(&self) -> Option<&CIString> {
103        self.name.as_ref()
104    }
105
106    /// Return `name` as a string reference if set; `None` otherwise.
107    pub fn name_as_str(&self) -> Option<&str> {
108        self.name.as_deref()
109    }
110
111    /// Return `mbox` as an [MyEmailAddress] if set; `None` otherwise.
112    pub fn mbox(&self) -> Option<&MyEmailAddress> {
113        self.mbox.as_ref()
114    }
115
116    /// Return `mbox_sha1sum` field (hex-encoded SHA1 hash of this entity's
117    /// `mbox` URI) if set; `None` otherwise.
118    pub fn mbox_sha1sum(&self) -> Option<&str> {
119        self.mbox_sha1sum.as_deref()
120    }
121
122    /// Return `openid` field (openID URI of this entity) if set; `None` otherwise.
123    pub fn openid(&self) -> Option<&UriStr> {
124        self.openid.as_deref()
125    }
126
127    /// Return `account` field (reference to this entity's [Account]) if set;
128    /// `None` otherwise.
129    pub fn account(&self) -> Option<&Account> {
130        self.account.as_ref()
131    }
132
133    /// Return the fingerprint of this instance.
134    pub fn uid(&self) -> u64 {
135        fingerprint_it(self)
136    }
137
138    /// Return TRUE if this is _Equivalent_ to `that`; FALSE otherwise.
139    pub fn equivalent(&self, that: &Agent) -> bool {
140        self.uid() == that.uid()
141    }
142}
143
144impl Ord for Agent {
145    fn cmp(&self, other: &Self) -> Ordering {
146        fingerprint_it(self).cmp(&fingerprint_it(other))
147    }
148}
149
150impl PartialOrd for Agent {
151    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
152        Some(self.cmp(other))
153    }
154}
155
156impl FromStr for Agent {
157    type Err = DataError;
158
159    fn from_str(s: &str) -> Result<Self, Self::Err> {
160        let map: Map<String, Value> = serde_json::from_str(s)?;
161        Self::from_json_obj(map)
162    }
163}
164
165impl fmt::Display for Agent {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        let mut vec = vec![];
168        if self.name.is_some() {
169            vec.push(format!("name: \"{}\"", self.name().unwrap()));
170        }
171        if self.mbox.is_some() {
172            vec.push(format!("mbox: \"{}\"", self.mbox().unwrap()));
173        }
174        if self.mbox_sha1sum.is_some() {
175            vec.push(format!(
176                "mbox_sha1sum: \"{}\"",
177                self.mbox_sha1sum().unwrap()
178            ));
179        }
180        if self.account.is_some() {
181            vec.push(format!("account: {}", self.account().unwrap()));
182        }
183        if self.openid.is_some() {
184            vec.push(format!("openid: \"{}\"", self.openid().unwrap()));
185        }
186        let res = vec
187            .iter()
188            .map(|x| x.to_string())
189            .collect::<Vec<_>>()
190            .join(", ");
191        write!(f, "Agent{{ {res} }}")
192    }
193}
194
195impl Fingerprint for Agent {
196    fn fingerprint<H: Hasher>(&self, state: &mut H) {
197        // always discard `object_type` and `name`
198        if self.mbox.is_some() {
199            self.mbox.as_ref().unwrap().fingerprint(state);
200        }
201        self.mbox_sha1sum.hash(state);
202        self.openid.hash(state);
203        if self.account.is_some() {
204            self.account.as_ref().unwrap().fingerprint(state);
205        }
206    }
207}
208
209impl Validate for Agent {
210    fn validate(&self) -> Vec<ValidationError> {
211        let mut vec = vec![];
212
213        if self.object_type.is_some() && !self.check_object_type() {
214            vec.push(ValidationError::WrongObjectType {
215                expected: ObjectType::Agent,
216                found: self.object_type.as_ref().unwrap().to_string().into(),
217            })
218        }
219        if self.name.is_some() && self.name.as_ref().unwrap().is_empty() {
220            vec.push(ValidationError::Empty("name".into()))
221        }
222        // xAPI mandates that "Exactly One of mbox, openid, mbox_sha1sum,
223        // account is required".
224        let mut count = 0;
225        if self.mbox.is_some() {
226            count += 1;
227            // no need to validate email address...
228        }
229        if self.mbox_sha1sum.is_some() {
230            count += 1;
231            validate_sha1sum(self.mbox_sha1sum.as_ref().unwrap()).unwrap_or_else(|x| vec.push(x))
232        }
233        if self.openid.is_some() {
234            count += 1;
235        }
236        if self.account.is_some() {
237            count += 1;
238            vec.extend(self.account.as_ref().unwrap().validate())
239        }
240        if count != 1 {
241            vec.push(ValidationError::ConstraintViolation(
242                "Exactly 1 IFI is required".into(),
243            ))
244        }
245
246        vec
247    }
248}
249
250/// A Type that knows how to construct an [Agent].
251#[derive(Debug, Default)]
252pub struct AgentBuilder {
253    _object_type: Option<ObjectType>,
254    _name: Option<CIString>,
255    _mbox: Option<MyEmailAddress>,
256    _sha1sum: Option<String>,
257    _openid: Option<UriString>,
258    _account: Option<Account>,
259}
260
261impl AgentBuilder {
262    /// Set `objectType` property.
263    pub fn with_object_type(mut self) -> Self {
264        self._object_type = Some(ObjectType::Agent);
265        self
266    }
267
268    /// Set the `name` field.
269    ///
270    /// Raise [DataError] if the string is empty.
271    pub fn name(mut self, s: &str) -> Result<Self, DataError> {
272        let s = s.trim();
273        if s.is_empty() {
274            emit_error!(DataError::Validation(ValidationError::Empty("name".into())))
275        }
276        self._name = Some(CIString::from(s));
277        Ok(self)
278    }
279
280    /// Set the `mbox` field prefixing w/ `mailto:` if scheme's missing.
281    ///
282    /// Built instance will have all of its other _Inverse Functional Identifier_
283    /// fields \[re\]set to `None`.
284    ///
285    /// Raise an [DataError] if the string is empty, a scheme was present but
286    /// wasn't `mailto`, or parsing the string as an IRI fails.
287    pub fn mbox(mut self, s: &str) -> Result<Self, DataError> {
288        set_email!(self, s)
289    }
290
291    /// Set the `mbox_sha1sum` field.
292    ///
293    /// Built instance will have all of its other _Inverse Functional Identifier_
294    /// fields \[re\]set to `None`.
295    ///
296    /// Raise a [DataError] if the string is empty, is not 40 characters long,
297    /// or contains non hexadecimal characters.
298    pub fn mbox_sha1sum(mut self, s: &str) -> Result<Self, DataError> {
299        let s = s.trim();
300        if s.is_empty() {
301            emit_error!(DataError::Validation(ValidationError::Empty(
302                "mbox_sha1sum".into()
303            )))
304        }
305
306        validate_sha1sum(s)?;
307        self._sha1sum = Some(s.to_owned());
308        self._mbox = None;
309        self._openid = None;
310        self._account = None;
311        Ok(self)
312    }
313
314    /// Set the `openid` field.
315    ///
316    /// Built instance will have all of its other _Inverse Functional Identifier_
317    /// fields \[re\]set to `None`.
318    ///
319    /// Raise a [DataError] if the string is empty, or fails parsing as a
320    /// valid URI.
321    pub fn openid(mut self, s: &str) -> Result<Self, DataError> {
322        let s = s.trim();
323        if s.is_empty() {
324            emit_error!(DataError::Validation(ValidationError::Empty(
325                "openid".into()
326            )))
327        }
328
329        let uri = UriString::from_str(s)?;
330        self._openid = Some(uri);
331        self._mbox = None;
332        self._sha1sum = None;
333        self._account = None;
334        Ok(self)
335    }
336
337    /// Set the `account` field.
338    ///
339    /// Built instance will have all of its other _Inverse Functional Identifier_
340    /// fields \[re\]set to `None`.
341    ///
342    /// Raise [DataError] if the [Account] is invalid.
343    pub fn account(mut self, val: Account) -> Result<Self, DataError> {
344        val.check_validity()?;
345        self._account = Some(val);
346        self._mbox = None;
347        self._sha1sum = None;
348        self._openid = None;
349        Ok(self)
350    }
351
352    /// Create an [Agent] instance.
353    ///
354    /// Raise [DataError] if no Inverse Functional Identifier field was set.
355    pub fn build(self) -> Result<Agent, DataError> {
356        if self._mbox.is_none()
357            && self._sha1sum.is_none()
358            && self._openid.is_none()
359            && self._account.is_none()
360        {
361            emit_error!(DataError::Validation(ValidationError::MissingIFI(
362                "Agent".into(),
363            )));
364        }
365
366        Ok(Agent {
367            object_type: self._object_type,
368            name: self._name,
369            mbox: self._mbox,
370            mbox_sha1sum: self._sha1sum,
371            openid: self._openid,
372            account: self._account,
373        })
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use tracing_test::traced_test;
381
382    #[test]
383    fn test_serde() -> Result<(), DataError> {
384        const JSON: &str =
385            r#"{"objectType":"Agent","name":"Z User","mbox":"mailto:zuser@inter.net"}"#;
386
387        let a1 = Agent::builder()
388            .with_object_type()
389            .name("Z User")?
390            .mbox("zuser@inter.net")?
391            .build()?;
392        let se_result = serde_json::to_string(&a1);
393        assert!(se_result.is_ok());
394        let json = se_result.unwrap();
395        assert_eq!(json, JSON);
396
397        let de_result = serde_json::from_str::<Agent>(JSON);
398        assert!(de_result.is_ok());
399        let a2 = de_result.unwrap();
400
401        assert_eq!(a1, a2);
402
403        Ok(())
404    }
405
406    #[test]
407    fn test_camel_and_snake() {
408        const JSON: &str = r#"{
409            "objectType": "Agent",
410            "name": "Ena Hills",
411            "mbox": "mailto:ena.hills@example.com",
412            "mbox_sha1sum": "ebd31e95054c018b10727ccffd2ef2ec3a016ee9",
413            "account": {
414                "homePage": "http://www.example.com",
415                "name": "13936749"
416            },
417            "openid": "http://toby.openid.example.org/"
418        }"#;
419        let de_result = serde_json::from_str::<Agent>(JSON);
420        assert!(de_result.is_ok());
421        let a = de_result.unwrap();
422
423        assert!(a.check_object_type());
424        assert!(a.name().is_some());
425        assert_eq!(a.name().unwrap(), &CIString::from("ena hills"));
426        assert_eq!(a.name_as_str().unwrap(), "Ena Hills");
427        assert!(a.mbox().is_some());
428        assert_eq!(a.mbox().unwrap().to_uri(), "mailto:ena.hills@example.com");
429        assert!(a.mbox_sha1sum().is_some());
430        assert_eq!(
431            a.mbox_sha1sum().unwrap(),
432            "ebd31e95054c018b10727ccffd2ef2ec3a016ee9"
433        );
434        assert!(a.account().is_some());
435        let act = a.account().unwrap();
436        assert_eq!(act.home_page_as_str(), "http://www.example.com");
437        assert_eq!(act.name(), "13936749");
438        assert!(a.openid().is_some());
439        assert_eq!(
440            a.openid().unwrap().to_string(),
441            "http://toby.openid.example.org/"
442        );
443    }
444
445    #[traced_test]
446    #[test]
447    fn test_validate() {
448        const JSON1: &str =
449            r#"{"objectType":"Agent","name":"Z User","openid":"http://résumé.net/zuser"}"#;
450
451        let de_result = serde_json::from_str::<Agent>(JSON1);
452        // should fail b/c of invalid OpenID URI...
453        assert!(de_result.as_ref().is_err_and(|x| x.is_data()));
454        let de_err = de_result.err().unwrap();
455        let (line, col) = (de_err.line(), de_err.column());
456        assert_eq!(line, 1);
457        assert_eq!(col, 74);
458
459        const JSON2: &str =
460            r#"{"objectType":"Activity","name":"Z User","openid":"http://inter.net/zuser"}"#;
461
462        let de_result = serde_json::from_str::<Agent>(JSON2);
463        // will succeed but is invalid --wrong object_type...
464        assert!(de_result.is_ok());
465        let agent = de_result.unwrap();
466        let errors = agent.validate();
467        assert!(!errors.is_empty());
468        assert_eq!(errors.len(), 1);
469        assert!(matches!(
470            &errors[0],
471            ValidationError::WrongObjectType { .. }
472        ));
473
474        const JSON3: &str = r#"{"name":"Rick James","objectType":"Agent"}"#;
475
476        let de_result = serde_json::from_str::<Agent>(JSON3);
477        // will succeed but is invalid --no IFIs...
478        assert!(de_result.is_ok());
479        let agent = de_result.unwrap();
480        let errors = agent.validate();
481        assert!(!errors.is_empty());
482        assert_eq!(errors.len(), 1);
483        assert!(matches!(
484            &errors[0],
485            ValidationError::ConstraintViolation { .. }
486        ))
487    }
488
489    #[ignore = "Partially Implemented"]
490    #[traced_test]
491    #[test]
492    fn test_null_optional_fields() {
493        const E1: &str = r#"{"objectType":"Agent","name":null}"#;
494        const E2: &str = r#"{"objectType":"Agent","mbox":null}"#;
495        const E3: &str = r#"{"objectType":"Agent","openid":null}"#;
496        const E4: &str = r#"{"objectType":"Agent","account":null}"#;
497
498        const OK1: &str = r#"{"objectType":"Agent","mbox":"foo@bar.org"}"#;
499
500        assert!(serde_json::from_str::<Agent>(E1).is_err());
501        assert!(serde_json::from_str::<Agent>(E2).is_err());
502        assert!(serde_json::from_str::<Agent>(E3).is_err());
503        assert!(serde_json::from_str::<Agent>(E4).is_err());
504
505        assert!(serde_json::from_str::<Agent>(OK1).is_ok());
506    }
507}