xapi_rs/data/
account.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{
4    data::{validate::validate_irl, DataError, Fingerprint, Validate, ValidationError},
5    emit_error,
6};
7use core::fmt;
8use iri_string::{
9    convert::MappedToUri,
10    format::ToDedicatedString,
11    types::{IriStr, IriString, UriString},
12};
13use serde::{Deserialize, Serialize};
14use std::{
15    cmp::Ordering,
16    hash::{Hash, Hasher},
17};
18
19// character to use for separating the `home_page` and the `name` values when
20// catenating the pair for persisting in a database table.
21const SEPARATOR: char = '~';
22
23/// Structure sometimes used by [Agent][1]s and [Group][2]s to identify them.
24///
25/// It's one of the 4 _Inverse Functional Identifiers_ (IFI) xAPI cites as a
26/// means of identifying unambiguously an [Actor][3].
27///
28/// [1]: crate::Agent
29/// [2]: crate::Group
30/// [3]: crate::Actor
31#[derive(Clone, Debug, Eq, Hash, PartialEq, Deserialize, Serialize)]
32pub struct Account {
33    #[serde(rename = "homePage")]
34    home_page: IriString,
35    name: String,
36}
37
38impl Account {
39    // used when converting a string to an Account.
40    fn from(home_page: &str, name: &str) -> Result<Self, DataError> {
41        let home_page = home_page.trim();
42        if home_page.is_empty() {
43            emit_error!(DataError::Validation(ValidationError::Empty(
44                "home_page".into()
45            )))
46        }
47
48        let home_page = IriStr::new(home_page)?;
49        validate_irl(home_page)?;
50
51        let name = name.trim();
52        if name.is_empty() {
53            emit_error!(DataError::Validation(ValidationError::Empty("name".into())))
54        }
55
56        Ok(Account {
57            home_page: home_page.into(),
58            name: name.to_owned(),
59        })
60    }
61
62    /// Return an [Account] _Builder_.
63    pub fn builder() -> AccountBuilder<'static> {
64        AccountBuilder::default()
65    }
66
67    /// Return the `home_page` field as an IRI.
68    pub fn home_page(&self) -> &IriStr {
69        &self.home_page
70    }
71
72    /// Return the `home_page` field as a string reference.
73    pub fn home_page_as_str(&self) -> &str {
74        self.home_page.as_str()
75    }
76
77    /// Return the `home_page` field as a URI.
78    pub fn home_page_as_uri(&self) -> UriString {
79        let uri = MappedToUri::from(&self.home_page).to_dedicated_string();
80        uri.normalize().to_dedicated_string()
81    }
82
83    /// Return the `name` field.
84    pub fn name(&self) -> &str {
85        &self.name
86    }
87
88    /// Return the combined properties as a single string suitable for efficient
89    /// storage.
90    pub fn as_joined_str(&self) -> String {
91        format!("{}{}{}", self.home_page, SEPARATOR, self.name)
92    }
93}
94
95impl fmt::Display for Account {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        write!(
98            f,
99            "Account{{ homePage: \"{}\", name: \"{}\" }}",
100            self.home_page_as_str(),
101            self.name
102        )
103    }
104}
105
106impl Fingerprint for Account {
107    fn fingerprint<H: Hasher>(&self, state: &mut H) {
108        let (x, y) = self.home_page.as_slice().to_absolute_and_fragment();
109        x.normalize().to_string().hash(state);
110        y.hash(state);
111        self.name.hash(state);
112    }
113}
114
115impl PartialOrd for Account {
116    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
117        let (x1, y1) = self.home_page.as_slice().to_absolute_and_fragment();
118        let (x2, y2) = other.home_page.as_slice().to_absolute_and_fragment();
119        match x1
120            .normalize()
121            .to_string()
122            .partial_cmp(&x2.normalize().to_string())
123        {
124            Some(Ordering::Equal) => match y1.partial_cmp(&y2) {
125                Some(Ordering::Equal) => {}
126                x => return x,
127            },
128            x => return x,
129        }
130        self.name.partial_cmp(&other.name)
131    }
132}
133
134impl Validate for Account {
135    fn validate(&self) -> Vec<ValidationError> {
136        let mut vec: Vec<ValidationError> = vec![];
137        validate_irl(self.home_page.as_ref()).unwrap_or_else(|x| vec.push(x));
138        if self.name.trim().is_empty() {
139            vec.push(ValidationError::Empty("name".into()))
140        }
141        vec
142    }
143}
144
145impl TryFrom<String> for Account {
146    type Error = DataError;
147
148    fn try_from(value: String) -> Result<Self, Self::Error> {
149        let mut parts = value.split(SEPARATOR);
150        Account::from(
151            parts.next().ok_or_else(|| {
152                DataError::Validation(ValidationError::MissingField("home_page".into()))
153            })?,
154            parts.next().ok_or_else(|| {
155                DataError::Validation(ValidationError::MissingField("name".into()))
156            })?,
157        )
158    }
159}
160
161/// A Type that knows how to construct an [Account].
162#[derive(Debug, Default)]
163pub struct AccountBuilder<'a> {
164    _home_page: Option<&'a IriStr>,
165    _name: &'a str,
166}
167
168impl<'a> AccountBuilder<'a> {
169    /// Convenience method which if successful results in an [Account] instance
170    /// constructed from a compact string combining the `home_page` and `name`
171    /// values separated by a hard-wired SEPARATOR character.
172    ///
173    /// Raise [DataError] if the input is malformed.
174    pub fn from(s: &str) -> Result<Account, DataError> {
175        let parts: Vec<_> = s.trim().split(SEPARATOR).collect();
176        if parts.len() < 2 {
177            emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
178                "Missing separator".into()
179            )))
180        }
181        Account::builder()
182            .home_page(parts[0])?
183            .name(parts[1])?
184            .build()
185    }
186
187    /// Set the `home_page` field.
188    ///
189    /// Raise [DataError] if the argument is empty, cannot be parsed as an IRI,
190    /// or the resulting IRI is not a valid URL.
191    pub fn home_page(mut self, val: &'a str) -> Result<Self, DataError> {
192        let home_page = val.trim();
193        if home_page.is_empty() {
194            emit_error!(DataError::Validation(ValidationError::Empty(
195                "home_page".into()
196            )))
197        } else {
198            let home_page = IriStr::new(home_page)?;
199            validate_irl(home_page)?;
200            self._home_page = Some(home_page);
201            Ok(self)
202        }
203    }
204
205    /// Set the `name` field.
206    ///
207    /// Raise [DataError] if the argument is empty.
208    pub fn name(mut self, val: &'a str) -> Result<Self, DataError> {
209        let name = val.trim();
210        if name.is_empty() {
211            emit_error!(DataError::Validation(ValidationError::Empty("name".into())))
212        } else {
213            self._name = val;
214            Ok(self)
215        }
216    }
217
218    /// Create an [Account] from set field values.
219    ///
220    /// Raise [DataError] if either `home_page` or `name` is empty.
221    pub fn build(&self) -> Result<Account, DataError> {
222        if self._home_page.is_none() {
223            emit_error!(DataError::Validation(ValidationError::MissingField(
224                "hom_page".into()
225            )))
226        } else if self._name.is_empty() {
227            emit_error!(DataError::Validation(ValidationError::MissingField(
228                "name".into()
229            )))
230        } else {
231            Ok(Account {
232                home_page: self._home_page.unwrap().into(),
233                name: self._name.to_owned(),
234            })
235        }
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use tracing_test::traced_test;
243
244    #[traced_test]
245    #[test]
246    fn test_serde() -> Result<(), DataError> {
247        const JSON: &str = r#"{"homePage":"https://inter.net/","name":"user"}"#;
248
249        let a1 = Account::builder()
250            .home_page("https://inter.net/")?
251            .name("user")?
252            .build()?;
253
254        let se_result = serde_json::to_string(&a1);
255        assert!(se_result.is_ok());
256        let json = se_result.unwrap();
257        assert_eq!(json, JSON);
258
259        let de_result = serde_json::from_str::<Account>(JSON);
260        assert!(de_result.is_ok());
261        let a2 = de_result.unwrap();
262        assert_eq!(a1, a2);
263
264        // how properties are ordered in the JSON string is irrelevant
265        const JSON_: &str = r#"{"name":"user","homePage":"https://inter.net/"}"#;
266        let de_result = serde_json::from_str::<Account>(JSON_);
267        assert!(de_result.is_ok());
268        let a4 = de_result.unwrap();
269        assert_eq!(a1, a4);
270
271        Ok(())
272    }
273
274    #[traced_test]
275    #[test]
276    fn test_display() -> Result<(), DataError> {
277        const DISPLAY: &str =
278            r#"Account{ homePage: "http://résumé.example.org/", name: "zRésumé" }"#;
279        // r#"Account{ homePage: "http://r%C3%A9sum%C3%A9.example.org/", name: "zRésumé" }"#;
280
281        let a = Account::builder()
282            .home_page("http://résumé.example.org/")?
283            .name("zRésumé")?
284            .build()?;
285
286        let disp = a.to_string();
287        assert_eq!(disp, DISPLAY);
288
289        // make sure we can access the original IRI...
290        assert_eq!(a.home_page_as_str(), "http://résumé.example.org/");
291
292        // ...as well as the normalized URI version...
293        assert_eq!(
294            a.home_page()
295                .encode_to_uri()
296                .to_dedicated_string()
297                .normalize()
298                .to_dedicated_string()
299                .as_str(),
300            "http://r%C3%A9sum%C3%A9.example.org/"
301        );
302        // ...in other words...
303        assert_eq!(
304            a.home_page()
305                .encode_to_uri()
306                .to_dedicated_string()
307                .normalize()
308                .to_dedicated_string()
309                .as_str(),
310            a.home_page_as_uri()
311        );
312
313        Ok(())
314    }
315
316    #[traced_test]
317    #[test]
318    fn test_validation() -> Result<(), DataError> {
319        let a = Account::builder()
320            .home_page("http://résumé.example.org/")?
321            .name("zRésumé")?
322            .build()?;
323
324        let r = a.validate();
325        assert!(r.is_empty());
326
327        Ok(())
328    }
329
330    #[test]
331    fn test_runtime_error_macro() -> Result<(), DataError> {
332        let r1 = Account::builder().home_page("");
333        let e1 = r1.err().unwrap();
334        assert!(matches!(e1, DataError::Validation { .. }));
335
336        let r2 = Account::builder()
337            .home_page("http://résumé.example.org/")?
338            .build();
339        let e2 = r2.err().unwrap();
340        assert!(matches!(e2, DataError::Validation { .. }));
341
342        Ok(())
343    }
344}