use crate::{
Actor, ActorId, Attachment, Context, ContextId, DataError, Fingerprint, MyTimestamp, MyVersion,
StatementObject, StatementObjectId, Validate, ValidationError, Verb, VerbId, XResult,
check_for_nulls, emit_error, fingerprint_it, statement_type::StatementType, stored_ser,
};
use chrono::{DateTime, SecondsFormat, Utc};
use core::fmt;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use serde_with::skip_serializing_none;
use std::{hash::Hasher, str::FromStr};
use uuid::Uuid;
#[skip_serializing_none]
#[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Statement {
id: Option<Uuid>,
actor: Actor,
verb: Verb,
object: StatementObject,
result: Option<XResult>,
context: Option<Context>,
timestamp: Option<MyTimestamp>,
#[serde(serialize_with = "stored_ser")]
stored: Option<DateTime<Utc>>,
authority: Option<Actor>,
version: Option<MyVersion>,
attachments: Option<Vec<Attachment>>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize)]
#[doc(hidden)]
pub struct StatementId {
id: Option<Uuid>,
actor: ActorId,
verb: VerbId,
object: StatementObjectId,
result: Option<XResult>,
context: Option<ContextId>,
timestamp: Option<MyTimestamp>,
#[serde(serialize_with = "stored_ser")]
stored: Option<DateTime<Utc>>,
authority: Option<ActorId>,
version: Option<MyVersion>,
attachments: Option<Vec<Attachment>>,
}
impl From<Statement> for StatementId {
fn from(value: Statement) -> Self {
StatementId {
id: value.id,
actor: ActorId::from(value.actor),
verb: value.verb.into(),
object: StatementObjectId::from(value.object),
result: value.result,
context: value.context.map(ContextId::from),
timestamp: value.timestamp,
stored: value.stored,
authority: value.authority.map(ActorId::from),
version: value.version,
attachments: value.attachments,
}
}
}
impl From<Box<Statement>> for StatementId {
fn from(value: Box<Statement>) -> Self {
StatementId {
id: value.id,
actor: ActorId::from(value.actor),
verb: value.verb.into(),
object: StatementObjectId::from(value.object),
result: value.result,
context: value.context.map(ContextId::from),
timestamp: value.timestamp,
stored: value.stored,
authority: value.authority.map(ActorId::from),
version: value.version,
attachments: value.attachments,
}
}
}
impl From<StatementId> for Statement {
fn from(value: StatementId) -> Self {
Statement {
id: value.id,
actor: Actor::from(value.actor),
verb: Verb::from(value.verb),
object: StatementObject::from(value.object),
result: value.result,
context: value.context.map(Context::from),
timestamp: value.timestamp,
stored: value.stored,
authority: value.authority.map(Actor::from),
version: value.version,
attachments: value.attachments,
}
}
}
impl From<Box<StatementId>> for Statement {
fn from(value: Box<StatementId>) -> Self {
Statement {
id: value.id,
actor: Actor::from(value.actor),
verb: Verb::from(value.verb),
object: StatementObject::from(value.object),
result: value.result,
context: value.context.map(Context::from),
timestamp: value.timestamp,
stored: value.stored,
authority: value.authority.map(Actor::from),
version: value.version,
attachments: value.attachments,
}
}
}
impl Statement {
pub fn from_json_obj(map: Map<String, Value>) -> Result<Self, DataError> {
for (k, v) in &map {
if v.is_null() {
emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
format!("Key '{k}' is null").into()
)))
} else if k != "extensions" {
check_for_nulls(v)?
}
}
let stmt: Statement = serde_json::from_value(Value::Object(map.to_owned()))?;
stmt.check_validity()?;
Ok(stmt)
}
pub fn builder() -> StatementBuilder {
StatementBuilder::default()
}
pub fn id(&self) -> Option<&Uuid> {
self.id.as_ref()
}
pub fn set_id(&mut self, id: Uuid) {
self.id = Some(id)
}
pub fn actor(&self) -> &Actor {
&self.actor
}
pub fn verb(&self) -> &Verb {
&self.verb
}
pub fn is_verb_voided(&self) -> bool {
self.verb.is_voided()
}
pub fn object(&self) -> &StatementObject {
&self.object
}
pub fn voided_target(&self) -> Option<Uuid> {
if self.is_verb_voided() && self.object.is_statement_ref() {
Some(
*self
.object
.as_statement_ref()
.expect("Failed coercing object to StatementRef")
.id(),
)
} else {
None
}
}
pub fn result(&self) -> Option<&XResult> {
self.result.as_ref()
}
pub fn context(&self) -> Option<&Context> {
self.context.as_ref()
}
pub fn timestamp(&self) -> Option<&DateTime<Utc>> {
if let Some(z_timestamp) = self.timestamp.as_ref() {
Some(z_timestamp.inner())
} else {
None
}
}
pub fn timestamp_internal(&self) -> Option<&MyTimestamp> {
self.timestamp.as_ref()
}
pub fn stored(&self) -> Option<&DateTime<Utc>> {
self.stored.as_ref()
}
pub fn set_stored(&mut self, val: DateTime<Utc>) {
self.stored = Some(val);
}
pub fn authority(&self) -> Option<&Actor> {
self.authority.as_ref()
}
pub fn set_authority_unchecked(&mut self, actor: Actor) {
self.authority = Some(actor)
}
pub fn version(&self) -> Option<&MyVersion> {
if let Some(z_version) = self.version.as_ref() {
Some(z_version)
} else {
None
}
}
pub fn attachments(&self) -> &[Attachment] {
match &self.attachments {
Some(x) => x,
None => &[],
}
}
pub fn attachments_mut(&mut self) -> &mut [Attachment] {
if self.attachments.is_some() {
self.attachments.as_deref_mut().unwrap()
} else {
&mut []
}
}
pub fn set_attachments(&mut self, attachments: Vec<Attachment>) {
self.attachments = Some(attachments)
}
pub fn print(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|_| String::from("$Statement"))
}
pub fn uid(&self) -> u64 {
fingerprint_it(self)
}
pub fn equivalent(&self, that: &Statement) -> bool {
self.uid() == that.uid()
}
}
impl StatementId {
#[allow(dead_code)]
pub(crate) fn stored(&self) -> Option<&DateTime<Utc>> {
self.stored.as_ref()
}
#[allow(dead_code)]
pub(crate) fn attachments(&self) -> &[Attachment] {
match &self.attachments {
Some(x) => x,
None => &[],
}
}
}
impl Fingerprint for Statement {
#[allow(clippy::let_unit_value)]
fn fingerprint<H: Hasher>(&self, state: &mut H) {
self.actor.fingerprint(state);
self.verb.fingerprint(state);
self.object.fingerprint(state);
let _ = self.context().map_or((), |x| x.fingerprint(state));
let _ = self.result().map_or((), |x| x.fingerprint(state));
}
}
impl fmt::Display for Statement {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut vec = vec![];
if self.id().is_some() {
vec.push(format!(
"id: \"{}\"",
self.id
.as_ref()
.unwrap()
.hyphenated()
.encode_lower(&mut Uuid::encode_buffer())
));
}
vec.push(format!("actor: {}", self.actor));
vec.push(format!("verb: {}", self.verb));
vec.push(format!("object: {}", self.object));
if let Some(z_result) = self.result.as_ref() {
vec.push(format!("result: {}", z_result))
}
if let Some(z_context) = self.context.as_ref() {
vec.push(format!("context: {}", z_context))
}
if let Some(z_timestamp) = self.timestamp.as_ref() {
vec.push(format!("timestamp: \"{}\"", z_timestamp))
}
if let Some(ts) = self.stored.as_ref() {
vec.push(format!(
"stored: \"{}\"",
ts.to_rfc3339_opts(SecondsFormat::Millis, true)
))
}
if let Some(z_authority) = self.authority.as_ref() {
vec.push(format!("authority: {}", z_authority))
}
if let Some(z_version) = self.version.as_ref() {
vec.push(format!("version: \"{}\"", z_version))
}
if self.attachments.is_some() {
let items = self.attachments.as_deref().unwrap();
vec.push(format!(
"attachments: [{}]",
items
.iter()
.map(|x| x.to_string())
.collect::<Vec<_>>()
.join(", ")
))
}
let res = vec
.iter()
.map(|x| x.to_string())
.collect::<Vec<_>>()
.join(", ");
write!(f, "Statement{{ {res} }}")
}
}
impl Validate for Statement {
fn validate(&self) -> Vec<ValidationError> {
let mut vec = vec![];
if self.id.is_some()
&& (self.id.as_ref().unwrap().is_nil() || self.id.as_ref().unwrap().is_max())
{
vec.push(ValidationError::ConstraintViolation(
"'id' must not be all 0's or 1's".into(),
))
}
vec.extend(self.actor.validate());
vec.extend(self.verb.validate());
vec.extend(self.object.validate());
if let Some(z_result) = self.result.as_ref() {
vec.extend(z_result.validate())
}
if let Some(z_context) = self.context.as_ref() {
vec.extend(z_context.validate());
if !self.object().is_activity()
&& (self.context().as_ref().unwrap().revision().is_some()
|| self.context().as_ref().unwrap().platform().is_some())
{
vec.push(ValidationError::ConstraintViolation(
"Statement context w/ revision | platform but object != Activity".into(),
))
}
}
if let Some(z_authority) = self.authority.as_ref() {
vec.extend(z_authority.validate());
if z_authority.is_group() {
let group = z_authority.as_group().unwrap();
if !group.is_anonymous() {
vec.push(ValidationError::ConstraintViolation(
"When used as an Authority, A Group must be anonymous".into(),
))
}
if group.members().len() != 2 {
vec.push(ValidationError::ConstraintViolation(
"When used as an Authority, an anonymous Group must have 2 members only"
.into(),
))
}
}
}
if let Some(z_version) = self.version.as_ref() {
vec.extend(z_version.validate())
}
if let Some(z_attachments) = self.attachments.as_ref() {
for att in z_attachments.iter() {
vec.extend(att.validate())
}
}
vec
}
}
impl FromStr for Statement {
type Err = DataError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let map: Map<String, Value> = serde_json::from_str(s)?;
Self::from_json_obj(map)
}
}
impl TryFrom<StatementType> for Statement {
type Error = DataError;
fn try_from(value: StatementType) -> Result<Self, Self::Error> {
match value {
StatementType::S(x) => Ok(*x),
StatementType::SId(x) => Ok(Statement::from(x)),
_ => Err(DataError::Validation(ValidationError::ConstraintViolation(
"Not a Statement".into(),
))),
}
}
}
impl TryFrom<StatementType> for StatementId {
type Error = DataError;
fn try_from(value: StatementType) -> Result<Self, Self::Error> {
match value {
StatementType::S(x) => Ok(StatementId::from(x)),
StatementType::SId(x) => Ok(*x),
_ => Err(DataError::Validation(ValidationError::ConstraintViolation(
"Not a StatementId".into(),
))),
}
}
}
#[derive(Debug, Default)]
pub struct StatementBuilder {
_id: Option<Uuid>,
_actor: Option<Actor>,
_verb: Option<Verb>,
_object: Option<StatementObject>,
_result: Option<XResult>,
_context: Option<Context>,
_timestamp: Option<MyTimestamp>,
_stored: Option<DateTime<Utc>>,
_authority: Option<Actor>,
_version: Option<MyVersion>,
_attachments: Option<Vec<Attachment>>,
}
impl StatementBuilder {
pub fn id(self, val: &str) -> Result<Self, DataError> {
let val = val.trim();
if val.is_empty() {
emit_error!(DataError::Validation(ValidationError::Empty("id".into())))
} else {
let uuid = Uuid::parse_str(val)?;
self.id_as_uuid(uuid)
}
}
pub fn id_as_uuid(mut self, uuid: Uuid) -> Result<Self, DataError> {
if uuid.is_nil() || uuid.is_max() {
emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
"'id' should not be all 0's or 1's".into()
)))
} else {
self._id = Some(uuid);
Ok(self)
}
}
pub fn actor(mut self, val: Actor) -> Result<Self, DataError> {
val.check_validity()?;
self._actor = Some(val);
Ok(self)
}
pub fn verb(mut self, val: Verb) -> Result<Self, DataError> {
val.check_validity()?;
self._verb = Some(val);
Ok(self)
}
pub fn object(mut self, val: StatementObject) -> Result<Self, DataError> {
val.check_validity()?;
self._object = Some(val);
Ok(self)
}
pub fn result(mut self, val: XResult) -> Result<Self, DataError> {
val.check_validity()?;
self._result = Some(val);
Ok(self)
}
pub fn context(mut self, val: Context) -> Result<Self, DataError> {
val.check_validity()?;
self._context = Some(val);
Ok(self)
}
pub fn timestamp(mut self, val: &str) -> Result<Self, DataError> {
let val = val.trim();
if val.is_empty() {
emit_error!(DataError::Validation(ValidationError::Empty(
"timestamp".into()
)))
}
let ts = MyTimestamp::from_str(val)?;
self._timestamp = Some(ts);
Ok(self)
}
pub fn with_timestamp(mut self, val: DateTime<Utc>) -> Self {
self._timestamp = Some(MyTimestamp::from(val));
self
}
pub fn stored(mut self, val: &str) -> Result<Self, DataError> {
let val = val.trim();
if val.is_empty() {
emit_error!(DataError::Validation(ValidationError::Empty(
"stored".into()
)))
}
let ts = serde_json::from_str::<MyTimestamp>(val)?;
self._stored = Some(*ts.inner());
Ok(self)
}
pub fn with_stored(mut self, val: DateTime<Utc>) -> Self {
self._stored = Some(val);
self
}
pub fn authority(mut self, val: Actor) -> Result<Self, DataError> {
val.check_validity()?;
if val.is_group() {
let group = val.as_group().unwrap();
if !group.is_anonymous() {
emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
"When used as an Authority, a Group must be anonymous".into()
)))
}
if group.members().len() != 2 {
emit_error!(DataError::Validation(ValidationError::ConstraintViolation(
"When used as an Authority, an anonymous Group must have 2 members only".into()
)))
}
}
self._authority = Some(val);
Ok(self)
}
pub fn version(mut self, val: &str) -> Result<Self, DataError> {
self._version = Some(MyVersion::from_str(val)?);
Ok(self)
}
pub fn attachment(mut self, att: Attachment) -> Result<Self, DataError> {
att.check_validity()?;
if self._attachments.is_none() {
self._attachments = Some(vec![])
}
self._attachments.as_mut().unwrap().push(att);
Ok(self)
}
pub fn build(self) -> Result<Statement, DataError> {
if self._actor.is_none() || self._verb.is_none() || self._object.is_none() {
emit_error!(DataError::Validation(ValidationError::MissingField(
"actor, verb, or object".into()
)))
}
Ok(Statement {
id: self._id,
actor: self._actor.unwrap(),
verb: self._verb.unwrap(),
object: self._object.unwrap(),
result: self._result,
context: self._context,
timestamp: self._timestamp,
stored: self._stored,
authority: self._authority,
version: self._version,
attachments: self._attachments,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Map, Value};
use tracing_test::traced_test;
#[traced_test]
#[test]
#[should_panic]
fn test_extra_properties() {
const S: &str = r#"{
"actor":{"objectType":"Agent","name":"xAPI mbox","mbox":"mailto:xapi@adlnet.gov"},
"verb":{"id":"http://adlnet.gov/expapi/verbs/attended","display":{"en-US":"attended"}},
"object":{"objectType":"Activity","id":"http://www.example.com/meetings/occurances/34534"},
"iD":"46bf512f-56ec-45ef-8f95-1f4b352386e6"}"#;
let map: Map<String, Value> = serde_json::from_str(S).unwrap();
assert!(!map.contains_key("id"));
assert!(!map.contains_key("ID"));
assert!(!map.contains_key("Id"));
assert!(map.contains_key("iD"));
let s = serde_json::from_value::<Statement>(Value::Object(map));
assert!(s.is_err());
Statement::from_str(S).unwrap();
}
#[traced_test]
#[test]
fn test_extensions_w_nulls() {
const S: &str = r#"{
"actor":{"objectType":"Agent","name":"xAPI account","mbox":"mailto:xapi@adlnet.gov"},
"verb":{"id":"http://adlnet.gov/expapi/verbs/attended","display":{"en-GB":"attended"}},
"object":{
"objectType":"Activity",
"id":"http://www.example.com/meetings/occurances/34534",
"definition":{
"type":"http://adlnet.gov/expapi/activities/meeting",
"name":{"en-GB":"example meeting","en-US":"example meeting"},
"description":{"en-GB":"An example meeting.","en-US":"An example meeting."},
"moreInfo":"http://virtualmeeting.example.com/345256",
"extensions":{"http://example.com/null":null}}}}"#;
assert!(Statement::from_str(S).is_ok());
}
#[test]
#[should_panic]
fn test_bad_duration() {
const S: &str = r#"{
"actor":{"objectType":"Agent","name":"xAPI account","mbox":"mailto:xapi@adlnet.gov"},
"verb":{"id":"http://adlnet.gov/expapi/verbs/attended","display":{"en-US":"attended"}},
"result":{
"score":{"scaled":0.95,"raw":95,"min":0,"max":100},
"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"}},
"success":true,
"completion":true,
"response":"We agreed on some example actions.",
"duration":"P4W1D"},
"object":{"objectType":"Activity","id":"http://www.example.com/meetings/occurances/34534"}}"#;
Statement::from_str(S).unwrap();
}
}