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//!
11//! ## Wire contract
12//!
13//! Pagination is cursor-based via the RFC 5988 `Link: rel="next"` header;
14//! the response body does not carry the cursor. `limit` is not a
15//! supported query parameter — page size is fixed at 50 server-side.
16//! `deprecated` is derived from `status == STATUS_BLOCKLISTED`.
17
18use crate::braze::error::BrazeApiError;
19use crate::braze::BrazeClient;
20use crate::resource::{CustomAttribute, CustomAttributeType};
21use serde::{Deserialize, Serialize};
22use std::collections::HashSet;
23
24/// At Braze's fixed 50 items/page this covers 10k attributes.
25const SAFETY_CAP_PAGES: usize = 200;
26
27/// Wire value of the `status` field that indicates a deprecated attribute.
28const STATUS_BLOCKLISTED: &str = "Blocklisted";
29
30impl BrazeClient {
31    /// List all Custom Attributes from Braze. Follows RFC 5988 `Link`
32    /// headers through every page until the server stops returning
33    /// `rel="next"`.
34    pub async fn list_custom_attributes(&self) -> Result<Vec<CustomAttribute>, BrazeApiError> {
35        let mut all: Vec<CustomAttribute> = Vec::new();
36        let mut seen: HashSet<String> = HashSet::new();
37        let mut next_url: Option<String> = None;
38
39        for _ in 0..SAFETY_CAP_PAGES {
40            let req = match &next_url {
41                None => self.get(&["custom_attributes"]),
42                Some(url) => self.get_absolute(url)?,
43            };
44            let (resp, next): (CustomAttributeListResponse, _) =
45                self.send_json_with_next_link(req).await?;
46
47            // Dedup across pages — per-page checks would miss a name that
48            // recurs on a later cursor page.
49            for w in resp.attributes {
50                if !seen.insert(w.name.clone()) {
51                    return Err(BrazeApiError::DuplicateNameInListResponse {
52                        endpoint: "/custom_attributes",
53                        name: w.name,
54                    });
55                }
56                all.push(wire_to_domain(w));
57            }
58
59            match next {
60                // Guard against a server that echoes the same cursor —
61                // without this the safety-cap is the only exit.
62                Some(url) if Some(&url) == next_url.as_ref() => {
63                    return Err(BrazeApiError::PaginationNotImplemented {
64                        endpoint: "/custom_attributes",
65                        detail: format!("server returned same next link twice: {url}"),
66                    });
67                }
68                Some(url) => next_url = Some(url),
69                None => return Ok(all),
70            }
71        }
72
73        Err(BrazeApiError::PaginationNotImplemented {
74            endpoint: "/custom_attributes",
75            detail: format!("exceeded {SAFETY_CAP_PAGES} page safety cap"),
76        })
77    }
78
79    /// Toggle the `blocklisted` (deprecated) flag on one or more Custom
80    /// Attributes. This is the **only** write operation braze-sync
81    /// performs for Custom Attributes.
82    pub async fn set_custom_attribute_blocklist(
83        &self,
84        names: &[&str],
85        blocklisted: bool,
86    ) -> Result<(), BrazeApiError> {
87        let body = BlocklistRequest {
88            custom_attribute_names: names,
89            blocklisted,
90        };
91        let req = self.post(&["custom_attributes", "blocklist"]).json(&body);
92        self.send_ok(req).await
93    }
94}
95
96fn wire_to_domain(w: CustomAttributeWire) -> CustomAttribute {
97    CustomAttribute {
98        name: w.name,
99        attribute_type: wire_data_type_to_domain(w.data_type.as_deref()),
100        description: w.description,
101        deprecated: w
102            .status
103            .as_deref()
104            .map(|s| s.eq_ignore_ascii_case(STATUS_BLOCKLISTED))
105            .unwrap_or(false),
106    }
107}
108
109/// Map the Braze wire `data_type` string to our domain enum.
110///
111/// Braze returns values like `"String (Automatically Detected)"` — we
112/// match on the **leading whitespace-delimited token** (case-insensitive)
113/// to ignore the suffix. Unknown values default to `String` with a warn.
114fn wire_data_type_to_domain(raw: Option<&str>) -> CustomAttributeType {
115    let lowered = raw.unwrap_or("").to_ascii_lowercase();
116
117    // `"object array"` must be checked before the leading-token match:
118    // `split_whitespace().next()` would return `"object"` alone and
119    // mis-classify Object-Array attributes as Object.
120    if lowered.starts_with("object array") {
121        return CustomAttributeType::ObjectArray;
122    }
123
124    let leading = lowered.split_whitespace().next().unwrap_or("");
125    match leading {
126        "string" => CustomAttributeType::String,
127        "number" | "integer" | "float" => CustomAttributeType::Number,
128        "boolean" | "bool" => CustomAttributeType::Boolean,
129        "time" | "date" => CustomAttributeType::Time,
130        "array" => CustomAttributeType::Array,
131        "object" => CustomAttributeType::Object,
132        "object_array" => CustomAttributeType::ObjectArray,
133        "" => {
134            tracing::debug!("Braze data_type is absent, defaulting to string");
135            CustomAttributeType::String
136        }
137        unknown => {
138            tracing::warn!(
139                data_type = unknown,
140                raw = ?raw,
141                "unknown Braze data_type, defaulting to string"
142            );
143            CustomAttributeType::String
144        }
145    }
146}
147
148#[derive(Debug, Deserialize)]
149struct CustomAttributeListResponse {
150    #[serde(default)]
151    attributes: Vec<CustomAttributeWire>,
152}
153
154#[derive(Debug, Deserialize)]
155struct CustomAttributeWire {
156    #[serde(default)]
157    name: String,
158    #[serde(default)]
159    description: Option<String>,
160    #[serde(default)]
161    data_type: Option<String>,
162    /// `"Active"` or `"Blocklisted"`. Absent for older workspaces —
163    /// treated as not blocklisted.
164    #[serde(default)]
165    status: Option<String>,
166}
167
168#[derive(Debug, Serialize)]
169struct BlocklistRequest<'a> {
170    custom_attribute_names: &'a [&'a str],
171    blocklisted: bool,
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::braze::test_client as make_client;
178    use serde_json::json;
179    use wiremock::matchers::{body_json, header, method, path};
180    use wiremock::{Mock, MockServer, ResponseTemplate};
181
182    #[tokio::test]
183    async fn list_happy_path() {
184        let server = MockServer::start().await;
185        Mock::given(method("GET"))
186            .and(path("/custom_attributes"))
187            .and(header("authorization", "Bearer test-key"))
188            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
189                "attributes": [
190                    {
191                        "name": "last_visit_date",
192                        "description": "Most recent visit",
193                        "data_type": "Date (Automatically Detected)",
194                        "array_length": null,
195                        "status": "Active",
196                        "tag_names": []
197                    },
198                    {
199                        "name": "legacy_segment",
200                        "description": null,
201                        "data_type": "String",
202                        "array_length": null,
203                        "status": "Blocklisted",
204                        "tag_names": []
205                    }
206                ],
207                "message": "success"
208            })))
209            .mount(&server)
210            .await;
211
212        let client = make_client(&server);
213        let attrs = client.list_custom_attributes().await.unwrap();
214        assert_eq!(attrs.len(), 2);
215        assert_eq!(attrs[0].name, "last_visit_date");
216        assert_eq!(attrs[0].attribute_type, CustomAttributeType::Time);
217        assert_eq!(attrs[0].description.as_deref(), Some("Most recent visit"));
218        assert!(!attrs[0].deprecated);
219        assert_eq!(attrs[1].name, "legacy_segment");
220        assert!(attrs[1].deprecated);
221    }
222
223    #[tokio::test]
224    async fn list_empty_array() {
225        let server = MockServer::start().await;
226        Mock::given(method("GET"))
227            .and(path("/custom_attributes"))
228            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"attributes": []})))
229            .mount(&server)
230            .await;
231        let client = make_client(&server);
232        assert!(client.list_custom_attributes().await.unwrap().is_empty());
233    }
234
235    #[tokio::test]
236    async fn list_ignores_unknown_fields() {
237        let server = MockServer::start().await;
238        Mock::given(method("GET"))
239            .and(path("/custom_attributes"))
240            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
241                "attributes": [{
242                    "name": "foo",
243                    "data_type": "String",
244                    "future_field": "ignored"
245                }]
246            })))
247            .mount(&server)
248            .await;
249        let client = make_client(&server);
250        let attrs = client.list_custom_attributes().await.unwrap();
251        assert_eq!(attrs.len(), 1);
252        assert_eq!(attrs[0].name, "foo");
253    }
254
255    #[tokio::test]
256    async fn list_unauthorized() {
257        let server = MockServer::start().await;
258        Mock::given(method("GET"))
259            .and(path("/custom_attributes"))
260            .respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
261            .mount(&server)
262            .await;
263        let client = make_client(&server);
264        let err = client.list_custom_attributes().await.unwrap_err();
265        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
266    }
267
268    #[tokio::test]
269    async fn list_follows_link_header_through_pages() {
270        let server = MockServer::start().await;
271        let base = server.uri();
272        let page_2_link = format!(
273            "<{base}/custom_attributes?cursor=p2>; rel=\"next\"",
274            base = base
275        );
276
277        // Page 2 (mounted first so cursor-bearing requests hit it).
278        Mock::given(method("GET"))
279            .and(path("/custom_attributes"))
280            .and(wiremock::matchers::query_param("cursor", "p2"))
281            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
282                "attributes": [
283                    {"name": "c", "data_type": "String", "status": "Active"}
284                ],
285                "message": "success"
286            })))
287            .mount(&server)
288            .await;
289        // Page 1 — no cursor query param, carries a Link header to p2.
290        Mock::given(method("GET"))
291            .and(path("/custom_attributes"))
292            .respond_with(
293                ResponseTemplate::new(200)
294                    .insert_header("link", page_2_link.as_str())
295                    .set_body_json(json!({
296                        "attributes": [
297                            {"name": "a", "data_type": "String", "status": "Active"},
298                            {"name": "b", "data_type": "Number", "status": "Active"}
299                        ],
300                        "message": "success"
301                    })),
302            )
303            .up_to_n_times(1)
304            .mount(&server)
305            .await;
306
307        let client = make_client(&server);
308        let attrs = client.list_custom_attributes().await.unwrap();
309        assert_eq!(attrs.len(), 3);
310        assert_eq!(attrs[0].name, "a");
311        assert_eq!(attrs[2].name, "c");
312    }
313
314    #[tokio::test]
315    async fn list_maps_data_types_correctly() {
316        let server = MockServer::start().await;
317        Mock::given(method("GET"))
318            .and(path("/custom_attributes"))
319            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
320                "attributes": [
321                    {"name": "s", "data_type": "String (Automatically Detected)"},
322                    {"name": "n", "data_type": "Number"},
323                    {"name": "b", "data_type": "Boolean"},
324                    {"name": "t", "data_type": "Date"},
325                    {"name": "a", "data_type": "Array"},
326                    {"name": "o", "data_type": "Object"},
327                    {"name": "oa", "data_type": "Object Array"}
328                ]
329            })))
330            .mount(&server)
331            .await;
332        let client = make_client(&server);
333        let attrs = client.list_custom_attributes().await.unwrap();
334        assert_eq!(attrs[0].attribute_type, CustomAttributeType::String);
335        assert_eq!(attrs[1].attribute_type, CustomAttributeType::Number);
336        assert_eq!(attrs[2].attribute_type, CustomAttributeType::Boolean);
337        assert_eq!(attrs[3].attribute_type, CustomAttributeType::Time);
338        assert_eq!(attrs[4].attribute_type, CustomAttributeType::Array);
339        assert_eq!(attrs[5].attribute_type, CustomAttributeType::Object);
340        assert_eq!(attrs[6].attribute_type, CustomAttributeType::ObjectArray);
341    }
342
343    #[tokio::test]
344    async fn deprecated_is_derived_from_status_blocklisted() {
345        let server = MockServer::start().await;
346        Mock::given(method("GET"))
347            .and(path("/custom_attributes"))
348            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
349                "attributes": [
350                    {"name": "active", "data_type": "String", "status": "Active"},
351                    {"name": "blocked", "data_type": "String", "status": "Blocklisted"},
352                    {"name": "missing", "data_type": "String"}
353                ]
354            })))
355            .mount(&server)
356            .await;
357        let client = make_client(&server);
358        let attrs = client.list_custom_attributes().await.unwrap();
359        assert!(!attrs[0].deprecated);
360        assert!(attrs[1].deprecated);
361        assert!(!attrs[2].deprecated);
362    }
363
364    #[tokio::test]
365    async fn blocklist_sends_correct_body() {
366        let server = MockServer::start().await;
367        Mock::given(method("POST"))
368            .and(path("/custom_attributes/blocklist"))
369            .and(header("authorization", "Bearer test-key"))
370            .and(body_json(json!({
371                "custom_attribute_names": ["legacy_segment"],
372                "blocklisted": true
373            })))
374            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
375                "message": "success"
376            })))
377            .expect(1)
378            .mount(&server)
379            .await;
380
381        let client = make_client(&server);
382        client
383            .set_custom_attribute_blocklist(&["legacy_segment"], true)
384            .await
385            .unwrap();
386    }
387
388    #[tokio::test]
389    async fn list_errors_on_duplicate_name() {
390        let server = MockServer::start().await;
391        Mock::given(method("GET"))
392            .and(path("/custom_attributes"))
393            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
394                "attributes": [
395                    {"name": "dup", "data_type": "String"},
396                    {"name": "unique", "data_type": "Number"},
397                    {"name": "dup", "data_type": "String"}
398                ]
399            })))
400            .mount(&server)
401            .await;
402        let client = make_client(&server);
403        let err = client.list_custom_attributes().await.unwrap_err();
404        match err {
405            BrazeApiError::DuplicateNameInListResponse { endpoint, name } => {
406                assert_eq!(endpoint, "/custom_attributes");
407                assert_eq!(name, "dup");
408            }
409            other => panic!("expected DuplicateNameInListResponse, got {other:?}"),
410        }
411    }
412
413    #[tokio::test]
414    async fn list_errors_on_duplicate_name_across_pages() {
415        // Same name appears on page 1 and page 2 — must be detected even
416        // though each individual page is internally unique.
417        let server = MockServer::start().await;
418        let base = server.uri();
419        let page_2_link = format!("<{base}/custom_attributes?cursor=p2>; rel=\"next\"");
420
421        Mock::given(method("GET"))
422            .and(path("/custom_attributes"))
423            .and(wiremock::matchers::query_param("cursor", "p2"))
424            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
425                "attributes": [
426                    {"name": "dup", "data_type": "String", "status": "Active"}
427                ]
428            })))
429            .mount(&server)
430            .await;
431        Mock::given(method("GET"))
432            .and(path("/custom_attributes"))
433            .respond_with(
434                ResponseTemplate::new(200)
435                    .insert_header("link", page_2_link.as_str())
436                    .set_body_json(json!({
437                        "attributes": [
438                            {"name": "dup", "data_type": "String", "status": "Active"}
439                        ]
440                    })),
441            )
442            .up_to_n_times(1)
443            .mount(&server)
444            .await;
445
446        let client = make_client(&server);
447        let err = client.list_custom_attributes().await.unwrap_err();
448        match err {
449            BrazeApiError::DuplicateNameInListResponse { endpoint, name } => {
450                assert_eq!(endpoint, "/custom_attributes");
451                assert_eq!(name, "dup");
452            }
453            other => panic!("expected DuplicateNameInListResponse, got {other:?}"),
454        }
455    }
456
457    #[tokio::test]
458    async fn list_errors_when_cursor_repeats() {
459        // Server echoes the same `rel="next"` cursor forever — without
460        // cycle detection we'd loop to SAFETY_CAP_PAGES.
461        let server = MockServer::start().await;
462        let base = server.uri();
463        let self_link = format!("<{base}/custom_attributes?cursor=loop>; rel=\"next\"");
464
465        Mock::given(method("GET"))
466            .and(path("/custom_attributes"))
467            .respond_with(
468                ResponseTemplate::new(200)
469                    .insert_header("link", self_link.as_str())
470                    .set_body_json(json!({ "attributes": [] })),
471            )
472            .mount(&server)
473            .await;
474
475        let client = make_client(&server);
476        let err = client.list_custom_attributes().await.unwrap_err();
477        assert!(
478            matches!(err, BrazeApiError::PaginationNotImplemented { .. }),
479            "got {err:?}"
480        );
481    }
482
483    #[tokio::test]
484    async fn blocklist_unblocklist() {
485        let server = MockServer::start().await;
486        Mock::given(method("POST"))
487            .and(path("/custom_attributes/blocklist"))
488            .and(body_json(json!({
489                "custom_attribute_names": ["reactivated"],
490                "blocklisted": false
491            })))
492            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
493                "message": "success"
494            })))
495            .expect(1)
496            .mount(&server)
497            .await;
498
499        let client = make_client(&server);
500        client
501            .set_custom_attribute_blocklist(&["reactivated"], false)
502            .await
503            .unwrap();
504    }
505}