xapi_rs/data/
group.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{
4    data::{
5        Account, Agent, AgentId, CIString, DataError, Fingerprint, MyEmailAddress, ObjectType,
6        Validate, 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 represents a group of [Agent][1]s.
22///
23/// A [Group] can be **identified**, otherwise is considered to be
24/// **anonymous**.
25///
26/// [1]: crate::Agent
27#[skip_serializing_none]
28#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
29#[serde(deny_unknown_fields)]
30pub struct Group {
31    #[serde(rename = "objectType")]
32    object_type: ObjectType,
33    name: Option<CIString>,
34    #[serde(rename = "member")]
35    members: Option<Vec<Agent>>,
36    mbox: Option<MyEmailAddress>,
37    mbox_sha1sum: Option<String>,
38    openid: Option<UriString>,
39    account: Option<Account>,
40}
41
42#[skip_serializing_none]
43#[derive(Debug, Serialize)]
44#[doc(hidden)]
45pub(crate) struct GroupId {
46    #[serde(rename = "objectType")]
47    object_type: ObjectType,
48    #[serde(rename = "member")]
49    members: Option<Vec<AgentId>>,
50    mbox: Option<MyEmailAddress>,
51    mbox_sha1sum: Option<String>,
52    openid: Option<UriString>,
53    account: Option<Account>,
54}
55
56impl From<Group> for GroupId {
57    fn from(value: Group) -> Self {
58        GroupId {
59            object_type: ObjectType::Group,
60            members: {
61                if value.members.is_some() {
62                    let members = value.members.unwrap();
63                    if members.is_empty() {
64                        None
65                    } else {
66                        Some(members.into_iter().map(AgentId::from).collect())
67                    }
68                } else {
69                    None
70                }
71            },
72            mbox: value.mbox,
73            mbox_sha1sum: value.mbox_sha1sum,
74            openid: value.openid,
75            account: value.account,
76        }
77    }
78}
79
80impl From<GroupId> for Group {
81    fn from(value: GroupId) -> Self {
82        Group {
83            object_type: ObjectType::Group,
84            name: None,
85            members: {
86                if value.members.is_some() {
87                    let members = value.members.unwrap();
88                    if members.is_empty() {
89                        None
90                    } else {
91                        Some(members.into_iter().map(Agent::from).collect())
92                    }
93                } else {
94                    None
95                }
96            },
97            mbox: value.mbox,
98            mbox_sha1sum: value.mbox_sha1sum,
99            openid: value.openid,
100            account: value.account,
101        }
102    }
103}
104
105impl Group {
106    /// Construct and validate a [Group] from a JSON Object.
107    pub fn from_json_obj(map: Map<String, Value>) -> Result<Self, DataError> {
108        for (k, v) in &map {
109            if v.is_null() {
110                emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
111                    format!("Key '{k}' is null").into()
112                )))
113            } else {
114                check_for_nulls(v)?
115            }
116        }
117        // finally convert it to a group...
118        let group: Group = serde_json::from_value(Value::Object(map))?;
119        group.check_validity()?;
120        Ok(group)
121    }
122
123    /// Return a [Group] _Builder_.
124    pub fn builder() -> GroupBuilder {
125        GroupBuilder::default()
126    }
127
128    /// Return TRUE if the `objectType` property is [Group][1]; FALSE otherwise.
129    ///
130    /// [1]: ObjectType#variant.Group
131    pub fn check_object_type(&self) -> bool {
132        self.object_type == ObjectType::Group
133    }
134
135    /// Return TRUE if this Group is _anonymous_; FALSE otherwise.
136    pub fn is_anonymous(&self) -> bool {
137        self.mbox.is_none()
138            && self.mbox_sha1sum.is_none()
139            && self.account.is_none()
140            && self.openid.is_none()
141    }
142
143    /// Return `name` field if set; `None` otherwise.
144    pub fn name(&self) -> Option<&CIString> {
145        self.name.as_ref()
146    }
147
148    /// Return `name` field as a string reference if set; `None` otherwise.
149    pub fn name_as_str(&self) -> Option<&str> {
150        self.name.as_deref()
151    }
152
153    /// Return the unordered `members` list if it's set or `None` otherwise.
154    ///
155    /// When set, it's a vector of at least one [Agent]). This is expected to
156    /// be the case when the Group is _anonymous_.
157    pub fn members(&self) -> Vec<&Agent> {
158        if self.members.is_none() {
159            vec![]
160        } else {
161            self.members
162                .as_ref()
163                .unwrap()
164                .as_slice()
165                .iter()
166                .collect::<Vec<_>>()
167        }
168    }
169
170    /// Return `mbox` field if set; `None` otherwise.
171    pub fn mbox(&self) -> Option<&MyEmailAddress> {
172        self.mbox.as_ref()
173    }
174
175    /// Return `mbox_sha1sum` field (hex-encoded SHA1 hash of this entity's
176    /// `mbox` URI) if set; `None` otherwise.
177    pub fn mbox_sha1sum(&self) -> Option<&str> {
178        self.mbox_sha1sum.as_deref()
179    }
180
181    /// Return `openid` field (openID URI of this entity) if set; `None` otherwise.
182    pub fn openid(&self) -> Option<&UriStr> {
183        self.openid.as_deref()
184    }
185
186    /// Return `account` field (reference to this entity's [Account]) if set;
187    /// `None` otherwise.
188    pub fn account(&self) -> Option<&Account> {
189        self.account.as_ref()
190    }
191
192    /// Return the fingerprint of this instance.
193    pub fn uid(&self) -> u64 {
194        fingerprint_it(self)
195    }
196
197    /// Return TRUE if this is _Equivalent_ to `that` and FALSE otherwise.
198    pub fn equivalent(&self, that: &Group) -> bool {
199        self.uid() == that.uid()
200    }
201}
202
203impl fmt::Display for Group {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        let mut vec = vec![];
206
207        if self.name.is_some() {
208            vec.push(format!("name: \"{}\"", self.name().unwrap()));
209        }
210        if self.mbox.is_some() {
211            vec.push(format!("mbox: \"{}\"", self.mbox().unwrap()));
212        }
213        if self.mbox_sha1sum.is_some() {
214            vec.push(format!(
215                "mbox_sha1sum: \"{}\"",
216                self.mbox_sha1sum().unwrap()
217            ));
218        }
219        if self.account.is_some() {
220            vec.push(format!("account: {}", self.account().unwrap()));
221        }
222        if self.openid.is_some() {
223            vec.push(format!("openid: \"{}\"", self.openid().unwrap()));
224        }
225        if self.members.is_some() {
226            let members = self.members.as_deref().unwrap();
227            vec.push(format!(
228                "members: [{}]",
229                members
230                    .iter()
231                    .map(|x| x.to_string())
232                    .collect::<Vec<_>>()
233                    .join(", ")
234            ))
235        }
236
237        let res = vec
238            .iter()
239            .map(|x| x.to_string())
240            .collect::<Vec<_>>()
241            .join(", ");
242        write!(f, "Group{{ {res} }}")
243    }
244}
245
246impl Fingerprint for Group {
247    fn fingerprint<H: Hasher>(&self, state: &mut H) {
248        // discard `object_type` and `name`
249        if self.members.is_some() {
250            // ensure Agents are sorted...
251            let mut members = self.members.clone().unwrap();
252            members.sort_unstable();
253            Fingerprint::fingerprint_slice(&members, state);
254        }
255        if self.mbox.is_some() {
256            self.mbox.as_ref().unwrap().fingerprint(state);
257        }
258        self.mbox_sha1sum.hash(state);
259        self.openid.hash(state);
260        if self.account.is_some() {
261            self.account.as_ref().unwrap().fingerprint(state);
262        }
263    }
264}
265
266impl Ord for Group {
267    fn cmp(&self, other: &Self) -> Ordering {
268        fingerprint_it(self).cmp(&fingerprint_it(other))
269    }
270}
271
272impl PartialOrd for Group {
273    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
274        Some(self.cmp(other))
275    }
276}
277
278impl Validate for Group {
279    fn validate(&self) -> Vec<ValidationError> {
280        let mut vec = vec![];
281
282        if !self.check_object_type() {
283            vec.push(ValidationError::WrongObjectType {
284                expected: ObjectType::Group,
285                found: self.object_type.to_string().into(),
286            })
287        }
288        if self.name.is_some() && self.name.as_ref().unwrap().is_empty() {
289            vec.push(ValidationError::Empty("name".into()))
290        }
291        // the xAPI specifications mandate that "Exactly One of mbox, openid,
292        // mbox_sha1sum, account is required".
293        let mut count = 0;
294        if self.mbox.is_some() {
295            count += 1;
296            // no need to validate email address...
297        }
298        if self.mbox_sha1sum.is_some() {
299            count += 1;
300            validate_sha1sum(self.mbox_sha1sum.as_ref().unwrap()).unwrap_or_else(|x| vec.push(x))
301        }
302        if self.openid.is_some() {
303            count += 1;
304        }
305        if self.account.is_some() {
306            count += 1;
307            vec.extend(self.account.as_ref().unwrap().validate())
308        }
309        if self.is_anonymous() {
310            // must contain at least 1 member...
311            if self.members.is_none() {
312                vec.push(ValidationError::EmptyAnonymousGroup)
313            }
314        } else if count != 1 {
315            vec.push(ValidationError::ConstraintViolation(
316                "Exactly 1 IFI is required".into(),
317            ))
318        }
319        // anonymous or identified, validate all members...
320        if self.members.is_some() {
321            self.members
322                .as_ref()
323                .unwrap()
324                .iter()
325                .for_each(|x| vec.extend(x.validate()));
326        }
327
328        vec
329    }
330}
331
332impl FromStr for Group {
333    type Err = DataError;
334
335    fn from_str(s: &str) -> Result<Self, Self::Err> {
336        let map = serde_json::from_str::<Map<String, Value>>(s)?;
337        Self::from_json_obj(map)
338    }
339}
340
341/// A Type that knows how to construct a [Group].
342#[derive(Debug, Default)]
343pub struct GroupBuilder {
344    _name: Option<CIString>,
345    _members: Option<Vec<Agent>>,
346    _mbox: Option<MyEmailAddress>,
347    _sha1sum: Option<String>,
348    _openid: Option<UriString>,
349    _account: Option<Account>,
350}
351
352impl GroupBuilder {
353    /// Set the `name` field.
354    ///
355    /// Raise [DataError] if the string is empty.
356    pub fn name(mut self, s: &str) -> Result<Self, DataError> {
357        let s = s.trim();
358        if s.is_empty() {
359            emit_error!(DataError::Validation(ValidationError::Empty("name".into())))
360        }
361        self._name = Some(CIString::from(s));
362        Ok(self)
363    }
364
365    /// Add an [Agent] to this [Group].
366    ///
367    /// Raise [DataError] if the [Agent] is invalid.
368    pub fn member(mut self, val: Agent) -> Result<Self, DataError> {
369        val.check_validity()?;
370        if self._members.is_none() {
371            self._members = Some(vec![]);
372        }
373        self._members.as_mut().unwrap().push(val);
374        Ok(self)
375    }
376
377    /// Set the `mbox` field prefixing w/ `mailto:` if scheme's missing.
378    ///
379    /// Built instance will have all of its other _Inverse Functional Identifier_
380    /// fields \[re\]set to `None`.
381    ///
382    /// Raise an [DataError] if the string is empty, a scheme was present but
383    /// wasn't `mailto`, or parsing the string as an IRI fails.
384    pub fn mbox(mut self, s: &str) -> Result<Self, DataError> {
385        set_email!(self, s)
386    }
387
388    /// Set the `mbox_sha1sum` field.
389    ///
390    /// Built instance will have all of its other _Inverse Functional Identifier_
391    /// fields \[re\]set to `None`.
392    ///
393    /// Raise a [DataError] if the string is empty, is not 40 characters long,
394    /// or contains non hexadecimal characters.
395    pub fn mbox_sha1sum(mut self, s: &str) -> Result<Self, DataError> {
396        let s = s.trim();
397        if s.is_empty() {
398            emit_error!(DataError::Validation(ValidationError::Empty(
399                "mbox_sha1sum".into()
400            )))
401        }
402
403        validate_sha1sum(s)?;
404
405        self._sha1sum = Some(s.to_owned());
406        self._mbox = None;
407        self._openid = None;
408        self._account = None;
409        Ok(self)
410    }
411
412    /// Set the `openid` field.
413    ///
414    /// Built instance will have all of its other _Inverse Functional Identifier_
415    /// fields \[re\]set to `None`.
416    ///
417    /// Raise a [DataError] if the string is empty, or fails parsing as a
418    /// valid URI.
419    pub fn openid(mut self, s: &str) -> Result<Self, DataError> {
420        let s = s.trim();
421        if s.is_empty() {
422            emit_error!(DataError::Validation(ValidationError::Empty(
423                "openid".into()
424            )))
425        }
426
427        let uri = UriString::from_str(s)?;
428        self._openid = Some(uri);
429        self._mbox = None;
430        self._sha1sum = None;
431        self._account = None;
432        Ok(self)
433    }
434
435    /// Set the `account` field.
436    ///
437    /// Built instance will have all of its other _Inverse Functional Identifier_
438    /// fields \[re\]set to `None`.
439    ///
440    /// Raise [DataError] if the [Account] is invalid.
441    pub fn account(mut self, val: Account) -> Result<Self, DataError> {
442        val.check_validity()?;
443        self._account = Some(val);
444        self._mbox = None;
445        self._sha1sum = None;
446        self._openid = None;
447        Ok(self)
448    }
449
450    /// Create a [Group] instance.
451    ///
452    /// Raise [DataError] if no Inverse Functional Identifier field was set.
453    pub fn build(mut self) -> Result<Group, DataError> {
454        if self._mbox.is_none()
455            && self._sha1sum.is_none()
456            && self._openid.is_none()
457            && self._account.is_none()
458        {
459            return Err(DataError::Validation(ValidationError::MissingIFI(
460                "Group".into(),
461            )));
462        }
463
464        // NOTE (rsn) 20240705 - sort Agents...
465        if self._members.is_some() {
466            self._members.as_mut().unwrap().sort_unstable();
467        }
468        Ok(Group {
469            object_type: ObjectType::Group,
470            name: self._name,
471            members: self._members,
472            mbox: self._mbox,
473            mbox_sha1sum: self._sha1sum,
474            openid: self._openid,
475            account: self._account,
476        })
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use tracing_test::traced_test;
484
485    #[traced_test]
486    #[test]
487    fn test_identified_group() {
488        const JSON: &str = r#"{
489            "objectType": "Group",
490            "name": "Z Group",
491            "account": {
492                "homePage": "http://inter.net/home",
493                "name": "ganon"
494            },
495            "member": [
496                { "objectType": "Agent", "name": "foo", "mbox": "mailto:foo@mail.inter.net" },
497                { "objectType": "Agent", "name": "bar", "openid": "https://inter.net/oid" }
498            ]
499        }"#;
500        let de_result = serde_json::from_str::<Group>(JSON);
501        assert!(de_result.is_ok());
502        let g = de_result.unwrap();
503
504        assert!(!g.is_anonymous());
505    }
506
507    #[traced_test]
508    #[test]
509    fn test_identified_0_agents() {
510        const JSON: &str = r#"{"objectType":"Group","name":"Z Group","account":{"homePage":"http://inter.net/home","name":"ganon"}}"#;
511
512        let de_result = serde_json::from_str::<Group>(JSON);
513        assert!(de_result.is_ok());
514        let g = de_result.unwrap();
515
516        assert!(!g.is_anonymous());
517    }
518
519    #[traced_test]
520    #[test]
521    fn test_anonymous_group() -> Result<(), DataError> {
522        const JSON_IN_: &str = r#"{"objectType":"Group","name":"Z Group","member":[{"objectType":"Agent","name":"foo","mbox":"mailto:foo@mail.inter.net"},{"objectType":"Agent","name":"bar","openid":"https://inter.net/oid"}],"account":{"homePage":"http://inter.net/home","name":"ganon"}}"#;
523        const JSON_OUT: &str = r#"{"objectType":"Group","name":"Z Group","member":[{"objectType":"Agent","name":"bar","openid":"https://inter.net/oid"},{"objectType":"Agent","name":"foo","mbox":"mailto:foo@mail.inter.net"}],"account":{"homePage":"http://inter.net/home","name":"ganon"}}"#;
524
525        let g1 = Group::builder()
526            .name("Z Group")?
527            .account(
528                Account::builder()
529                    .home_page("http://inter.net/home")?
530                    .name("ganon")?
531                    .build()?,
532            )?
533            .member(
534                Agent::builder()
535                    .with_object_type()
536                    .name("foo")?
537                    .mbox("foo@mail.inter.net")?
538                    .build()?,
539            )?
540            .member(
541                Agent::builder()
542                    .with_object_type()
543                    .name("bar")?
544                    .openid("https://inter.net/oid")?
545                    .build()?,
546            )?
547            .build()?;
548        let se_result = serde_json::to_string(&g1);
549        assert!(se_result.is_ok());
550        let json = se_result.unwrap();
551        assert_eq!(json, JSON_OUT);
552
553        let de_result = serde_json::from_str::<Group>(JSON_IN_);
554        assert!(de_result.is_ok());
555        let g2 = de_result.unwrap();
556
557        // NOTE (rsn) 20240605 - unpredictable Agent members order in a Group
558        // may cause an equality test to fail.  however if two Groups have
559        // equivalent data their fingerprints should match...
560        assert_ne!(g1, g2);
561        assert!(g1.equivalent(&g2));
562
563        Ok(())
564    }
565
566    #[traced_test]
567    #[test]
568    fn test_long_group() {
569        const JSON: &str = r#"{
570            "name": "Team PB",
571            "mbox": "mailto:teampb@example.com",
572            "member": [
573                {
574                    "name": "Andrew Downes",
575                    "account": {
576                        "homePage": "http://www.example.com",
577                        "name": "13936749"
578                    },
579                    "objectType": "Agent"
580                },
581                {
582                    "name": "Toby Nichols",
583                    "openid": "http://toby.openid.example.org/",
584                    "objectType": "Agent"
585                },
586                {
587                    "name": "Ena Hills",
588                    "mbox_sha1sum": "ebd31e95054c018b10727ccffd2ef2ec3a016ee9",
589                    "objectType": "Agent"
590                }
591            ],
592            "objectType": "Group"
593        }"#;
594
595        let de_result = serde_json::from_str::<Group>(JSON);
596        assert!(de_result.is_ok());
597        let g = de_result.unwrap();
598
599        assert!(!g.is_anonymous());
600
601        assert!(g.name().is_some());
602        assert_eq!(g.name().unwrap(), "Team PB");
603
604        assert!(g.mbox().is_some());
605        assert_eq!(g.mbox().unwrap().to_uri(), "mailto:teampb@example.com");
606        assert!(g.mbox_sha1sum().is_none());
607        assert!(g.account().is_none());
608        assert!(g.openid().is_none());
609
610        assert_eq!(g.members().len(), 3);
611    }
612}