1use crate::braze::error::BrazeApiError;
19use crate::braze::BrazeClient;
20use crate::resource::{CustomAttribute, CustomAttributeType};
21use serde::{Deserialize, Serialize};
22use std::collections::HashSet;
23
24const SAFETY_CAP_PAGES: usize = 200;
26
27const STATUS_BLOCKLISTED: &str = "Blocklisted";
29
30impl BrazeClient {
31 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 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 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 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
109fn wire_data_type_to_domain(raw: Option<&str>) -> CustomAttributeType {
115 let lowered = raw.unwrap_or("").to_ascii_lowercase();
116
117 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 #[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 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 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 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 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}