Skip to main content

braze_sync/braze/
custom_attribute.rs

1//! Custom Attribute endpoints.
2//!
3//! Custom Attributes are managed in **registry mode**: the only list
4//! endpoint is `GET /custom_attributes`, and the only mutation is
5//! toggling the `blocklisted` (deprecated) flag.
6//!
7//! Braze creates Custom Attributes implicitly when `/users/track`
8//! receives data containing a previously-unseen attribute name. There
9//! is no declarative "create attribute" API.
10
11use crate::braze::error::BrazeApiError;
12use crate::braze::{check_duplicate_names, check_pagination, BrazeClient};
13use crate::resource::{CustomAttribute, CustomAttributeType};
14use serde::{Deserialize, Serialize};
15
16const LIST_LIMIT: u32 = 100;
17
18impl BrazeClient {
19    /// List all Custom Attributes from Braze. Fail-closed on pagination:
20    /// if the response indicates more attributes exist beyond the first
21    /// page, returns `PaginationNotImplemented` rather than silently
22    /// truncating.
23    pub async fn list_custom_attributes(&self) -> Result<Vec<CustomAttribute>, BrazeApiError> {
24        let req = self
25            .get(&["custom_attributes"])
26            .query(&[("limit", LIST_LIMIT.to_string())]);
27        let resp: CustomAttributeListResponse = self.send_json(req).await?;
28        let returned = resp.custom_attributes.len();
29
30        // Fail closed when the page is or might be truncated.
31        check_pagination(
32            resp.count,
33            returned,
34            LIST_LIMIT as usize,
35            "/custom_attributes",
36        )?;
37
38        check_duplicate_names(
39            resp.custom_attributes
40                .iter()
41                .map(|e| e.custom_attribute_name.as_str()),
42            returned,
43            "/custom_attributes",
44        )?;
45
46        Ok(resp
47            .custom_attributes
48            .into_iter()
49            .map(|w| CustomAttribute {
50                name: w.custom_attribute_name,
51                attribute_type: wire_data_type_to_domain(w.data_type.as_deref()),
52                description: w.description,
53                // Braze omits `blocklisted` for non-blocklisted attributes;
54                // treat absent as active (not deprecated).
55                deprecated: w.blocklisted.unwrap_or(false),
56            })
57            .collect())
58    }
59
60    /// Toggle the `blocklisted` (deprecated) flag on one or more Custom
61    /// Attributes. This is the **only** write operation braze-sync
62    /// performs for Custom Attributes.
63    pub async fn set_custom_attribute_blocklist(
64        &self,
65        names: &[&str],
66        blocklisted: bool,
67    ) -> Result<(), BrazeApiError> {
68        let body = BlocklistRequest {
69            custom_attribute_names: names,
70            blocklisted,
71        };
72        let req = self.post(&["custom_attributes", "blocklist"]).json(&body);
73        self.send_ok(req).await
74    }
75}
76
77/// Map the Braze wire `data_type` string to our domain enum.
78/// Unknown types default to `String` — forward-compat for types Braze
79/// may add in the future. `"number"` and `"bool"` aliases are undocumented
80/// but guarded defensively in case the API ever returns them.
81fn wire_data_type_to_domain(data_type: Option<&str>) -> CustomAttributeType {
82    match data_type {
83        Some("string") => CustomAttributeType::String,
84        Some("integer") | Some("float") | Some("number") => CustomAttributeType::Number,
85        Some("boolean") | Some("bool") => CustomAttributeType::Boolean,
86        Some("date") | Some("time") => CustomAttributeType::Time,
87        Some("array") => CustomAttributeType::Array,
88        Some(unknown) => {
89            tracing::warn!(
90                data_type = unknown,
91                "unknown Braze data_type, defaulting to string"
92            );
93            CustomAttributeType::String
94        }
95        None => {
96            tracing::debug!("Braze data_type is absent, defaulting to string");
97            CustomAttributeType::String
98        }
99    }
100}
101
102// =====================================================================
103// Wire types — Braze API response shapes.
104// =====================================================================
105
106#[derive(Debug, Deserialize)]
107struct CustomAttributeListResponse {
108    #[serde(default)]
109    custom_attributes: Vec<CustomAttributeWire>,
110    #[serde(default)]
111    count: Option<usize>,
112}
113
114#[derive(Debug, Deserialize)]
115struct CustomAttributeWire {
116    #[serde(default)]
117    custom_attribute_name: String,
118    #[serde(default)]
119    data_type: Option<String>,
120    #[serde(default)]
121    description: Option<String>,
122    #[serde(default)]
123    blocklisted: Option<bool>,
124}
125
126#[derive(Debug, Serialize)]
127struct BlocklistRequest<'a> {
128    custom_attribute_names: &'a [&'a str],
129    blocklisted: bool,
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::braze::test_client as make_client;
136    use serde_json::json;
137    use wiremock::matchers::{body_json, header, method, path, query_param};
138    use wiremock::{Mock, MockServer, ResponseTemplate};
139
140    #[tokio::test]
141    async fn list_happy_path() {
142        let server = MockServer::start().await;
143        Mock::given(method("GET"))
144            .and(path("/custom_attributes"))
145            .and(header("authorization", "Bearer test-key"))
146            .and(query_param("limit", "100"))
147            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
148                "count": 2,
149                "custom_attributes": [
150                    {
151                        "custom_attribute_name": "last_visit_date",
152                        "data_type": "date",
153                        "description": "Most recent visit",
154                        "blocklisted": false
155                    },
156                    {
157                        "custom_attribute_name": "legacy_segment",
158                        "data_type": "string",
159                        "blocklisted": true
160                    }
161                ],
162                "message": "success"
163            })))
164            .mount(&server)
165            .await;
166
167        let client = make_client(&server);
168        let attrs = client.list_custom_attributes().await.unwrap();
169        assert_eq!(attrs.len(), 2);
170        assert_eq!(attrs[0].name, "last_visit_date");
171        assert_eq!(attrs[0].attribute_type, CustomAttributeType::Time);
172        assert_eq!(attrs[0].description.as_deref(), Some("Most recent visit"));
173        assert!(!attrs[0].deprecated);
174        assert_eq!(attrs[1].name, "legacy_segment");
175        assert!(attrs[1].deprecated);
176    }
177
178    #[tokio::test]
179    async fn list_empty_array() {
180        let server = MockServer::start().await;
181        Mock::given(method("GET"))
182            .and(path("/custom_attributes"))
183            .respond_with(
184                ResponseTemplate::new(200).set_body_json(json!({"custom_attributes": []})),
185            )
186            .mount(&server)
187            .await;
188        let client = make_client(&server);
189        assert!(client.list_custom_attributes().await.unwrap().is_empty());
190    }
191
192    #[tokio::test]
193    async fn list_ignores_unknown_fields() {
194        let server = MockServer::start().await;
195        Mock::given(method("GET"))
196            .and(path("/custom_attributes"))
197            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
198                "custom_attributes": [{
199                    "custom_attribute_name": "foo",
200                    "data_type": "string",
201                    "future_field": "ignored"
202                }]
203            })))
204            .mount(&server)
205            .await;
206        let client = make_client(&server);
207        let attrs = client.list_custom_attributes().await.unwrap();
208        assert_eq!(attrs.len(), 1);
209        assert_eq!(attrs[0].name, "foo");
210    }
211
212    #[tokio::test]
213    async fn list_unauthorized() {
214        let server = MockServer::start().await;
215        Mock::given(method("GET"))
216            .and(path("/custom_attributes"))
217            .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
218            .mount(&server)
219            .await;
220        let client = make_client(&server);
221        let err = client.list_custom_attributes().await.unwrap_err();
222        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
223    }
224
225    #[tokio::test]
226    async fn list_errors_when_count_exceeds_returned() {
227        let server = MockServer::start().await;
228        let entries: Vec<serde_json::Value> = (0..50)
229            .map(|i| {
230                json!({
231                    "custom_attribute_name": format!("attr_{i}"),
232                    "data_type": "string"
233                })
234            })
235            .collect();
236        Mock::given(method("GET"))
237            .and(path("/custom_attributes"))
238            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
239                "count": 150,
240                "custom_attributes": entries,
241                "message": "success"
242            })))
243            .mount(&server)
244            .await;
245        let client = make_client(&server);
246        let err = client.list_custom_attributes().await.unwrap_err();
247        assert!(
248            matches!(err, BrazeApiError::PaginationNotImplemented { .. }),
249            "got {err:?}"
250        );
251    }
252
253    #[tokio::test]
254    async fn list_errors_on_full_page_with_no_count_field() {
255        let server = MockServer::start().await;
256        let entries: Vec<serde_json::Value> = (0..100)
257            .map(|i| {
258                json!({
259                    "custom_attribute_name": format!("attr_{i}"),
260                    "data_type": "string"
261                })
262            })
263            .collect();
264        Mock::given(method("GET"))
265            .and(path("/custom_attributes"))
266            .respond_with(
267                ResponseTemplate::new(200).set_body_json(json!({ "custom_attributes": entries })),
268            )
269            .mount(&server)
270            .await;
271        let client = make_client(&server);
272        let err = client.list_custom_attributes().await.unwrap_err();
273        assert!(
274            matches!(err, BrazeApiError::PaginationNotImplemented { .. }),
275            "got {err:?}"
276        );
277    }
278
279    #[tokio::test]
280    async fn list_short_page_with_no_count_is_trusted_as_complete() {
281        let server = MockServer::start().await;
282        Mock::given(method("GET"))
283            .and(path("/custom_attributes"))
284            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
285                "custom_attributes": [
286                    {"custom_attribute_name": "a", "data_type": "string"},
287                    {"custom_attribute_name": "b", "data_type": "number"}
288                ]
289            })))
290            .mount(&server)
291            .await;
292        let client = make_client(&server);
293        let attrs = client.list_custom_attributes().await.unwrap();
294        assert_eq!(attrs.len(), 2);
295    }
296
297    #[tokio::test]
298    async fn list_maps_data_types_correctly() {
299        let server = MockServer::start().await;
300        Mock::given(method("GET"))
301            .and(path("/custom_attributes"))
302            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
303                "count": 5,
304                "custom_attributes": [
305                    {"custom_attribute_name": "s", "data_type": "string"},
306                    {"custom_attribute_name": "n", "data_type": "integer"},
307                    {"custom_attribute_name": "b", "data_type": "boolean"},
308                    {"custom_attribute_name": "t", "data_type": "date"},
309                    {"custom_attribute_name": "a", "data_type": "array"}
310                ]
311            })))
312            .mount(&server)
313            .await;
314        let client = make_client(&server);
315        let attrs = client.list_custom_attributes().await.unwrap();
316        assert_eq!(attrs[0].attribute_type, CustomAttributeType::String);
317        assert_eq!(attrs[1].attribute_type, CustomAttributeType::Number);
318        assert_eq!(attrs[2].attribute_type, CustomAttributeType::Boolean);
319        assert_eq!(attrs[3].attribute_type, CustomAttributeType::Time);
320        assert_eq!(attrs[4].attribute_type, CustomAttributeType::Array);
321    }
322
323    #[tokio::test]
324    async fn blocklist_sends_correct_body() {
325        let server = MockServer::start().await;
326        Mock::given(method("POST"))
327            .and(path("/custom_attributes/blocklist"))
328            .and(header("authorization", "Bearer test-key"))
329            .and(body_json(json!({
330                "custom_attribute_names": ["legacy_segment"],
331                "blocklisted": true
332            })))
333            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
334                "message": "success"
335            })))
336            .expect(1)
337            .mount(&server)
338            .await;
339
340        let client = make_client(&server);
341        client
342            .set_custom_attribute_blocklist(&["legacy_segment"], true)
343            .await
344            .unwrap();
345    }
346
347    #[tokio::test]
348    async fn list_errors_on_duplicate_name() {
349        let server = MockServer::start().await;
350        Mock::given(method("GET"))
351            .and(path("/custom_attributes"))
352            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
353                "count": 3,
354                "custom_attributes": [
355                    {"custom_attribute_name": "dup", "data_type": "string"},
356                    {"custom_attribute_name": "unique", "data_type": "number"},
357                    {"custom_attribute_name": "dup", "data_type": "string"}
358                ]
359            })))
360            .mount(&server)
361            .await;
362        let client = make_client(&server);
363        let err = client.list_custom_attributes().await.unwrap_err();
364        match err {
365            BrazeApiError::DuplicateNameInListResponse { endpoint, name } => {
366                assert_eq!(endpoint, "/custom_attributes");
367                assert_eq!(name, "dup");
368            }
369            other => panic!("expected DuplicateNameInListResponse, got {other:?}"),
370        }
371    }
372
373    #[tokio::test]
374    async fn blocklist_unblocklist() {
375        let server = MockServer::start().await;
376        Mock::given(method("POST"))
377            .and(path("/custom_attributes/blocklist"))
378            .and(body_json(json!({
379                "custom_attribute_names": ["reactivated"],
380                "blocklisted": false
381            })))
382            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
383                "message": "success"
384            })))
385            .expect(1)
386            .mount(&server)
387            .await;
388
389        let client = make_client(&server);
390        client
391            .set_custom_attribute_blocklist(&["reactivated"], false)
392            .await
393            .unwrap();
394    }
395}