Skip to main content

xapi_rs/data/
account.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{
4    data::{DataError, Fingerprint, Validate, ValidationError, validate::validate_irl},
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 let Some(z_home_page) = self._home_page {
223            if self._name.is_empty() {
224                emit_error!(DataError::Validation(ValidationError::MissingField(
225                    "name".into()
226                )))
227            } else {
228                Ok(Account {
229                    home_page: z_home_page.into(),
230                    name: self._name.to_owned(),
231                })
232            }
233        } else {
234            emit_error!(DataError::Validation(ValidationError::MissingField(
235                "home_page".into()
236            )))
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use tracing_test::traced_test;
245
246    #[traced_test]
247    #[test]
248    fn test_serde() -> Result<(), DataError> {
249        const JSON: &str = r#"{"homePage":"https://inter.net/","name":"user"}"#;
250
251        let a1 = Account::builder()
252            .home_page("https://inter.net/")?
253            .name("user")?
254            .build()?;
255
256        let se_result = serde_json::to_string(&a1);
257        assert!(se_result.is_ok());
258        let json = se_result.unwrap();
259        assert_eq!(json, JSON);
260
261        let de_result = serde_json::from_str::<Account>(JSON);
262        assert!(de_result.is_ok());
263        let a2 = de_result.unwrap();
264        assert_eq!(a1, a2);
265
266        // how properties are ordered in the JSON string is irrelevant
267        const JSON_: &str = r#"{"name":"user","homePage":"https://inter.net/"}"#;
268        let de_result = serde_json::from_str::<Account>(JSON_);
269        assert!(de_result.is_ok());
270        let a4 = de_result.unwrap();
271        assert_eq!(a1, a4);
272
273        Ok(())
274    }
275
276    #[traced_test]
277    #[test]
278    fn test_display() -> Result<(), DataError> {
279        const DISPLAY: &str =
280            r#"Account{ homePage: "http://résumé.example.org/", name: "zRésumé" }"#;
281        // r#"Account{ homePage: "http://r%C3%A9sum%C3%A9.example.org/", name: "zRésumé" }"#;
282
283        let a = Account::builder()
284            .home_page("http://résumé.example.org/")?
285            .name("zRésumé")?
286            .build()?;
287
288        let disp = a.to_string();
289        assert_eq!(disp, DISPLAY);
290
291        // make sure we can access the original IRI...
292        assert_eq!(a.home_page_as_str(), "http://résumé.example.org/");
293
294        // ...as well as the normalized URI version...
295        assert_eq!(
296            a.home_page()
297                .encode_to_uri()
298                .to_dedicated_string()
299                .normalize()
300                .to_dedicated_string()
301                .as_str(),
302            "http://r%C3%A9sum%C3%A9.example.org/"
303        );
304        // ...in other words...
305        assert_eq!(
306            a.home_page()
307                .encode_to_uri()
308                .to_dedicated_string()
309                .normalize()
310                .to_dedicated_string()
311                .as_str(),
312            a.home_page_as_uri()
313        );
314
315        Ok(())
316    }
317
318    #[traced_test]
319    #[test]
320    fn test_validation() -> Result<(), DataError> {
321        let a = Account::builder()
322            .home_page("http://résumé.example.org/")?
323            .name("zRésumé")?
324            .build()?;
325
326        let r = a.validate();
327        assert!(r.is_empty());
328
329        Ok(())
330    }
331
332    #[test]
333    fn test_runtime_error_macro() -> Result<(), DataError> {
334        let r1 = Account::builder().home_page("");
335        let e1 = r1.err().unwrap();
336        assert!(matches!(e1, DataError::Validation { .. }));
337
338        let r2 = Account::builder()
339            .home_page("http://résumé.example.org/")?
340            .build();
341        let e2 = r2.err().unwrap();
342        assert!(matches!(e2, DataError::Validation { .. }));
343
344        Ok(())
345    }
346}