Skip to main content

made_core/value_objects/
attributes.rs

1//! [`Attributes`] value object.
2//!
3//! Opaque bag of structured data attached to a task, proposal, or event.
4//! Use-case-specific payloads (alert envelopes, case records, document
5//! references, …) ride here without leaking domain vocabulary into the
6//! MADE core.
7
8use std::collections::BTreeMap;
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::error::DomainError;
15
16/// Soft upper bound on the number of top-level attribute keys.
17pub const MAX_ATTRIBUTES_KEYS: usize = 1024;
18
19#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(transparent)]
21pub struct Attributes(BTreeMap<String, Value>);
22
23impl Attributes {
24    pub fn new(entries: BTreeMap<String, Value>) -> Result<Self, DomainError> {
25        if entries.len() > MAX_ATTRIBUTES_KEYS {
26            return Err(DomainError::OutOfRange {
27                field: "attributes.keys",
28                value: entries.len() as f64,
29                min: 0.0,
30                max: MAX_ATTRIBUTES_KEYS as f64,
31            });
32        }
33        for key in entries.keys() {
34            if key.trim().is_empty() {
35                return Err(DomainError::EmptyField {
36                    field: "attributes.key",
37                });
38            }
39        }
40        Ok(Self(entries))
41    }
42
43    #[must_use]
44    pub fn empty() -> Self {
45        Self::default()
46    }
47
48    #[must_use]
49    pub fn len(&self) -> usize {
50        self.0.len()
51    }
52
53    #[must_use]
54    pub fn is_empty(&self) -> bool {
55        self.0.is_empty()
56    }
57
58    #[must_use]
59    pub fn get(&self, key: &str) -> Option<&Value> {
60        self.0.get(key)
61    }
62
63    #[must_use]
64    pub fn as_map(&self) -> &BTreeMap<String, Value> {
65        &self.0
66    }
67
68    #[must_use]
69    pub fn into_inner(self) -> BTreeMap<String, Value> {
70        self.0
71    }
72}
73
74impl fmt::Display for Attributes {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        write!(f, "Attributes({} keys)", self.0.len())
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use serde_json::json;
84
85    fn entry(key: &str, value: Value) -> BTreeMap<String, Value> {
86        let mut m = BTreeMap::new();
87        m.insert(key.to_owned(), value);
88        m
89    }
90
91    #[test]
92    fn empty_is_allowed() {
93        assert!(Attributes::empty().is_empty());
94    }
95
96    #[test]
97    fn blank_key_is_rejected() {
98        let err = Attributes::new(entry("  ", json!(true))).unwrap_err();
99        assert!(matches!(
100            err,
101            DomainError::EmptyField {
102                field: "attributes.key"
103            }
104        ));
105    }
106
107    #[test]
108    fn too_many_keys_is_rejected() {
109        let mut m = BTreeMap::new();
110        for i in 0..=MAX_ATTRIBUTES_KEYS {
111            m.insert(format!("k{i}"), json!(i));
112        }
113        assert!(matches!(
114            Attributes::new(m).unwrap_err(),
115            DomainError::OutOfRange {
116                field: "attributes.keys",
117                ..
118            }
119        ));
120    }
121
122    #[test]
123    fn arbitrary_payload_is_accepted() {
124        let attrs = Attributes::new(entry(
125            "alert",
126            json!({"source": "grafana", "severity": "p1"}),
127        ))
128        .unwrap();
129        assert_eq!(attrs.len(), 1);
130        assert_eq!(attrs.get("alert").unwrap()["severity"], json!("p1"));
131    }
132
133    #[test]
134    fn serde_is_transparent() {
135        let a = Attributes::new(entry("k", json!("v"))).unwrap();
136        assert_eq!(serde_json::to_string(&a).unwrap(), r#"{"k":"v"}"#);
137    }
138}