Skip to main content

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 let Some(mut z_other_definition) = other.definition {
99                    let x = mem::take(&mut z_other_definition);
100                    let mut z = Some(x);
101                    mem::swap(&mut self.definition, &mut z);
102                }
103            } else if let Some(y) = other.definition {
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 let Some(z_definition) = self.definition.as_ref() {
293            vec.push(format!("definition: {}", z_definition))
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        if let Some(z_object_type) = self.object_type.as_ref()
318            && *z_object_type != ObjectType::Activity
319        {
320            vec.push(ValidationError::WrongObjectType {
321                expected: ObjectType::Activity,
322                found: z_object_type.to_string().into(),
323            })
324        }
325
326        if self.id.is_empty() {
327            vec.push(ValidationError::Empty("id".into()))
328        }
329        if let Some(z_definition) = self.definition.as_ref() {
330            vec.extend(z_definition.validate());
331        }
332
333        vec
334    }
335}
336
337impl Canonical for Activity {
338    fn canonicalize(&mut self, language_tags: &[MyLanguageTag]) {
339        if let Some(z_definition) = &mut self.definition {
340            z_definition.canonicalize(language_tags);
341        }
342    }
343}
344
345impl FromStr for Activity {
346    type Err = DataError;
347
348    fn from_str(s: &str) -> Result<Self, Self::Err> {
349        let x = serde_json::from_str::<Activity>(s)?;
350        x.check_validity()?;
351        Ok(x)
352    }
353}
354
355/// A Type that knows how to construct an [Activity].
356#[derive(Debug, Default)]
357pub struct ActivityBuilder<'a> {
358    _object_type: Option<ObjectType>,
359    _id: Option<&'a IriStr>,
360    _definition: Option<ActivityDefinition>,
361}
362
363impl<'a> ActivityBuilder<'a> {
364    /// Set `objectType` property.
365    pub fn with_object_type(mut self) -> Self {
366        self._object_type = Some(ObjectType::Activity);
367        self
368    }
369
370    /// Set the `id` field.
371    ///
372    /// Raise [DataError] if the input string is empty or is not a valid IRI.
373    pub fn id(mut self, val: &'a str) -> Result<Self, DataError> {
374        let id = val.trim();
375        if id.is_empty() {
376            emit_error!(DataError::Validation(ValidationError::Empty("id".into())))
377        } else {
378            let iri = IriStr::new(id)?;
379            assert!(
380                !iri.is_empty(),
381                "Activity identifier IRI should not be empty"
382            );
383            self._id = Some(iri);
384            Ok(self)
385        }
386    }
387
388    /// Set the `definition` field.
389    ///
390    /// Raise [DataError] if the argument is invalid.
391    pub fn definition(mut self, val: ActivityDefinition) -> Result<Self, DataError> {
392        val.check_validity()?;
393        self._definition = Some(val);
394        Ok(self)
395    }
396
397    /// Merge given definition w/ this one.
398    pub fn add_definition(mut self, val: ActivityDefinition) -> Result<Self, DataError> {
399        val.check_validity()?;
400        if self._definition.is_none() {
401            self._definition = Some(val)
402        } else {
403            let mut x = mem::take(&mut self._definition).unwrap();
404            x.merge(val);
405            let mut z = Some(x);
406            mem::swap(&mut self._definition, &mut z);
407        }
408        Ok(self)
409    }
410
411    /// Create an [Activity] instance from set field values.
412    ///
413    /// Raise [DataError] if the `id` field is missing.
414    pub fn build(self) -> Result<Activity, DataError> {
415        if let Some(z_id) = self._id {
416            Ok(Activity {
417                object_type: self._object_type,
418                id: z_id.to_owned(),
419                definition: self._definition,
420            })
421        } else {
422            emit_error!(DataError::Validation(ValidationError::MissingField(
423                "id".into()
424            )))
425        }
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use std::collections::HashMap;
433    use tracing_test::traced_test;
434
435    #[traced_test]
436    #[test]
437    fn test_long_activity() {
438        const ROOM_KEY: &str =
439            "http://example.com/profiles/meetings/activitydefinitionextensions/room";
440        const JSON: &str = r#"{
441            "id": "http://www.example.com/meetings/occurances/34534",
442            "definition": {
443                "extensions": {
444                    "http://example.com/profiles/meetings/activitydefinitionextensions/room": {
445                        "name": "Kilby",
446                        "id": "http://example.com/rooms/342"
447                    }
448                },
449                "name": {
450                    "en-GB": "example meeting",
451                    "en-US": "example meeting"
452                },
453                "description": {
454                    "en-GB": "An example meeting that happened on a specific occasion with certain people present.",
455                    "en-US": "An example meeting that happened on a specific occasion with certain people present."
456                },
457                "type": "http://adlnet.gov/expapi/activities/meeting",
458                "moreInfo": "http://virtualmeeting.example.com/345256"
459            },
460            "objectType": "Activity"
461        }"#;
462
463        let room_iri = IriStr::new(ROOM_KEY).expect("Failed parsing IRI");
464        let de_result = serde_json::from_str::<Activity>(JSON);
465        assert!(de_result.is_ok());
466        let activity = de_result.unwrap();
467
468        let definition = activity.definition().unwrap();
469        assert!(definition.more_info().is_some());
470        assert_eq!(
471            definition.more_info().unwrap(),
472            "http://virtualmeeting.example.com/345256"
473        );
474
475        assert!(definition.extensions().is_some());
476        let ext = definition.extensions().unwrap();
477        assert!(ext.contains_key(room_iri));
478
479        // let room_info = ext.get(ROOM_KEY).unwrap();
480        let room_info = ext.get(room_iri).unwrap();
481        let room = serde_json::from_value::<HashMap<String, String>>(room_info.clone()).unwrap();
482        assert!(room.contains_key("name"));
483        assert_eq!(room.get("name"), Some(&String::from("Kilby")));
484        assert!(room.contains_key("id"));
485        assert_eq!(
486            room.get("id"),
487            Some(&String::from("http://example.com/rooms/342"))
488        );
489    }
490
491    #[traced_test]
492    #[test]
493    fn test_merge() -> Result<(), DataError> {
494        const XT_LOCATION: &str = "http://example.com/xt/meeting/location";
495        const XT_REPORTER: &str = "http://example.com/xt/meeting/reporter";
496        const MORE_INFO: &str = "http://virtualmeeting.example.com/345256";
497        const V1: &str = r#"{
498            "id": "http://www.example.com/test",
499            "definition": {
500                "name": {
501                    "en-GB": "attended",
502                    "en-US": "attended"
503                },
504                "description": {
505                    "en-US": "On this map, please mark Franklin, TN"
506                },
507                "type": "http://adlnet.gov/expapi/activities/cmi.interaction",
508                "moreInfo": "http://virtualmeeting.example.com/345256",
509                "interactionType": "other"
510            }
511        }"#;
512        const V2: &str = r#"{
513            "objectType": "Activity",
514            "id": "http://www.example.com/test",
515            "definition": {
516                "name": {
517                    "en": "Other",
518                    "ja-JP": "出席した",
519                    "ko-KR": "참석",
520                    "is-IS": "sótti",
521                    "ru-RU": "участие",
522                    "pa-IN": "ਹਾਜ਼ਰ",
523                    "sk-SK": "zúčastnil",
524                    "ar-EG": "حضر"
525                },
526                "extensions": {
527                    "http://example.com/xt/meeting/location": "X:\\meetings\\minutes\\examplemeeting.one"
528                }
529            }
530        }"#;
531        const V3: &str = r#"{
532            "id": "http://www.example.com/test",
533            "definition": {
534                "correctResponsesPattern": [ "(35.937432,-86.868896)" ],
535                "extensions": {
536                    "http://example.com/xt/meeting/reporter": {
537                        "name": "Thomas",
538                        "id": "http://openid.com/342"
539                    }
540                }
541            }
542        }"#;
543
544        let location_iri = IriStr::new(XT_LOCATION).expect("Failed parsing XT_LOCATION IRI");
545        let reporter_iri = IriStr::new(XT_REPORTER).expect("Failed parsing XT_REPORTER IRI");
546
547        let en = MyLanguageTag::from_str("en-GB")?;
548        let ko = MyLanguageTag::from_str("ko-KR")?;
549
550        let mut v1 = serde_json::from_str::<Activity>(V1).unwrap();
551        let v2 = serde_json::from_str::<Activity>(V2).unwrap();
552        let v3 = serde_json::from_str::<Activity>(V3).unwrap();
553
554        v1.merge(v2);
555
556        // should still find all V1 elements...
557        assert_eq!(
558            v1.definition()
559                .unwrap()
560                .more_info()
561                .expect("Failed finding `more_info` after merging V2"),
562            MORE_INFO
563        );
564        assert_eq!(v1.definition().unwrap().name(&en).unwrap(), "attended");
565
566        // ... as well entries in augmented `name`...
567        assert_eq!(v1.definition().unwrap().name(&ko).unwrap(), "참석");
568        // ...and new ones that didn't exist before the merge...
569        assert_eq!(v1.definition().unwrap().extensions().unwrap().len(), 1);
570        assert!(
571            v1.definition()
572                .unwrap()
573                .extensions()
574                .unwrap()
575                .contains_key(location_iri)
576        );
577
578        v1.merge(v3);
579
580        assert_eq!(v1.definition().unwrap().extensions().unwrap().len(), 2);
581        assert!(
582            v1.definition()
583                .unwrap()
584                .extensions()
585                .unwrap()
586                .contains_key(reporter_iri)
587        );
588
589        Ok(())
590    }
591
592    #[test]
593    fn test_validity() {
594        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"}}}}"#;
595
596        // deserializing w/ serde works and yields an Activity instance...
597        let res = serde_json::from_str::<Activity>(BAD);
598        assert!(res.is_ok());
599        // the instance however is invalid b/c missing 'interactionType'
600        let act = res.unwrap();
601        assert!(!act.is_valid());
602
603        // on the other hand, using from_str raises an error as expected...
604        let res = Activity::from_str(BAD);
605        assert!(res.is_err());
606    }
607
608    #[test]
609    fn test_merge_definition() -> Result<(), DataError> {
610        const A1: &str = r#"{
611"objectType":"Activity",
612"id":"http://www.xapi.net/activity/12345",
613"definition":{
614  "type":"http://adlnet.gov/expapi/activities/meeting",
615  "name":{"en-GB":"meeting","en-US":"meeting"},
616  "description":{"en-US":"A past meeting."},
617  "moreInfo":"https://xapi.net/more/345256",
618  "extensions":{
619    "http://example.com/profiles/meetings/extension/location":"X:\\\\meetings\\\\minutes\\\\examplemeeting.one",
620    "http://example.com/profiles/meetings/extension/reporter":{"name":"Larry","id":"http://openid.com/342"}
621  }
622}}"#;
623        const A2: &str = r#"{
624"objectType":"Activity",
625"id":"http://www.xapi.net/activity/12345",
626"definition":{
627  "type":"http://adlnet.gov/expapi/activities/meeting",
628  "name":{"en-GB":"meeting","fr-FR":"réunion"},
629  "description":{"en-GB":"A past meeting."},
630  "moreInfo":"https://xapi.net/more/345256",
631  "extensions":{
632    "http://example.com/profiles/meetings/extension/location":"X:\\\\meetings\\\\minutes\\\\examplemeeting.one",
633    "http://example.com/profiles/meetings/extension/editor":{"name":"Curly","id":"http://openid.com/342"}
634  }
635}}"#;
636        let en = MyLanguageTag::from_str("en-GB")?;
637        let am = MyLanguageTag::from_str("en-US")?;
638        let fr = MyLanguageTag::from_str("fr-FR")?;
639
640        let mut a1 = Activity::from_str(A1).unwrap();
641        assert_eq!(a1.name(&en), Some("meeting"));
642        assert_eq!(a1.name(&am), Some("meeting"));
643        assert!(a1.name(&fr).is_none());
644        assert_eq!(a1.description(&am), Some("A past meeting."));
645        assert!(a1.description(&en).is_none());
646        assert_eq!(a1.extensions().map_or(0, |x| x.len()), 2);
647
648        let a2 = Activity::from_str(A2).unwrap();
649        assert_eq!(a2.name(&en), Some("meeting"));
650        assert_eq!(a2.name(&fr), Some("réunion"));
651        assert!(a2.name(&am).is_none());
652        assert_eq!(a2.description(&en), Some("A past meeting."));
653        assert!(a2.description(&am).is_none());
654        assert_eq!(a2.extensions().map_or(0, |x| x.len()), 2);
655
656        a1.merge(a2);
657        assert_eq!(a1.name(&en), Some("meeting"));
658        assert_eq!(a1.name(&am), Some("meeting"));
659        assert_eq!(a1.name(&fr), Some("réunion"));
660        assert_eq!(a1.description(&am), Some("A past meeting."));
661        assert_eq!(a1.description(&en), Some("A past meeting."));
662        assert_eq!(a1.extensions().map_or(0, |x| x.len()), 3);
663
664        Ok(())
665    }
666}