Skip to main content

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 let Some(z_object_type) = self.object_type.as_ref() {
95            z_object_type == &ObjectType::Agent
96        } else {
97            true
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 let Some(z_mbox) = self.mbox.as_ref() {
199            z_mbox.fingerprint(state);
200        }
201        self.mbox_sha1sum.hash(state);
202        self.openid.hash(state);
203        if let Some(z_account) = self.account.as_ref() {
204            z_account.fingerprint(state);
205        }
206    }
207}
208
209impl Validate for Agent {
210    fn validate(&self) -> Vec<ValidationError> {
211        let mut vec = vec![];
212
213        if let Some(z_object_type) = self.object_type.as_ref()
214            && z_object_type != &ObjectType::Agent
215        {
216            vec.push(ValidationError::WrongObjectType {
217                expected: ObjectType::Agent,
218                found: z_object_type.to_string().into(),
219            })
220        }
221        if self.name.is_some() && self.name.as_ref().unwrap().is_empty() {
222            vec.push(ValidationError::Empty("name".into()))
223        }
224        // xAPI mandates that "Exactly One of mbox, openid, mbox_sha1sum,
225        // account is required".
226        let mut count = 0;
227        if self.mbox.is_some() {
228            count += 1;
229            // no need to validate email address...
230        }
231        if let Some(z_mbox_sha1sum) = self.mbox_sha1sum.as_ref() {
232            count += 1;
233            validate_sha1sum(z_mbox_sha1sum).unwrap_or_else(|x| vec.push(x))
234        }
235        if self.openid.is_some() {
236            count += 1;
237        }
238        if let Some(z_account) = self.account.as_ref() {
239            count += 1;
240            vec.extend(z_account.validate())
241        }
242        if count != 1 {
243            vec.push(ValidationError::ConstraintViolation(
244                "Exactly 1 IFI is required".into(),
245            ))
246        }
247
248        vec
249    }
250}
251
252/// A Type that knows how to construct an [Agent].
253#[derive(Debug, Default)]
254pub struct AgentBuilder {
255    _object_type: Option<ObjectType>,
256    _name: Option<CIString>,
257    _mbox: Option<MyEmailAddress>,
258    _sha1sum: Option<String>,
259    _openid: Option<UriString>,
260    _account: Option<Account>,
261}
262
263impl AgentBuilder {
264    /// Set `objectType` property.
265    pub fn with_object_type(mut self) -> Self {
266        self._object_type = Some(ObjectType::Agent);
267        self
268    }
269
270    /// Set the `name` field.
271    ///
272    /// Raise [DataError] if the string is empty.
273    pub fn name(mut self, s: &str) -> Result<Self, DataError> {
274        let s = s.trim();
275        if s.is_empty() {
276            emit_error!(DataError::Validation(ValidationError::Empty("name".into())))
277        }
278        self._name = Some(CIString::from(s));
279        Ok(self)
280    }
281
282    /// Set the `mbox` field prefixing w/ `mailto:` if scheme's missing.
283    ///
284    /// Built instance will have all of its other _Inverse Functional Identifier_
285    /// fields \[re\]set to `None`.
286    ///
287    /// Raise an [DataError] if the string is empty, a scheme was present but
288    /// wasn't `mailto`, or parsing the string as an IRI fails.
289    pub fn mbox(mut self, s: &str) -> Result<Self, DataError> {
290        set_email!(self, s)
291    }
292
293    /// Set the `mbox_sha1sum` field.
294    ///
295    /// Built instance will have all of its other _Inverse Functional Identifier_
296    /// fields \[re\]set to `None`.
297    ///
298    /// Raise a [DataError] if the string is empty, is not 40 characters long,
299    /// or contains non hexadecimal characters.
300    pub fn mbox_sha1sum(mut self, s: &str) -> Result<Self, DataError> {
301        let s = s.trim();
302        if s.is_empty() {
303            emit_error!(DataError::Validation(ValidationError::Empty(
304                "mbox_sha1sum".into()
305            )))
306        }
307
308        validate_sha1sum(s)?;
309        self._sha1sum = Some(s.to_owned());
310        self._mbox = None;
311        self._openid = None;
312        self._account = None;
313        Ok(self)
314    }
315
316    /// Set the `openid` field.
317    ///
318    /// Built instance will have all of its other _Inverse Functional Identifier_
319    /// fields \[re\]set to `None`.
320    ///
321    /// Raise a [DataError] if the string is empty, or fails parsing as a
322    /// valid URI.
323    pub fn openid(mut self, s: &str) -> Result<Self, DataError> {
324        let s = s.trim();
325        if s.is_empty() {
326            emit_error!(DataError::Validation(ValidationError::Empty(
327                "openid".into()
328            )))
329        }
330
331        let uri = UriString::from_str(s)?;
332        self._openid = Some(uri);
333        self._mbox = None;
334        self._sha1sum = None;
335        self._account = None;
336        Ok(self)
337    }
338
339    /// Set the `account` field.
340    ///
341    /// Built instance will have all of its other _Inverse Functional Identifier_
342    /// fields \[re\]set to `None`.
343    ///
344    /// Raise [DataError] if the [Account] is invalid.
345    pub fn account(mut self, val: Account) -> Result<Self, DataError> {
346        val.check_validity()?;
347        self._account = Some(val);
348        self._mbox = None;
349        self._sha1sum = None;
350        self._openid = None;
351        Ok(self)
352    }
353
354    /// Create an [Agent] instance.
355    ///
356    /// Raise [DataError] if no Inverse Functional Identifier field was set.
357    pub fn build(self) -> Result<Agent, DataError> {
358        if self._mbox.is_none()
359            && self._sha1sum.is_none()
360            && self._openid.is_none()
361            && self._account.is_none()
362        {
363            emit_error!(DataError::Validation(ValidationError::MissingIFI(
364                "Agent".into(),
365            )));
366        }
367
368        Ok(Agent {
369            object_type: self._object_type,
370            name: self._name,
371            mbox: self._mbox,
372            mbox_sha1sum: self._sha1sum,
373            openid: self._openid,
374            account: self._account,
375        })
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use tracing_test::traced_test;
383
384    #[test]
385    fn test_serde() -> Result<(), DataError> {
386        const JSON: &str =
387            r#"{"objectType":"Agent","name":"Z User","mbox":"mailto:zuser@inter.net"}"#;
388
389        let a1 = Agent::builder()
390            .with_object_type()
391            .name("Z User")?
392            .mbox("zuser@inter.net")?
393            .build()?;
394        let se_result = serde_json::to_string(&a1);
395        assert!(se_result.is_ok());
396        let json = se_result.unwrap();
397        assert_eq!(json, JSON);
398
399        let de_result = serde_json::from_str::<Agent>(JSON);
400        assert!(de_result.is_ok());
401        let a2 = de_result.unwrap();
402
403        assert_eq!(a1, a2);
404
405        Ok(())
406    }
407
408    #[test]
409    fn test_camel_and_snake() {
410        const JSON: &str = r#"{
411            "objectType": "Agent",
412            "name": "Ena Hills",
413            "mbox": "mailto:ena.hills@example.com",
414            "mbox_sha1sum": "ebd31e95054c018b10727ccffd2ef2ec3a016ee9",
415            "account": {
416                "homePage": "http://www.example.com",
417                "name": "13936749"
418            },
419            "openid": "http://toby.openid.example.org/"
420        }"#;
421        let de_result = serde_json::from_str::<Agent>(JSON);
422        assert!(de_result.is_ok());
423        let a = de_result.unwrap();
424
425        assert!(a.check_object_type());
426        assert!(a.name().is_some());
427        assert_eq!(a.name().unwrap(), &CIString::from("ena hills"));
428        assert_eq!(a.name_as_str().unwrap(), "Ena Hills");
429        assert!(a.mbox().is_some());
430        assert_eq!(a.mbox().unwrap().to_uri(), "mailto:ena.hills@example.com");
431        assert!(a.mbox_sha1sum().is_some());
432        assert_eq!(
433            a.mbox_sha1sum().unwrap(),
434            "ebd31e95054c018b10727ccffd2ef2ec3a016ee9"
435        );
436        assert!(a.account().is_some());
437        let act = a.account().unwrap();
438        assert_eq!(act.home_page_as_str(), "http://www.example.com");
439        assert_eq!(act.name(), "13936749");
440        assert!(a.openid().is_some());
441        assert_eq!(
442            a.openid().unwrap().to_string(),
443            "http://toby.openid.example.org/"
444        );
445    }
446
447    #[traced_test]
448    #[test]
449    fn test_validate() {
450        const JSON1: &str =
451            r#"{"objectType":"Agent","name":"Z User","openid":"http://résumé.net/zuser"}"#;
452
453        let de_result = serde_json::from_str::<Agent>(JSON1);
454        // should fail b/c of invalid OpenID URI...
455        assert!(de_result.as_ref().is_err_and(|x| x.is_data()));
456        let de_err = de_result.err().unwrap();
457        let (line, col) = (de_err.line(), de_err.column());
458        assert_eq!(line, 1);
459        assert_eq!(col, 74);
460
461        const JSON2: &str =
462            r#"{"objectType":"Activity","name":"Z User","openid":"http://inter.net/zuser"}"#;
463
464        let de_result = serde_json::from_str::<Agent>(JSON2);
465        // will succeed but is invalid --wrong object_type...
466        assert!(de_result.is_ok());
467        let agent = de_result.unwrap();
468        let errors = agent.validate();
469        assert!(!errors.is_empty());
470        assert_eq!(errors.len(), 1);
471        assert!(matches!(
472            &errors[0],
473            ValidationError::WrongObjectType { .. }
474        ));
475
476        const JSON3: &str = r#"{"name":"Rick James","objectType":"Agent"}"#;
477
478        let de_result = serde_json::from_str::<Agent>(JSON3);
479        // will succeed but is invalid --no IFIs...
480        assert!(de_result.is_ok());
481        let agent = de_result.unwrap();
482        let errors = agent.validate();
483        assert!(!errors.is_empty());
484        assert_eq!(errors.len(), 1);
485        assert!(matches!(
486            &errors[0],
487            ValidationError::ConstraintViolation { .. }
488        ))
489    }
490
491    #[ignore = "Partially Implemented"]
492    #[traced_test]
493    #[test]
494    fn test_null_optional_fields() {
495        const E1: &str = r#"{"objectType":"Agent","name":null}"#;
496        const E2: &str = r#"{"objectType":"Agent","mbox":null}"#;
497        const E3: &str = r#"{"objectType":"Agent","openid":null}"#;
498        const E4: &str = r#"{"objectType":"Agent","account":null}"#;
499
500        const OK1: &str = r#"{"objectType":"Agent","mbox":"foo@bar.org"}"#;
501
502        assert!(serde_json::from_str::<Agent>(E1).is_err());
503        assert!(serde_json::from_str::<Agent>(E2).is_err());
504        assert!(serde_json::from_str::<Agent>(E3).is_err());
505        assert!(serde_json::from_str::<Agent>(E4).is_err());
506
507        assert!(serde_json::from_str::<Agent>(OK1).is_ok());
508    }
509}