Skip to main content

braze_sync/resource/
catalog.rs

1//! Catalog Schema domain types. See IMPLEMENTATION.md ยง6.2.
2//!
3//! Catalog **items** (row data) are intentionally out of scope: braze-sync
4//! manages Braze configuration, not runtime data. See docs/scope-boundaries.md.
5
6use serde::{Deserialize, Serialize};
7
8/// Name of the privileged primary-key field on every Braze catalog.
9/// `POST /catalogs` rejects bodies whose `fields[0].name` is not this
10/// (error id `id-not-first-column`).
11pub const ID_FIELD_NAME: &str = "id";
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct Catalog {
15    pub name: String,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub description: Option<String>,
18    pub fields: Vec<CatalogField>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub struct CatalogField {
23    pub name: String,
24    #[serde(rename = "type")]
25    pub field_type: CatalogFieldType,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum CatalogFieldType {
31    String,
32    Number,
33    Boolean,
34    Time,
35    Object,
36    Array,
37    /// Catch-all for field types not yet known to this binary version.
38    /// Forward-compat: prevents deserialization failures when Braze adds
39    /// new field types. Round-trips as `"unknown"` โ€” the original type
40    /// name is not preserved. Upgrade braze-sync for full support.
41    #[serde(other)]
42    Unknown,
43}
44
45impl CatalogFieldType {
46    /// The lowercase wire string for this field type ("string", "number",
47    /// ...). Single source of truth used by `format::table`,
48    /// `format::json`, `cli::apply`, and `braze::catalog`. Matches the
49    /// snake_case `Serialize` representation derived above so the wire
50    /// string and the explicit method can never drift.
51    pub fn as_str(self) -> &'static str {
52        match self {
53            Self::String => "string",
54            Self::Number => "number",
55            Self::Boolean => "boolean",
56            Self::Time => "time",
57            Self::Object => "object",
58            Self::Array => "array",
59            Self::Unknown => "unknown",
60        }
61    }
62}
63
64impl Catalog {
65    /// Return a copy with `fields` ordered for serialization: the `id`
66    /// field (if present) first, then the rest sorted alphabetically by
67    /// name. Used both for deterministic on-disk output and for the
68    /// `POST /catalogs` request body โ€” Braze rejects creates whose
69    /// `fields[0]` is not the `id` column with `id-not-first-column`.
70    pub fn normalized(&self) -> Self {
71        let mut sorted = self.clone();
72        sorted.fields.sort_by(|a, b| {
73            let a_is_id = a.name == ID_FIELD_NAME;
74            let b_is_id = b.name == ID_FIELD_NAME;
75            // bool false < true, so reverse to put id first.
76            b_is_id.cmp(&a_is_id).then_with(|| a.name.cmp(&b.name))
77        });
78        sorted
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn catalog_yaml_roundtrip() {
88        let cat = Catalog {
89            name: "cardiology".into(),
90            description: Some("Cardiology catalog".into()),
91            fields: vec![
92                CatalogField {
93                    name: "condition_id".into(),
94                    field_type: CatalogFieldType::String,
95                },
96                CatalogField {
97                    name: "display_order".into(),
98                    field_type: CatalogFieldType::Number,
99                },
100            ],
101        };
102        let yaml = serde_norway::to_string(&cat).unwrap();
103        let parsed: Catalog = serde_norway::from_str(&yaml).unwrap();
104        assert_eq!(cat, parsed);
105    }
106
107    #[test]
108    fn catalog_field_type_serializes_snake_case() {
109        let yaml = serde_norway::to_string(&CatalogFieldType::Boolean).unwrap();
110        assert_eq!(yaml.trim(), "boolean");
111    }
112
113    #[test]
114    fn unknown_field_type_deserializes_without_failure() {
115        // Forward compat: if Braze adds a field type this binary doesn't
116        // know about, the catalog should still parse โ€” the unknown type
117        // round-trips as "unknown" rather than crashing the entire export.
118        let yaml = "name: future\nfields:\n  - name: x\n    type: hyperlink\n";
119        let cat: Catalog = serde_norway::from_str(yaml).unwrap();
120        assert_eq!(cat.fields[0].field_type, CatalogFieldType::Unknown);
121        assert_eq!(cat.fields[0].field_type.as_str(), "unknown");
122    }
123
124    #[test]
125    fn unknown_field_type_does_not_break_known_fields() {
126        // A catalog with a mix of known and unknown types should parse
127        // the known types correctly.
128        let yaml = "\
129name: mixed
130fields:
131  - name: id
132    type: string
133  - name: fancy
134    type: quantum_entanglement
135  - name: score
136    type: number
137";
138        let cat: Catalog = serde_norway::from_str(yaml).unwrap();
139        assert_eq!(cat.fields.len(), 3);
140        assert_eq!(cat.fields[0].field_type, CatalogFieldType::String);
141        assert_eq!(cat.fields[1].field_type, CatalogFieldType::Unknown);
142        assert_eq!(cat.fields[2].field_type, CatalogFieldType::Number);
143    }
144
145    #[test]
146    fn description_omitted_when_none() {
147        let cat = Catalog {
148            name: "x".into(),
149            description: None,
150            fields: vec![],
151        };
152        let yaml = serde_norway::to_string(&cat).unwrap();
153        assert!(!yaml.contains("description"));
154    }
155
156    #[test]
157    fn normalized_sorts_fields_by_name() {
158        let cat = Catalog {
159            name: "x".into(),
160            description: None,
161            fields: vec![
162                CatalogField {
163                    name: "z".into(),
164                    field_type: CatalogFieldType::String,
165                },
166                CatalogField {
167                    name: "a".into(),
168                    field_type: CatalogFieldType::String,
169                },
170            ],
171        };
172        let n = cat.normalized();
173        assert_eq!(n.fields[0].name, "a");
174        assert_eq!(n.fields[1].name, "z");
175    }
176
177    #[test]
178    fn normalized_hoists_id_field_to_front() {
179        // Braze's POST /catalogs rejects bodies whose first field is not
180        // `id` of type string with error id `id-not-first-column`. The
181        // hoist applies regardless of the id field's alphabetic position.
182        let cat = Catalog {
183            name: "x".into(),
184            description: None,
185            fields: vec![
186                CatalogField {
187                    name: "URL".into(),
188                    field_type: CatalogFieldType::String,
189                },
190                CatalogField {
191                    name: "author".into(),
192                    field_type: CatalogFieldType::String,
193                },
194                CatalogField {
195                    name: "id".into(),
196                    field_type: CatalogFieldType::String,
197                },
198                CatalogField {
199                    name: "title".into(),
200                    field_type: CatalogFieldType::String,
201                },
202            ],
203        };
204        let n = cat.normalized();
205        let names: Vec<_> = n.fields.iter().map(|f| f.name.as_str()).collect();
206        assert_eq!(names, vec!["id", "URL", "author", "title"]);
207    }
208
209    #[test]
210    fn normalized_without_id_field_is_pure_alphabetical() {
211        let cat = Catalog {
212            name: "x".into(),
213            description: None,
214            fields: vec![
215                CatalogField {
216                    name: "z".into(),
217                    field_type: CatalogFieldType::String,
218                },
219                CatalogField {
220                    name: "a".into(),
221                    field_type: CatalogFieldType::String,
222                },
223            ],
224        };
225        let n = cat.normalized();
226        assert_eq!(n.fields[0].name, "a");
227        assert_eq!(n.fields[1].name, "z");
228    }
229}