xapi_rs/data/
activity.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{
4    MyLanguageTag,
5    data::{
6        ActivityDefinition, Canonical, DataError, Extensions, Fingerprint, InteractionComponent,
7        InteractionType, ObjectType, Validate, ValidationError,
8    },
9    emit_error,
10};
11use core::fmt;
12use iri_string::types::{IriStr, IriString};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use serde_with::skip_serializing_none;
16use std::{
17    hash::{Hash, Hasher},
18    mem,
19    str::FromStr,
20};
21
22/// Structure making up "this" in "I did this"; it is something with which an
23/// [Actor][1] interacted. It can be a unit of instruction, experience, or
24/// performance that is to be tracked in meaningful combination with a [Verb][2].
25///
26/// Interpretation of [Activity] is broad, meaning that activities can even be
27/// tangible objects such as a chair (real or virtual). In the [Statement][3]
28/// "Anna tried a cake recipe", the recipe constitutes the [Activity]. Other
29/// examples may include a book, an e-learning course, a hike, or a meeting.
30///
31/// [1]: crate::Actor
32/// [2]: crate::Verb
33/// [3]: crate::Statement
34#[skip_serializing_none]
35#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
36pub struct Activity {
37    #[serde(rename = "objectType")]
38    object_type: Option<ObjectType>,
39    id: IriString,
40    definition: Option<ActivityDefinition>,
41}
42
43#[derive(Debug, Serialize)]
44pub(crate) struct ActivityId {
45    id: IriString,
46}
47
48impl From<Activity> for ActivityId {
49    fn from(value: Activity) -> Self {
50        ActivityId { id: value.id }
51    }
52}
53
54impl From<ActivityId> for Activity {
55    fn from(value: ActivityId) -> Self {
56        Activity {
57            object_type: None,
58            id: value.id,
59            definition: None,
60        }
61    }
62}
63
64impl Activity {
65    /// Constructor that creates a new empty instance when it successfully
66    /// parses the input as the Activity's IRI identifier.
67    pub fn from_iri_str(iri: &str) -> Result<Self, DataError> {
68        Activity::builder().id(iri)?.build()
69    }
70
71    /// Return an [Activity] _Builder_.
72    pub fn builder() -> ActivityBuilder<'static> {
73        ActivityBuilder::default()
74    }
75
76    /// Return `id` field as an IRI.
77    pub fn id(&self) -> &IriStr {
78        &self.id
79    }
80
81    /// Return `id` field as a string reference.
82    pub fn id_as_str(&self) -> &str {
83        self.id.as_str()
84    }
85
86    /// Return `definition` field if set; `None` otherwise.
87    pub fn definition(&self) -> Option<&ActivityDefinition> {
88        self.definition.as_ref()
89    }
90
91    /// Consumes `other`'s `definition` replacing or augmenting `self`'s.
92    pub fn merge(&mut self, other: Activity) {
93        // FIXME (rsn) 20250412 - change the signature to return a Result
94        // raising an error if both arguments do not share the same ID instead
95        // of silently returning...
96        if self.id == other.id {
97            if self.definition.is_none() {
98                if other.definition.is_some() {
99                    let x = mem::take(&mut other.definition.unwrap());
100                    let mut z = Some(x);
101                    mem::swap(&mut self.definition, &mut z);
102                }
103            } else if other.definition.is_some() {
104                let mut x = mem::take(&mut self.definition).unwrap();
105                let y = other.definition.unwrap();
106                x.merge(y);
107                let mut z = Some(x);
108                mem::swap(&mut self.definition, &mut z);
109            }
110        }
111    }
112
113    // ===== convenience pass-through methods to the `definition` field =====
114
115    /// Convenience pass-through method to the `definition` field.
116    /// Return `name` for the given language `tag` if it exists; `None` otherwise.
117    pub fn name(&self, tag: &MyLanguageTag) -> Option<&str> {
118        match &self.definition {
119            None => None,
120            Some(def) => def.name(tag),
121        }
122    }
123
124    /// Convenience pass-through method to the `definition` field.
125    /// Return `description` for the given language `tag` if it exists; `None`
126    /// otherwise.
127    pub fn description(&self, tag: &MyLanguageTag) -> Option<&str> {
128        match &self.definition {
129            None => None,
130            Some(def) => def.description(tag),
131        }
132    }
133
134    /// Convenience pass-through method to the `definition` field.
135    /// Return `type_` if set; `None` otherwise.
136    pub fn type_(&self) -> Option<&IriStr> {
137        match &self.definition {
138            None => None,
139            Some(def) => def.type_(),
140        }
141    }
142
143    /// Convenience pass-through method to the `definition` field.
144    /// Return `more_info` if set; `None` otherwise.
145    ///
146    /// When set, it's an IRL that points to information about the associated
147    /// [Activity] possibly incl. a way to launch it.
148    pub fn more_info(&self) -> Option<&IriStr> {
149        match &self.definition {
150            None => None,
151            Some(def) => def.more_info(),
152        }
153    }
154
155    /// Convenience pass-through method to the `definition` field.
156    /// Return `interaction_type` if set; `None` otherwise.
157    ///
158    /// Possible values are: [`true-false`][InteractionType#variant.TrueFalse],
159    /// [`choice`][InteractionType#variant.Choice],
160    /// [`fill-in`][InteractionType#variant.FillIn],
161    /// [`long-fill-in`][InteractionType#variant.LongFillIn],
162    /// [`matching`][InteractionType#variant.Matching],
163    /// [`performance`][InteractionType#variant.Performance],
164    /// [`sequencing`][InteractionType#variant.Sequencing],
165    /// [`likert`][InteractionType#variant.Likert],
166    /// [`numeric`][InteractionType#variant.Numeric], and
167    /// [`other`][InteractionType#variant.Other],
168    pub fn interaction_type(&self) -> Option<&InteractionType> {
169        match &self.definition {
170            None => None,
171            Some(def) => def.interaction_type(),
172        }
173    }
174
175    /// Convenience pass-through method to the `definition` field.
176    /// Return `correct_responses_pattern` if set; `None` otherwise.
177    ///
178    /// When set, it's a Vector of patterns representing the correct response
179    /// to the interaction.
180    ///
181    /// The structure of the patterns vary depending on the `interaction_type`.
182    pub fn correct_responses_pattern(&self) -> Option<&Vec<String>> {
183        match &self.definition {
184            None => None,
185            Some(def) => def.correct_responses_pattern(),
186        }
187    }
188
189    /// Convenience pass-through method to the `definition` field.
190    /// Return `choices` if set; `None` otherwise.
191    ///
192    /// When set, it's a vector of of [InteractionComponent]s representing the
193    /// correct response to the interaction.
194    ///
195    /// The contents of item(s) in the vector are specific to the given
196    /// _interaction type_.
197    pub fn choices(&self) -> Option<&Vec<InteractionComponent>> {
198        match &self.definition {
199            None => None,
200            Some(def) => def.choices(),
201        }
202    }
203
204    /// Convenience pass-through method to the `definition` field.
205    /// Return `scale` if set; `None` otherwise.
206    ///
207    /// When set, it's a vector of of [InteractionComponent]s representing the
208    /// correct response to the interaction.
209    ///
210    /// The contents of item(s) in the vector are specific to the given
211    /// _interaction type_.
212    pub fn scale(&self) -> Option<&Vec<InteractionComponent>> {
213        match &self.definition {
214            None => None,
215            Some(def) => def.scale(),
216        }
217    }
218
219    /// Convenience pass-through method to the `definition` field.
220    /// Return `source` if set; `None` otherwise.
221    ///
222    /// When set, it's a vector of of [InteractionComponent]s representing the
223    /// correct response to the interaction.
224    ///
225    /// The contents of item(s) in the vector are specific to the given
226    /// _interaction type_.
227    pub fn source(&self) -> Option<&Vec<InteractionComponent>> {
228        match &self.definition {
229            None => None,
230            Some(def) => def.source(),
231        }
232    }
233
234    /// Convenience pass-through method to the `definition` field.
235    /// Return `target` if set; `None` otherwise.
236    ///
237    /// When set, it's a vector of of [InteractionComponent]s representing the
238    /// correct response to the interaction.
239    ///
240    /// The contents of item(s) in the vector are specific to the given
241    /// _interaction type_.
242    pub fn target(&self) -> Option<&Vec<InteractionComponent>> {
243        match &self.definition {
244            None => None,
245            Some(def) => def.target(),
246        }
247    }
248
249    /// Convenience pass-through method to the `definition` field.
250    /// Return `steps` if set; `None` otherwise.
251    ///
252    /// When set, it's a vector of of [InteractionComponent]s representing the
253    /// correct response to the interaction.
254    ///
255    /// The contents of item(s) in the vector are specific to the given
256    /// _interaction type_.
257    pub fn steps(&self) -> Option<&Vec<InteractionComponent>> {
258        match &self.definition {
259            None => None,
260            Some(def) => def.steps(),
261        }
262    }
263
264    /// Convenience pass-through method to the `definition` field.
265    /// Return [Extensions] if set; `None` otherwise.
266    pub fn extensions(&self) -> Option<&Extensions> {
267        match &self.definition {
268            None => None,
269            Some(def) => def.extensions(),
270        }
271    }
272
273    /// Convenience pass-through method to the `definition` field.
274    /// Return extension keyed by `key` if it exists; `None` otherwise.
275    pub fn extension(&self, key: &IriStr) -> Option<&Value> {
276        match &self.definition {
277            None => None,
278            Some(def) => def.extension(key),
279        }
280    }
281
282    /// Ensure `object_type` field is set.
283    pub fn set_object_type(&mut self) {
284        self.object_type = Some(ObjectType::Activity);
285    }
286}
287
288impl fmt::Display for Activity {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        let mut vec = vec![];
291        vec.push(format!("id: \"{}\"", self.id));
292        if self.definition.is_some() {
293            vec.push(format!("definition: {}", self.definition.as_ref().unwrap()))
294        }
295        let res = vec
296            .iter()
297            .map(|x| x.to_string())
298            .collect::<Vec<_>>()
299            .join(", ");
300        write!(f, "Activity{{ {res} }}")
301    }
302}
303
304impl Fingerprint for Activity {
305    fn fingerprint<H: Hasher>(&self, state: &mut H) {
306        // discard `object_type`
307        let (x, y) = self.id.as_slice().to_absolute_and_fragment();
308        x.normalize().to_string().hash(state);
309        y.hash(state);
310        // exclude `definition`
311    }
312}
313
314impl Validate for Activity {
315    fn validate(&self) -> Vec<ValidationError> {
316        let mut vec = vec![];
317
318        if self.object_type.is_some() && *self.object_type.as_ref().unwrap() != ObjectType::Activity
319        {
320            vec.push(ValidationError::WrongObjectType {
321                expected: ObjectType::Activity,
322                found: self.object_type.as_ref().unwrap().to_string().into(),
323            })
324        }
325        if self.id.is_empty() {
326            vec.push(ValidationError::Empty("id".into()))
327        }
328        if self.definition.is_some() {
329            vec.extend(self.definition.as_ref().unwrap().validate());
330        }
331
332        vec
333    }
334}
335
336impl Canonical for Activity {
337    fn canonicalize(&mut self, language_tags: &[MyLanguageTag]) {
338        if self.definition.is_some() {
339            self.definition
340                .as_mut()
341                .unwrap()
342                .canonicalize(language_tags);
343        }
344    }
345}
346
347impl FromStr for Activity {
348    type Err = DataError;
349
350    fn from_str(s: &str) -> Result<Self, Self::Err> {
351        let x = serde_json::from_str::<Activity>(s)?;
352        x.check_validity()?;
353        Ok(x)
354    }
355}
356
357/// A Type that knows how to construct an [Activity].
358#[derive(Debug, Default)]
359pub struct ActivityBuilder<'a> {
360    _object_type: Option<ObjectType>,
361    _id: Option<&'a IriStr>,
362    _definition: Option<ActivityDefinition>,
363}
364
365impl<'a> ActivityBuilder<'a> {
366    /// Set `objectType` property.
367    pub fn with_object_type(mut self) -> Self {
368        self._object_type = Some(ObjectType::Activity);
369        self
370    }
371
372    /// Set the `id` field.
373    ///
374    /// Raise [DataError] if the input string is empty or is not a valid IRI.
375    pub fn id(mut self, val: &'a str) -> Result<Self, DataError> {
376        let id = val.trim();
377        if id.is_empty() {
378            emit_error!(DataError::Validation(ValidationError::Empty("id".into())))
379        } else {
380            let iri = IriStr::new(id)?;
381            assert!(
382                !iri.is_empty(),
383                "Activity identifier IRI should not be empty"
384            );
385            self._id = Some(iri);
386            Ok(self)
387        }
388    }
389
390    /// Set the `definition` field.
391    ///
392    /// Raise [DataError] if the argument is invalid.
393    pub fn definition(mut self, val: ActivityDefinition) -> Result<Self, DataError> {
394        val.check_validity()?;
395        self._definition = Some(val);
396        Ok(self)
397    }
398
399    /// Merge given definition w/ this one.
400    pub fn add_definition(mut self, val: ActivityDefinition) -> Result<Self, DataError> {
401        val.check_validity()?;
402        if self._definition.is_none() {
403            self._definition = Some(val)
404        } else {
405            let mut x = mem::take(&mut self._definition).unwrap();
406            x.merge(val);
407            let mut z = Some(x);
408            mem::swap(&mut self._definition, &mut z);
409        }
410        Ok(self)
411    }
412
413    /// Create an [Activity] instance from set field values.
414    ///
415    /// Raise [DataError] if the `id` field is missing.
416    pub fn build(self) -> Result<Activity, DataError> {
417        if self._id.is_none() {
418            emit_error!(DataError::Validation(ValidationError::MissingField(
419                "id".into()
420            )))
421        } else {
422            Ok(Activity {
423                object_type: self._object_type,
424                id: self._id.unwrap().to_owned(),
425                definition: self._definition,
426            })
427        }
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use std::collections::HashMap;
435    use tracing_test::traced_test;
436
437    #[traced_test]
438    #[test]
439    fn test_long_activity() {
440        const ROOM_KEY: &str =
441            "http://example.com/profiles/meetings/activitydefinitionextensions/room";
442        const JSON: &str = r#"{
443            "id": "http://www.example.com/meetings/occurances/34534",
444            "definition": {
445                "extensions": {
446                    "http://example.com/profiles/meetings/activitydefinitionextensions/room": {
447                        "name": "Kilby",
448                        "id": "http://example.com/rooms/342"
449                    }
450                },
451                "name": {
452                    "en-GB": "example meeting",
453                    "en-US": "example meeting"
454                },
455                "description": {
456                    "en-GB": "An example meeting that happened on a specific occasion with certain people present.",
457                    "en-US": "An example meeting that happened on a specific occasion with certain people present."
458                },
459                "type": "http://adlnet.gov/expapi/activities/meeting",
460                "moreInfo": "http://virtualmeeting.example.com/345256"
461            },
462            "objectType": "Activity"
463        }"#;
464
465        let room_iri = IriStr::new(ROOM_KEY).expect("Failed parsing IRI");
466        let de_result = serde_json::from_str::<Activity>(JSON);
467        assert!(de_result.is_ok());
468        let activity = de_result.unwrap();
469
470        let definition = activity.definition().unwrap();
471        assert!(definition.more_info().is_some());
472        assert_eq!(
473            definition.more_info().unwrap(),
474            "http://virtualmeeting.example.com/345256"
475        );
476
477        assert!(definition.extensions().is_some());
478        let ext = definition.extensions().unwrap();
479        assert!(ext.contains_key(room_iri));
480
481        // let room_info = ext.get(ROOM_KEY).unwrap();
482        let room_info = ext.get(room_iri).unwrap();
483        let room = serde_json::from_value::<HashMap<String, String>>(room_info.clone()).unwrap();
484        assert!(room.contains_key("name"));
485        assert_eq!(room.get("name"), Some(&String::from("Kilby")));
486        assert!(room.contains_key("id"));
487        assert_eq!(
488            room.get("id"),
489            Some(&String::from("http://example.com/rooms/342"))
490        );
491    }
492
493    #[traced_test]
494    #[test]
495    fn test_merge() -> Result<(), DataError> {
496        const XT_LOCATION: &str = "http://example.com/xt/meeting/location";
497        const XT_REPORTER: &str = "http://example.com/xt/meeting/reporter";
498        const MORE_INFO: &str = "http://virtualmeeting.example.com/345256";
499        const V1: &str = r#"{
500            "id": "http://www.example.com/test",
501            "definition": {
502                "name": {
503                    "en-GB": "attended",
504                    "en-US": "attended"
505                },
506                "description": {
507                    "en-US": "On this map, please mark Franklin, TN"
508                },
509                "type": "http://adlnet.gov/expapi/activities/cmi.interaction",
510                "moreInfo": "http://virtualmeeting.example.com/345256",
511                "interactionType": "other"
512            }
513        }"#;
514        const V2: &str = r#"{
515            "objectType": "Activity",
516            "id": "http://www.example.com/test",
517            "definition": {
518                "name": {
519                    "en": "Other",
520                    "ja-JP": "出席した",
521                    "ko-KR": "참석",
522                    "is-IS": "sótti",
523                    "ru-RU": "участие",
524                    "pa-IN": "ਹਾਜ਼ਰ",
525                    "sk-SK": "zúčastnil",
526                    "ar-EG": "حضر"
527                },
528                "extensions": {
529                    "http://example.com/xt/meeting/location": "X:\\meetings\\minutes\\examplemeeting.one"
530                }
531            }
532        }"#;
533        const V3: &str = r#"{
534            "id": "http://www.example.com/test",
535            "definition": {
536                "correctResponsesPattern": [ "(35.937432,-86.868896)" ],
537                "extensions": {
538                    "http://example.com/xt/meeting/reporter": {
539                        "name": "Thomas",
540                        "id": "http://openid.com/342"
541                    }
542                }
543            }
544        }"#;
545
546        let location_iri = IriStr::new(XT_LOCATION).expect("Failed parsing XT_LOCATION IRI");
547        let reporter_iri = IriStr::new(XT_REPORTER).expect("Failed parsing XT_REPORTER IRI");
548
549        let en = MyLanguageTag::from_str("en-GB")?;
550        let ko = MyLanguageTag::from_str("ko-KR")?;
551
552        let mut v1 = serde_json::from_str::<Activity>(V1).unwrap();
553        let v2 = serde_json::from_str::<Activity>(V2).unwrap();
554        let v3 = serde_json::from_str::<Activity>(V3).unwrap();
555
556        v1.merge(v2);
557
558        // should still find all V1 elements...
559        assert_eq!(
560            v1.definition()
561                .unwrap()
562                .more_info()
563                .expect("Failed finding `more_info` after merging V2"),
564            MORE_INFO
565        );
566        assert_eq!(v1.definition().unwrap().name(&en).unwrap(), "attended");
567
568        // ... as well entries in augmented `name`...
569        assert_eq!(v1.definition().unwrap().name(&ko).unwrap(), "참석");
570        // ...and new ones that didn't exist before the merge...
571        assert_eq!(v1.definition().unwrap().extensions().unwrap().len(), 1);
572        assert!(
573            v1.definition()
574                .unwrap()
575                .extensions()
576                .unwrap()
577                .contains_key(location_iri)
578        );
579
580        v1.merge(v3);
581
582        assert_eq!(v1.definition().unwrap().extensions().unwrap().len(), 2);
583        assert!(
584            v1.definition()
585                .unwrap()
586                .extensions()
587                .unwrap()
588                .contains_key(reporter_iri)
589        );
590
591        Ok(())
592    }
593
594    #[test]
595    fn test_validity() {
596        const BAD: &str = r#"{"objectType":"Activity","id":"http://www.example.com/meetings/categories/teammeeting","definition":{"name":{"en":"Fill-In"},"description":{"en":"Ben is often heard saying:"},"type":"http://adlnet.gov/expapi/activities/cmi.interaction","moreInfo":"http://virtualmeeting.example.com/345256","correctResponsesPattern":["Bob's your uncle"],"extensions":{"http://example.com/profiles/meetings/extension/location":"X:\\\\meetings\\\\minutes\\\\examplemeeting.one","http://example.com/profiles/meetings/extension/reporter":{"name":"Thomas","id":"http://openid.com/342"}}}}"#;
597
598        // deserializing w/ serde works and yields an Activity instance...
599        let res = serde_json::from_str::<Activity>(BAD);
600        assert!(res.is_ok());
601        // the instance however is invalid b/c missing 'interactionType'
602        let act = res.unwrap();
603        assert!(!act.is_valid());
604
605        // on the other hand, using from_str raises an error as expected...
606        let res = Activity::from_str(BAD);
607        assert!(res.is_err());
608    }
609
610    #[test]
611    fn test_merge_definition() -> Result<(), DataError> {
612        const A1: &str = r#"{
613"objectType":"Activity",
614"id":"http://www.xapi.net/activity/12345",
615"definition":{
616  "type":"http://adlnet.gov/expapi/activities/meeting",
617  "name":{"en-GB":"meeting","en-US":"meeting"},
618  "description":{"en-US":"A past meeting."},
619  "moreInfo":"https://xapi.net/more/345256",
620  "extensions":{
621    "http://example.com/profiles/meetings/extension/location":"X:\\\\meetings\\\\minutes\\\\examplemeeting.one",
622    "http://example.com/profiles/meetings/extension/reporter":{"name":"Larry","id":"http://openid.com/342"}
623  }
624}}"#;
625        const A2: &str = r#"{
626"objectType":"Activity",
627"id":"http://www.xapi.net/activity/12345",
628"definition":{
629  "type":"http://adlnet.gov/expapi/activities/meeting",
630  "name":{"en-GB":"meeting","fr-FR":"réunion"},
631  "description":{"en-GB":"A past meeting."},
632  "moreInfo":"https://xapi.net/more/345256",
633  "extensions":{
634    "http://example.com/profiles/meetings/extension/location":"X:\\\\meetings\\\\minutes\\\\examplemeeting.one",
635    "http://example.com/profiles/meetings/extension/editor":{"name":"Curly","id":"http://openid.com/342"}
636  }
637}}"#;
638        let en = MyLanguageTag::from_str("en-GB")?;
639        let am = MyLanguageTag::from_str("en-US")?;
640        let fr = MyLanguageTag::from_str("fr-FR")?;
641
642        let mut a1 = Activity::from_str(A1).unwrap();
643        assert_eq!(a1.name(&en), Some("meeting"));
644        assert_eq!(a1.name(&am), Some("meeting"));
645        assert!(a1.name(&fr).is_none());
646        assert_eq!(a1.description(&am), Some("A past meeting."));
647        assert!(a1.description(&en).is_none());
648        assert_eq!(a1.extensions().map_or(0, |x| x.len()), 2);
649
650        let a2 = Activity::from_str(A2).unwrap();
651        assert_eq!(a2.name(&en), Some("meeting"));
652        assert_eq!(a2.name(&fr), Some("réunion"));
653        assert!(a2.name(&am).is_none());
654        assert_eq!(a2.description(&en), Some("A past meeting."));
655        assert!(a2.description(&am).is_none());
656        assert_eq!(a2.extensions().map_or(0, |x| x.len()), 2);
657
658        a1.merge(a2);
659        assert_eq!(a1.name(&en), Some("meeting"));
660        assert_eq!(a1.name(&am), Some("meeting"));
661        assert_eq!(a1.name(&fr), Some("réunion"));
662        assert_eq!(a1.description(&am), Some("A past meeting."));
663        assert_eq!(a1.description(&en), Some("A past meeting."));
664        assert_eq!(a1.extensions().map_or(0, |x| x.len()), 3);
665
666        Ok(())
667    }
668}