Skip to main content

librotenado/
object.rs

1// SPDX-License-Identifier: MIT
2//! Objects and their types (spec part 0003). Every object of the protocol has a type; it is written where the
3//! context does not determine it – on a root object, and in an array that mixes types – and nowhere else.
4
5use serde::{Serialize, de::DeserializeOwned};
6use serde_json::Value;
7
8use crate::{CanonicalError, canonicalize, to_canonical_vec};
9
10/// An object of the protocol with its type, e.g. `event` or `account`.
11pub trait Object {
12    /// The type, written as `"type"` when the object stands on its own.
13    const TYPE: &'static str;
14}
15
16/// Why JSON is not the expected root object.
17#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum ObjectError {
19    /// Not JSON that has a canonical encoding.
20    #[error(transparent)]
21    Canonical(#[from] CanonicalError),
22    /// A root object without its type, or of another type.
23    #[error("expected an object of type {expected:?}, found {found:?}")]
24    Type {
25        /// The type expected.
26        expected: &'static str,
27        /// The type found, if any.
28        found: Option<String>,
29    },
30    /// Of the right type, but not valid content of it.
31    #[error("not a valid {0}: {1}")]
32    Content(&'static str, String),
33}
34
35/// The canonical encoding of `value` as a root object: its members and its `type`.
36pub fn to_root<T: Object + Serialize>(value: &T) -> Result<Vec<u8>, CanonicalError> {
37    let mut json = serde_json::to_value(value).map_err(|e| CanonicalError::Json(e.to_string()))?;
38    let Value::Object(members) = &mut json else {
39        return Err(CanonicalError::Json(format!("a {} is not a JSON object", T::TYPE)));
40    };
41    members.insert("type".into(), T::TYPE.into());
42    to_canonical_vec(&json)
43}
44
45/// Reads a root object of type `T` from JSON in any formatting.
46pub fn from_root<T: Object + Serialize + DeserializeOwned>(json: &[u8]) -> Result<T, ObjectError> {
47    let canonical = canonicalize(json)?;
48    let mut value: Value = serde_json::from_slice(&canonical).map_err(|e| CanonicalError::Json(e.to_string()))?;
49    let found = value.as_object_mut().and_then(|members| members.remove("type"));
50    match found {
51        Some(Value::String(found)) if found == T::TYPE => {}
52        found => {
53            let found = found.map(|t| t.as_str().map_or_else(|| t.to_string(), str::to_owned));
54            return Err(ObjectError::Type { expected: T::TYPE, found });
55        }
56    }
57    let object: T = serde_json::from_value(value.clone()).map_err(|e| ObjectError::Content(T::TYPE, e.to_string()))?;
58    // Where this version knows the position of a nested object, its type follows from it and is not written. What
59    // this version does not know (members of later versions) is not checked: there an array may mix types.
60    if let Ok(known) = serde_json::to_value(&object)
61        && nested_type(&value, &known, true)
62    {
63        return Err(ObjectError::Content(T::TYPE, "a nested object carries \"type\"".into()));
64    }
65    Ok(object)
66}
67
68/// Whether `received` has a `type` on a nested object at a position that `known` – this version's reading – has.
69fn nested_type(received: &Value, known: &Value, root: bool) -> bool {
70    match (received, known) {
71        (Value::Object(received), Value::Object(known)) => {
72            (!root && received.contains_key("type"))
73                || known.iter().any(|(name, k)| received.get(name).is_some_and(|r| nested_type(r, k, false)))
74        }
75        (Value::Array(received), Value::Array(known)) => {
76            received.iter().zip(known).any(|(r, k)| nested_type(r, k, false))
77        }
78        _ => false,
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::{CurrencyDefinition, Money};
86
87    #[test]
88    fn a_root_object_carries_its_type() {
89        let money = Money::new(1240, "EUR".parse().unwrap()).unwrap();
90        let json = to_root(&money).unwrap();
91        assert_eq!(json, br#"{"base_units":"1240","currency":"EUR","type":"money"}"#);
92        assert_eq!(from_root::<Money>(&json).unwrap(), money);
93    }
94
95    #[test]
96    fn the_type_must_be_there_and_fit() {
97        let untyped = br#"{"base_units":"1240","currency":"EUR"}"#;
98        assert!(matches!(from_root::<Money>(untyped), Err(ObjectError::Type { found: None, .. })));
99        let other = br#"{"code":"EUR","subdivisions":[100],"type":"money"}"#;
100        assert!(matches!(from_root::<CurrencyDefinition>(other), Err(ObjectError::Type { .. })));
101        let currency = br#"{"code":"EUR","subdivisions":[100],"type":"currency"}"#;
102        assert!(from_root::<CurrencyDefinition>(currency).is_ok());
103    }
104
105    #[test]
106    fn nested_objects_carry_no_type() {
107        let posting = br#"{"type":"posting","owner":"0192f1e4-0000-7000-8000-0000000000a1","account":"0192f1e4-0000-7000-8000-000000000001","amount":{"currency":"EUR","base_units":"5"}}"#;
108        assert!(from_root::<crate::Posting>(posting).is_ok());
109        let typed = br#"{"type":"posting","owner":"0192f1e4-0000-7000-8000-0000000000a1","account":"0192f1e4-0000-7000-8000-000000000001","amount":{"type":"money","currency":"EUR","base_units":"5"}}"#;
110        assert!(matches!(from_root::<crate::Posting>(typed), Err(ObjectError::Content(..))));
111        let later = br#"{"type":"posting","owner":"0192f1e4-0000-7000-8000-0000000000a1","account":"0192f1e4-0000-7000-8000-000000000001","amount":{"currency":"EUR","base_units":"5"},"notes":[{"type":"x"}]}"#;
112        assert!(from_root::<crate::Posting>(later).is_ok(), "members of later versions are not checked");
113    }
114}