xapi_rs/data/
statement.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{
4    data::{
5        Actor, ActorId, Attachment, Context, ContextId, DataError, Fingerprint, MyTimestamp,
6        MyVersion, StatementObject, StatementObjectId, Validate, ValidationError, Verb, VerbId,
7        XResult, check_for_nulls, fingerprint_it, statement_type::StatementType, stored_ser,
8    },
9    emit_error,
10};
11use chrono::{DateTime, SecondsFormat, Utc};
12use core::fmt;
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15use serde_with::skip_serializing_none;
16use std::{hash::Hasher, str::FromStr};
17use uuid::Uuid;
18
19/// Structure showing evidence of any sort of experience or event to be tracked
20/// in xAPI as a _Learning Record_.
21///
22/// A set of several [Statement]s, each representing an event in time, might
23/// be used to track complete details about a _learning experience_.
24///
25#[skip_serializing_none]
26#[derive(Debug, Deserialize, PartialEq, Serialize)]
27#[serde(deny_unknown_fields)]
28pub struct Statement {
29    id: Option<Uuid>,
30    actor: Actor,
31    verb: Verb,
32    object: StatementObject,
33    result: Option<XResult>,
34    context: Option<Context>,
35    timestamp: Option<MyTimestamp>,
36    #[serde(serialize_with = "stored_ser")]
37    stored: Option<DateTime<Utc>>,
38    authority: Option<Actor>,
39    version: Option<MyVersion>,
40    attachments: Option<Vec<Attachment>>,
41}
42
43// a doppelgänger structure that abides by the 'ids' format rules.
44#[skip_serializing_none]
45#[derive(Debug, Serialize)]
46#[doc(hidden)]
47pub(crate) struct StatementId {
48    id: Option<Uuid>,
49    actor: ActorId,
50    verb: VerbId,
51    object: StatementObjectId,
52    result: Option<XResult>,
53    context: Option<ContextId>,
54    timestamp: Option<MyTimestamp>,
55    #[serde(serialize_with = "stored_ser")]
56    stored: Option<DateTime<Utc>>,
57    authority: Option<ActorId>,
58    version: Option<MyVersion>,
59    attachments: Option<Vec<Attachment>>,
60}
61
62impl From<Statement> for StatementId {
63    fn from(value: Statement) -> Self {
64        StatementId {
65            id: value.id,
66            actor: ActorId::from(value.actor),
67            verb: value.verb.into(),
68            object: StatementObjectId::from(value.object),
69            result: value.result,
70            context: value.context.map(ContextId::from),
71            timestamp: value.timestamp,
72            stored: value.stored,
73            authority: value.authority.map(ActorId::from),
74            version: value.version,
75            attachments: value.attachments,
76        }
77    }
78}
79
80impl From<Box<Statement>> for StatementId {
81    fn from(value: Box<Statement>) -> Self {
82        StatementId {
83            id: value.id,
84            actor: ActorId::from(value.actor),
85            verb: value.verb.into(),
86            object: StatementObjectId::from(value.object),
87            result: value.result,
88            context: value.context.map(ContextId::from),
89            timestamp: value.timestamp,
90            stored: value.stored,
91            authority: value.authority.map(ActorId::from),
92            version: value.version,
93            attachments: value.attachments,
94        }
95    }
96}
97
98impl From<StatementId> for Statement {
99    fn from(value: StatementId) -> Self {
100        Statement {
101            id: value.id,
102            actor: Actor::from(value.actor),
103            verb: Verb::from(value.verb),
104            object: StatementObject::from(value.object),
105            result: value.result,
106            context: value.context.map(Context::from),
107            timestamp: value.timestamp,
108            stored: value.stored,
109            authority: value.authority.map(Actor::from),
110            version: value.version,
111            attachments: value.attachments,
112        }
113    }
114}
115
116impl From<Box<StatementId>> for Statement {
117    fn from(value: Box<StatementId>) -> Self {
118        Statement {
119            id: value.id,
120            actor: Actor::from(value.actor),
121            verb: Verb::from(value.verb),
122            object: StatementObject::from(value.object),
123            result: value.result,
124            context: value.context.map(Context::from),
125            timestamp: value.timestamp,
126            stored: value.stored,
127            authority: value.authority.map(Actor::from),
128            version: value.version,
129            attachments: value.attachments,
130        }
131    }
132}
133
134impl Statement {
135    /// Construct and validate a [Statement] from a JSON map.
136    pub fn from_json_obj(map: Map<String, Value>) -> Result<Self, DataError> {
137        for (k, v) in &map {
138            // NOTE (rsn) 20241104 - from "4.2.1 Table Guidelines": "The LRS
139            // shall reject Statements with any null values (except inside
140            // extensions)."
141            if v.is_null() {
142                emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
143                    format!("Key '{k}' is null").into()
144                )))
145            } else if k != "extensions" {
146                check_for_nulls(v)?
147            }
148        }
149        // finally convert it to a Statement...
150        let stmt: Statement = serde_json::from_value(Value::Object(map.to_owned()))?;
151        stmt.check_validity()?;
152        Ok(stmt)
153    }
154
155    /// Return a [Statement] _Builder_.
156    pub fn builder() -> StatementBuilder {
157        StatementBuilder::default()
158    }
159
160    /// Return the `id` field (a UUID) if set; `None` otherwise. It's assigned by
161    /// the LRS if not already set by the LRP.
162    pub fn id(&self) -> Option<&Uuid> {
163        self.id.as_ref()
164    }
165
166    /// Set the `id` field of this instance to the given value.
167    pub fn set_id(&mut self, id: Uuid) {
168        self.id = Some(id)
169    }
170
171    /// Return the [Actor] whom the [Statement] is about. The [Actor] is either
172    /// an [Agent][1] or a [Group][2].
173    ///
174    /// [1]: crate::Agent
175    /// [2]: crate::Group
176    pub fn actor(&self) -> &Actor {
177        &self.actor
178    }
179
180    /// Return the _action_ taken by the _actor_.
181    pub fn verb(&self) -> &Verb {
182        &self.verb
183    }
184
185    /// Return TRUE if `verb` is _voided_; FALSE otherwise.
186    pub fn is_verb_voided(&self) -> bool {
187        self.verb.is_voided()
188    }
189
190    /// Return an [Activity][1], an [Agent][2], or another [Statement] that is
191    /// the [Object][StatementObject] of this instance.
192    ///
193    /// [1]: crate::Activity
194    /// [2]: crate::Agent
195    pub fn object(&self) -> &StatementObject {
196        &self.object
197    }
198
199    /// Return the UUID of the (target) Statement to be voided by this one iff
200    /// (a) the verb is _voided_, and (b) the object is a [StatementRef][crate::StatementRef].
201    ///
202    /// Return `None` otherwise.
203    pub fn voided_target(&self) -> Option<Uuid> {
204        if self.is_verb_voided() && self.object.is_statement_ref() {
205            Some(
206                *self
207                    .object
208                    .as_statement_ref()
209                    .expect("Failed coercing object to StatementRef")
210                    .id(),
211            )
212        } else {
213            None
214        }
215    }
216
217    /// Return the [XResult] instance if set; `None` otherwise.
218    pub fn result(&self) -> Option<&XResult> {
219        self.result.as_ref()
220    }
221
222    /// Return the [Context] of this instance if set; `None` otherwise.
223    pub fn context(&self) -> Option<&Context> {
224        self.context.as_ref()
225    }
226
227    /// Return the timestamp of when the events described within this [Statement]
228    /// occurred as a `chrono::DateTime` if set; `None`  otherwise.
229    ///
230    /// It's set by the LRS if not provided.
231    pub fn timestamp(&self) -> Option<&DateTime<Utc>> {
232        if self.timestamp.is_none() {
233            None
234        } else {
235            Some(self.timestamp.as_ref().unwrap().inner())
236        }
237    }
238
239    /// Return the timestamp of when the events described within this [Statement]
240    /// occurred if set; `None` otherwise.
241    ///
242    /// It's set by the LRS if not provided.
243    pub fn timestamp_internal(&self) -> Option<&MyTimestamp> {
244        self.timestamp.as_ref()
245    }
246
247    /// Return the timestamp of when this [Statement] was persisted if set;
248    /// `None` otherwise.
249    pub fn stored(&self) -> Option<&DateTime<Utc>> {
250        self.stored.as_ref()
251    }
252
253    pub(crate) fn set_stored(&mut self, val: DateTime<Utc>) {
254        self.stored = Some(val);
255    }
256
257    /// Return the [Agent][crate::Agent] or the [Group][crate::Group] who is
258    /// asserting this [Statement] is TRUE if set or `None` otherwise.
259    ///
260    /// When provided it should be verified by the LRS based on authentication.
261    /// It's set by LRS if not provided, or if a strong trust relationship
262    /// between the LRP and LRS has not been established.
263    pub fn authority(&self) -> Option<&Actor> {
264        self.authority.as_ref()
265    }
266
267    pub(crate) fn set_authority_unchecked(&mut self, actor: Actor) {
268        self.authority = Some(actor)
269    }
270
271    /// Return the [Statement]'s associated xAPI version if set; `None` otherwise.
272    ///
273    /// When set, it's expected to be formatted according to [Semantic Versioning
274    /// 1.0.0][1].
275    ///
276    /// [1]: https://semver.org/spec/v1.0.0.html
277    pub fn version(&self) -> Option<&MyVersion> {
278        if self.version.is_none() {
279            None
280        } else {
281            Some(self.version.as_ref().unwrap())
282        }
283    }
284
285    /// Return a reference to the potentially empty array of [`attachments`][Attachment].
286    pub fn attachments(&self) -> &[Attachment] {
287        match &self.attachments {
288            Some(x) => x,
289            None => &[],
290        }
291    }
292
293    /// Return a mutable reference to the potentially empty array of [`attachments`][Attachment].
294    pub fn attachments_mut(&mut self) -> &mut [Attachment] {
295        if self.attachments.is_some() {
296            self.attachments.as_deref_mut().unwrap()
297        } else {
298            &mut []
299        }
300    }
301
302    /// Set (as in replace) `attachments` field of this instance to the given
303    /// value.
304    pub fn set_attachments(&mut self, attachments: Vec<Attachment>) {
305        self.attachments = Some(attachments)
306    }
307
308    /// Return a pretty-printed output of `self`.
309    pub fn print(&self) -> String {
310        serde_json::to_string_pretty(self).unwrap_or_else(|_| String::from("$Statement"))
311    }
312
313    /// Return the fingerprint of this instance.
314    pub fn uid(&self) -> u64 {
315        fingerprint_it(self)
316    }
317
318    /// Return TRUE if this is _Equivalent_ to `that` and FALSE otherwise.
319    pub fn equivalent(&self, that: &Statement) -> bool {
320        self.uid() == that.uid()
321    }
322}
323
324impl StatementId {
325    pub(crate) fn stored(&self) -> Option<&DateTime<Utc>> {
326        self.stored.as_ref()
327    }
328
329    pub(crate) fn attachments(&self) -> &[Attachment] {
330        match &self.attachments {
331            Some(x) => x,
332            None => &[],
333        }
334    }
335}
336
337impl Fingerprint for Statement {
338    #[allow(clippy::let_unit_value)]
339    fn fingerprint<H: Hasher>(&self, state: &mut H) {
340        // discard `id`, `timestamp`, `stored`, `authority`, `version` and `attachments`
341        self.actor.fingerprint(state);
342        self.verb.fingerprint(state);
343        self.object.fingerprint(state);
344        let _ = self.context().map_or((), |x| x.fingerprint(state));
345        let _ = self.result().map_or((), |x| x.fingerprint(state));
346    }
347}
348
349impl fmt::Display for Statement {
350    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
351        let mut vec = vec![];
352
353        if self.id().is_some() {
354            // always use the hyphenated lowercase format for UUIDs...
355            vec.push(format!(
356                "id: \"{}\"",
357                self.id
358                    .as_ref()
359                    .unwrap()
360                    .hyphenated()
361                    .encode_lower(&mut Uuid::encode_buffer())
362            ));
363        }
364        vec.push(format!("actor: {}", self.actor));
365        vec.push(format!("verb: {}", self.verb));
366        vec.push(format!("object: {}", self.object));
367        if self.result.is_some() {
368            vec.push(format!("result: {}", self.result.as_ref().unwrap()))
369        }
370        if self.context.is_some() {
371            vec.push(format!("context: {}", self.context.as_ref().unwrap()))
372        }
373        if self.timestamp.is_some() {
374            vec.push(format!(
375                "timestamp: \"{}\"",
376                self.timestamp.as_ref().unwrap()
377            ))
378        }
379        if self.stored.is_some() {
380            let ts = self.stored.as_ref().unwrap();
381            vec.push(format!(
382                "stored: \"{}\"",
383                ts.to_rfc3339_opts(SecondsFormat::Millis, true)
384            ))
385        }
386        if self.authority.is_some() {
387            vec.push(format!("authority: {}", self.authority.as_ref().unwrap()))
388        }
389        if self.version.is_some() {
390            vec.push(format!("version: \"{}\"", self.version.as_ref().unwrap()))
391        }
392        if self.attachments.is_some() {
393            let items = self.attachments.as_deref().unwrap();
394            vec.push(format!(
395                "attachments: [{}]",
396                items
397                    .iter()
398                    .map(|x| x.to_string())
399                    .collect::<Vec<_>>()
400                    .join(", ")
401            ))
402        }
403        let res = vec
404            .iter()
405            .map(|x| x.to_string())
406            .collect::<Vec<_>>()
407            .join(", ");
408        write!(f, "Statement{{ {res} }}")
409    }
410}
411
412impl Validate for Statement {
413    /// IMPORTANT - xAPI mandates that... The LRS shall specifically not consider
414    /// any of the following for equivalence, nor is it responsible for preservation
415    /// as described above for the following properties/cases:
416    ///
417    /// * Case (upper vs. lower)
418    /// * Id
419    /// * Order of any Group Members
420    /// * Authority
421    /// * Stored
422    /// * Timestamp
423    /// * Version
424    /// * Any attachments
425    /// * Any referenced Activity Definitions
426    ///
427    fn validate(&self) -> Vec<ValidationError> {
428        let mut vec = vec![];
429
430        if self.id.is_some()
431            && (self.id.as_ref().unwrap().is_nil() || self.id.as_ref().unwrap().is_max())
432        {
433            vec.push(ValidationError::ConstraintViolation(
434                "'id' must not be all 0's or 1's".into(),
435            ))
436        }
437        vec.extend(self.actor.validate());
438        vec.extend(self.verb.validate());
439        vec.extend(self.object.validate());
440        if self.result.is_some() {
441            vec.extend(self.result.as_ref().unwrap().validate())
442        }
443        if self.context.is_some() {
444            vec.extend(self.context.as_ref().unwrap().validate());
445            // NOTE (rsn) 20241017 - pending a resolution to [1] i'm adding checks
446            // here to conform to the requirement that...
447            // > A Statement cannot contain both a "revision" property in its
448            // "context" property and have the value of the "object" property's
449            // "objectType" be anything but "Activity"
450            //
451            // [1]: https://github.com/adlnet/lrs-conformance-test-suite/issues/278
452            //
453            if !self.object().is_activity()
454                && (self.context().as_ref().unwrap().revision().is_some()
455                    || self.context().as_ref().unwrap().platform().is_some())
456            {
457                vec.push(ValidationError::ConstraintViolation(
458                    "Statement context w/ revision | platform but object != Activity".into(),
459                ))
460            }
461        }
462        if self.authority.is_some() {
463            vec.extend(self.authority.as_ref().unwrap().validate());
464
465            // NOTE (rsn) 20241018 - Current v2_0 conformance tests apply v1_0_3
466            // constraints specified [here][1]. For `authority` these are:
467            // * XAPI-00098 - An `authority` property which is also a _Group_
468            //   contains exactly two _Agent_s. The LRS rejects with **`400 Bad
469            //   Request`**` a statement which has an `authority` property with
470            //   an `objectType` of `Group` with more or less than 2 Oauth
471            //   Agents as values of the `member` property.
472            // * XAPI-00099 - An LRS populates the `authority` property if it
473            //   is not provided in the _Statement_.
474            // * XAPI-00100 - An LRS rejects with error code **`400 Bad Request`**,
475            //   a Request whose `authority` is a _Group_ having more than two
476            //   _Agent_s.
477            // For now ensure the first and last ones are honored here. The
478            // middle one will be taken care of the same way `stored` is.
479            //
480            // [1]: https://adl.gitbooks.io/xapi-lrs-conformance-requirements/content/
481            if self.authority.as_ref().unwrap().is_group() {
482                let group = self.authority.as_ref().unwrap().as_group().unwrap();
483                if !group.is_anonymous() {
484                    vec.push(ValidationError::ConstraintViolation(
485                        "When used as an Authority, A Group must be anonymous".into(),
486                    ))
487                }
488                if group.members().len() != 2 {
489                    vec.push(ValidationError::ConstraintViolation(
490                        "When used as an Authority, an anonymous Group must have 2 members only"
491                            .into(),
492                    ))
493                }
494            }
495        }
496        if self.version.is_some() {
497            vec.extend(self.version.as_ref().unwrap().validate())
498        }
499        if self.attachments.is_some() {
500            for att in self.attachments.as_ref().unwrap().iter() {
501                vec.extend(att.validate())
502            }
503        }
504
505        vec
506    }
507}
508
509impl FromStr for Statement {
510    type Err = DataError;
511
512    fn from_str(s: &str) -> Result<Self, Self::Err> {
513        let map: Map<String, Value> = serde_json::from_str(s)?;
514        Self::from_json_obj(map)
515    }
516}
517
518impl TryFrom<StatementType> for Statement {
519    type Error = DataError;
520
521    fn try_from(value: StatementType) -> Result<Self, Self::Error> {
522        match value {
523            StatementType::S(x) => Ok(*x),
524            StatementType::SId(x) => Ok(Statement::from(x)),
525            _ => Err(DataError::Validation(ValidationError::ConstraintViolation(
526                "Not a Statement".into(),
527            ))),
528        }
529    }
530}
531
532impl TryFrom<StatementType> for StatementId {
533    type Error = DataError;
534
535    fn try_from(value: StatementType) -> Result<Self, Self::Error> {
536        match value {
537            StatementType::S(x) => Ok(StatementId::from(x)),
538            StatementType::SId(x) => Ok(*x),
539            _ => Err(DataError::Validation(ValidationError::ConstraintViolation(
540                "Not a StatementId".into(),
541            ))),
542        }
543    }
544}
545
546/// A Type that knows how to construct [Statement].
547#[derive(Debug, Default)]
548pub struct StatementBuilder {
549    _id: Option<Uuid>,
550    _actor: Option<Actor>,
551    _verb: Option<Verb>,
552    _object: Option<StatementObject>,
553    _result: Option<XResult>,
554    _context: Option<Context>,
555    _timestamp: Option<MyTimestamp>,
556    _stored: Option<DateTime<Utc>>,
557    _authority: Option<Actor>,
558    _version: Option<MyVersion>,
559    _attachments: Option<Vec<Attachment>>,
560}
561
562impl StatementBuilder {
563    /// Set the `id` field parsing the argument as a UUID.
564    ///
565    /// Raise [DataError] if argument is empty, cannot be parsed into a
566    /// valid UUID, or is all zeroes (`nil` UUID) or ones (`max` UUID).
567    pub fn id(self, val: &str) -> Result<Self, DataError> {
568        let val = val.trim();
569        if val.is_empty() {
570            emit_error!(DataError::Validation(ValidationError::Empty("id".into())))
571        } else {
572            let uuid = Uuid::parse_str(val)?;
573            self.id_as_uuid(uuid)
574        }
575    }
576
577    /// Set the `id` field from given UUID.
578    ///
579    /// Raise [DataError] if argument is empty, or is all zeroes (`nil` UUID)
580    /// or ones (`max` UUID).
581    pub fn id_as_uuid(mut self, uuid: Uuid) -> Result<Self, DataError> {
582        if uuid.is_nil() || uuid.is_max() {
583            emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
584                "'id' should not be all 0's or 1's".into()
585            )))
586        } else {
587            self._id = Some(uuid);
588            Ok(self)
589        }
590    }
591
592    /// Set the `actor` field.
593    ///
594    /// Raise [DataError] if the argument is invalid.
595    pub fn actor(mut self, val: Actor) -> Result<Self, DataError> {
596        val.check_validity()?;
597        self._actor = Some(val);
598        Ok(self)
599    }
600
601    /// Set the `verb` field.
602    ///
603    /// Raise [DataError] if the argument is invalid.
604    pub fn verb(mut self, val: Verb) -> Result<Self, DataError> {
605        val.check_validity()?;
606        self._verb = Some(val);
607        Ok(self)
608    }
609
610    /// Set the `object` field.
611    ///
612    /// Raise [DataError] if the argument is invalid.
613    pub fn object(mut self, val: StatementObject) -> Result<Self, DataError> {
614        val.check_validity()?;
615        self._object = Some(val);
616        Ok(self)
617    }
618
619    /// Set the `result` field.
620    ///
621    /// Raise [DataError] if the argument is invalid.
622    pub fn result(mut self, val: XResult) -> Result<Self, DataError> {
623        val.check_validity()?;
624        self._result = Some(val);
625        Ok(self)
626    }
627
628    /// Set the `context` field.
629    ///
630    /// Raise [DataError] if the argument is invalid.
631    pub fn context(mut self, val: Context) -> Result<Self, DataError> {
632        val.check_validity()?;
633        self._context = Some(val);
634        Ok(self)
635    }
636
637    /// Set the `timestamp` field from a string.
638    ///
639    /// Raise [DataError] if the argument is empty or invalid.
640    pub fn timestamp(mut self, val: &str) -> Result<Self, DataError> {
641        let val = val.trim();
642        if val.is_empty() {
643            emit_error!(DataError::Validation(ValidationError::Empty(
644                "timestamp".into()
645            )))
646        }
647        let ts = MyTimestamp::from_str(val)?;
648        self._timestamp = Some(ts);
649        Ok(self)
650    }
651
652    /// Set the `timestamp` field from a [DateTime] value.
653    pub fn with_timestamp(mut self, val: DateTime<Utc>) -> Self {
654        self._timestamp = Some(MyTimestamp::from(val));
655        self
656    }
657
658    /// Set the `stored` field from a string.
659    ///
660    /// Raise [DataError] if the argument is empty or invalid.
661    pub fn stored(mut self, val: &str) -> Result<Self, DataError> {
662        let val = val.trim();
663        if val.is_empty() {
664            emit_error!(DataError::Validation(ValidationError::Empty(
665                "stored".into()
666            )))
667        }
668        let ts = serde_json::from_str::<MyTimestamp>(val)?;
669        self._stored = Some(*ts.inner());
670        Ok(self)
671    }
672
673    /// Set the `stored` field from a [DateTime] value.
674    pub fn with_stored(mut self, val: DateTime<Utc>) -> Self {
675        self._stored = Some(val);
676        self
677    }
678
679    /// Set the `authority` field.
680    ///
681    /// Raise [DataError] if the argument is invalid.
682    pub fn authority(mut self, val: Actor) -> Result<Self, DataError> {
683        val.check_validity()?;
684        // in addition it must satisfy the following constraints for
685        // use as an Authority --see validate():
686        if val.is_group() {
687            let group = val.as_group().unwrap();
688            if !group.is_anonymous() {
689                emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
690                    "When used as an Authority, a Group must be anonymous".into()
691                )))
692            }
693            if group.members().len() != 2 {
694                emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
695                    "When used as an Authority, an anonymous Group must have 2 members only".into()
696                )))
697            }
698        }
699        self._authority = Some(val);
700        Ok(self)
701    }
702
703    /// Set the `version` field.
704    ///
705    /// Raise [DataError] if the argument is empty or invalid.
706    pub fn version(mut self, val: &str) -> Result<Self, DataError> {
707        self._version = Some(MyVersion::from_str(val)?);
708        Ok(self)
709    }
710
711    /// Add `att` to `attachments` field if valid; otherwise raise a
712    /// [DataError].
713    pub fn attachment(mut self, att: Attachment) -> Result<Self, DataError> {
714        att.check_validity()?;
715        if self._attachments.is_none() {
716            self._attachments = Some(vec![])
717        }
718        self._attachments.as_mut().unwrap().push(att);
719        Ok(self)
720    }
721
722    /// Create a [Statement] from set field values.
723    ///
724    /// Raise [DataError] if an error occurs.
725    pub fn build(self) -> Result<Statement, DataError> {
726        if self._actor.is_none() || self._verb.is_none() || self._object.is_none() {
727            emit_error!(DataError::Validation(ValidationError::MissingField(
728                "actor, verb, or object".into()
729            )))
730        }
731        Ok(Statement {
732            id: self._id,
733            actor: self._actor.unwrap(),
734            verb: self._verb.unwrap(),
735            object: self._object.unwrap(),
736            result: self._result,
737            context: self._context,
738            timestamp: self._timestamp,
739            stored: self._stored,
740            authority: self._authority,
741            version: self._version,
742            attachments: self._attachments,
743        })
744    }
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750    use serde_json::{Map, Value};
751    use tracing_test::traced_test;
752
753    #[traced_test]
754    #[test]
755    #[should_panic]
756    fn test_extra_properties() {
757        const S: &str = r#"{
758"actor":{"objectType":"Agent","name":"xAPI mbox","mbox":"mailto:xapi@adlnet.gov"},
759"verb":{"id":"http://adlnet.gov/expapi/verbs/attended","display":{"en-US":"attended"}},
760"object":{"objectType":"Activity","id":"http://www.example.com/meetings/occurances/34534"},
761"iD":"46bf512f-56ec-45ef-8f95-1f4b352386e6"}"#;
762
763        let map: Map<String, Value> = serde_json::from_str(S).unwrap();
764        assert!(!map.contains_key("id"));
765        assert!(!map.contains_key("ID"));
766        assert!(!map.contains_key("Id"));
767        assert!(map.contains_key("iD"));
768        let s = serde_json::from_value::<Statement>(Value::Object(map));
769        assert!(s.is_err());
770
771        // now try from_str; which calls from_json_obj... it should panic
772        Statement::from_str(S).unwrap();
773    }
774
775    #[traced_test]
776    #[test]
777    fn test_extensions_w_nulls() {
778        const S: &str = r#"{
779"actor":{"objectType":"Agent","name":"xAPI account","mbox":"mailto:xapi@adlnet.gov"},
780"verb":{"id":"http://adlnet.gov/expapi/verbs/attended","display":{"en-GB":"attended"}},
781"object":{
782  "objectType":"Activity",
783  "id":"http://www.example.com/meetings/occurances/34534",
784  "definition":{
785    "type":"http://adlnet.gov/expapi/activities/meeting",
786    "name":{"en-GB":"example meeting","en-US":"example meeting"},
787    "description":{"en-GB":"An example meeting.","en-US":"An example meeting."},
788    "moreInfo":"http://virtualmeeting.example.com/345256",
789    "extensions":{"http://example.com/null":null}}}}"#;
790
791        assert!(Statement::from_str(S).is_ok());
792    }
793
794    #[test]
795    #[should_panic]
796    fn test_bad_duration() {
797        const S: &str = r#"{
798"actor":{"objectType":"Agent","name":"xAPI account","mbox":"mailto:xapi@adlnet.gov"},
799"verb":{"id":"http://adlnet.gov/expapi/verbs/attended","display":{"en-US":"attended"}},
800"result":{
801  "score":{"scaled":0.95,"raw":95,"min":0,"max":100},
802  "extensions":{"http://example.com/profiles/meetings/resultextensions/minuteslocation":"X:\\\\meetings\\\\minutes\\\\examplemeeting.one","http://example.com/profiles/meetings/resultextensions/reporter":{"name":"Thomas","id":"http://openid.com/342"}},
803  "success":true,
804  "completion":true,
805  "response":"We agreed on some example actions.",
806  "duration":"P4W1D"},
807"object":{"objectType":"Activity","id":"http://www.example.com/meetings/occurances/34534"}}"#;
808
809        Statement::from_str(S).unwrap();
810    }
811}