1use 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 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 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 deprecated: w.blocklisted.unwrap_or(false),
56 })
57 .collect())
58 }
59
60 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
77fn 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#[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}