Skip to main content

xapi_data/
activity.rs

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