1use serde::{Serialize, de::DeserializeOwned};
6use serde_json::Value;
7
8use crate::{CanonicalError, canonicalize, to_canonical_vec};
9
10pub trait Object {
12 const TYPE: &'static str;
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum ObjectError {
19 #[error(transparent)]
21 Canonical(#[from] CanonicalError),
22 #[error("expected an object of type {expected:?}, found {found:?}")]
24 Type {
25 expected: &'static str,
27 found: Option<String>,
29 },
30 #[error("not a valid {0}: {1}")]
32 Content(&'static str, String),
33}
34
35pub 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
45pub 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 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
68fn 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}