1use crate::braze::error::BrazeApiError;
8use crate::braze::BrazeClient;
9use crate::resource::{Catalog, CatalogField, CatalogFieldType};
10use reqwest::StatusCode;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Deserialize)]
22struct CatalogsResponse {
23 #[serde(default)]
24 catalogs: Vec<Catalog>,
25 #[serde(default)]
29 next_cursor: Option<String>,
30}
31
32impl BrazeClient {
33 pub async fn list_catalogs(&self) -> Result<Vec<Catalog>, BrazeApiError> {
39 let req = self.get(&["catalogs"]);
40 let resp: CatalogsResponse = self.send_json(req).await?;
41 if let Some(cursor) = resp.next_cursor.as_deref() {
42 if !cursor.is_empty() {
43 return Err(BrazeApiError::PaginationNotImplemented {
44 endpoint: "/catalogs",
45 detail: format!(
46 "got {} catalog(s) plus a non-empty next_cursor; \
47 aborting to prevent silent truncation",
48 resp.catalogs.len()
49 ),
50 });
51 }
52 }
53 Ok(resp.catalogs)
54 }
55
56 pub async fn get_catalog(&self, name: &str) -> Result<Catalog, BrazeApiError> {
63 let req = self.get(&["catalogs", name]);
64 match self.send_json::<CatalogsResponse>(req).await {
65 Ok(resp) => resp
66 .catalogs
67 .into_iter()
68 .next()
69 .ok_or_else(|| BrazeApiError::NotFound {
70 resource: format!("catalog '{name}'"),
71 }),
72 Err(BrazeApiError::Http { status, .. }) if status == StatusCode::NOT_FOUND => {
73 Err(BrazeApiError::NotFound {
74 resource: format!("catalog '{name}'"),
75 })
76 }
77 Err(e) => Err(e),
78 }
79 }
80
81 pub async fn create_catalog(&self, catalog: &Catalog) -> Result<(), BrazeApiError> {
88 let body = CreateCatalogRequest {
89 catalogs: vec![CreateCatalogEntry {
90 name: &catalog.name,
91 description: catalog.description.as_deref(),
92 fields: catalog
93 .fields
94 .iter()
95 .map(|f| WireField {
96 name: &f.name,
97 field_type: f.field_type,
98 })
99 .collect(),
100 }],
101 };
102 let req = self.post(&["catalogs"]).json(&body);
103 self.send_ok(req).await
104 }
105
106 pub async fn add_catalog_field(
112 &self,
113 catalog_name: &str,
114 field: &CatalogField,
115 ) -> Result<(), BrazeApiError> {
116 let body = AddFieldsRequest {
117 fields: vec![WireField {
118 name: &field.name,
119 field_type: field.field_type,
120 }],
121 };
122 let req = self.post(&["catalogs", catalog_name, "fields"]).json(&body);
123 self.send_ok(req).await
124 }
125
126 pub async fn delete_catalog_field(
135 &self,
136 catalog_name: &str,
137 field_name: &str,
138 ) -> Result<(), BrazeApiError> {
139 let req = self.delete(&["catalogs", catalog_name, "fields", field_name]);
140 self.send_ok(req).await
141 }
142}
143
144#[derive(Serialize)]
145struct AddFieldsRequest<'a> {
146 fields: Vec<WireField<'a>>,
147}
148
149#[derive(Serialize)]
150struct CreateCatalogRequest<'a> {
151 catalogs: Vec<CreateCatalogEntry<'a>>,
152}
153
154#[derive(Serialize)]
155struct CreateCatalogEntry<'a> {
156 name: &'a str,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 description: Option<&'a str>,
159 fields: Vec<WireField<'a>>,
160}
161
162#[derive(Serialize)]
163struct WireField<'a> {
164 name: &'a str,
165 #[serde(rename = "type")]
168 field_type: CatalogFieldType,
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::braze::test_client as make_client;
175 use serde_json::json;
176 use wiremock::matchers::{body_json, header, method, path};
177 use wiremock::{Mock, MockServer, ResponseTemplate};
178
179 #[tokio::test]
180 async fn list_catalogs_happy_path() {
181 let server = MockServer::start().await;
182 Mock::given(method("GET"))
183 .and(path("/catalogs"))
184 .and(header("authorization", "Bearer test-key"))
185 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
186 "catalogs": [
187 {
188 "name": "cardiology",
189 "description": "Cardiology catalog",
190 "fields": [
191 {"name": "id", "type": "string"},
192 {"name": "score", "type": "number"}
193 ]
194 },
195 {
196 "name": "dermatology",
197 "fields": [
198 {"name": "id", "type": "string"}
199 ]
200 }
201 ],
202 "message": "success"
203 })))
204 .mount(&server)
205 .await;
206
207 let client = make_client(&server);
208 let cats = client.list_catalogs().await.unwrap();
209 assert_eq!(cats.len(), 2);
210 assert_eq!(cats[0].name, "cardiology");
211 assert_eq!(cats[0].description.as_deref(), Some("Cardiology catalog"));
212 assert_eq!(cats[0].fields.len(), 2);
213 assert_eq!(cats[0].fields[1].field_type, CatalogFieldType::Number);
214 assert_eq!(cats[1].name, "dermatology");
215 assert_eq!(cats[1].description, None);
216 }
217
218 #[tokio::test]
219 async fn list_catalogs_empty() {
220 let server = MockServer::start().await;
221 Mock::given(method("GET"))
222 .and(path("/catalogs"))
223 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
224 .mount(&server)
225 .await;
226 let client = make_client(&server);
227 let cats = client.list_catalogs().await.unwrap();
228 assert!(cats.is_empty());
229 }
230
231 #[tokio::test]
232 async fn list_catalogs_sets_user_agent() {
233 let server = MockServer::start().await;
234 Mock::given(method("GET"))
235 .and(path("/catalogs"))
236 .and(header(
237 "user-agent",
238 concat!("braze-sync/", env!("CARGO_PKG_VERSION")),
239 ))
240 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
241 .mount(&server)
242 .await;
243 let client = make_client(&server);
244 client.list_catalogs().await.unwrap();
245 }
246
247 #[tokio::test]
248 async fn list_catalogs_ignores_unknown_fields_in_response() {
249 let server = MockServer::start().await;
253 Mock::given(method("GET"))
254 .and(path("/catalogs"))
255 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
256 "catalogs": [
257 {
258 "name": "future",
259 "description": "tomorrow",
260 "future_metadata": {"foo": "bar"},
261 "num_items": 1234,
262 "fields": [
263 {"name": "id", "type": "string", "extra": "ignored"}
264 ]
265 }
266 ],
267 "future_top_level": {"whatever": true},
268 "message": "success"
269 })))
270 .mount(&server)
271 .await;
272 let client = make_client(&server);
273 let cats = client.list_catalogs().await.unwrap();
274 assert_eq!(cats.len(), 1);
275 assert_eq!(cats[0].name, "future");
276 }
277
278 #[tokio::test]
279 async fn list_catalogs_errors_when_next_cursor_present() {
280 let server = MockServer::start().await;
282 Mock::given(method("GET"))
283 .and(path("/catalogs"))
284 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
285 "catalogs": [
286 {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
287 ],
288 "next_cursor": "abc123"
289 })))
290 .mount(&server)
291 .await;
292 let client = make_client(&server);
293 let err = client.list_catalogs().await.unwrap_err();
294 match err {
295 BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
296 assert_eq!(endpoint, "/catalogs");
297 assert!(detail.contains("next_cursor"), "detail: {detail}");
298 assert!(detail.contains("1 catalog"), "detail: {detail}");
299 }
300 other => panic!("expected PaginationNotImplemented, got {other:?}"),
301 }
302 }
303
304 #[tokio::test]
305 async fn list_catalogs_empty_string_cursor_is_treated_as_no_more_pages() {
306 let server = MockServer::start().await;
312 Mock::given(method("GET"))
313 .and(path("/catalogs"))
314 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
315 "catalogs": [{"name": "only", "fields": []}],
316 "next_cursor": ""
317 })))
318 .mount(&server)
319 .await;
320 let client = make_client(&server);
321 let cats = client.list_catalogs().await.unwrap();
322 assert_eq!(cats.len(), 1);
323 assert_eq!(cats[0].name, "only");
324 }
325
326 #[tokio::test]
327 async fn unauthorized_returns_typed_error() {
328 let server = MockServer::start().await;
329 Mock::given(method("GET"))
330 .and(path("/catalogs"))
331 .respond_with(ResponseTemplate::new(401).set_body_string("invalid api key"))
332 .mount(&server)
333 .await;
334 let client = make_client(&server);
335 let err = client.list_catalogs().await.unwrap_err();
336 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
337 }
338
339 #[tokio::test]
340 async fn server_error_carries_status_and_body() {
341 let server = MockServer::start().await;
342 Mock::given(method("GET"))
343 .and(path("/catalogs"))
344 .respond_with(ResponseTemplate::new(500).set_body_string("internal explosion"))
345 .mount(&server)
346 .await;
347 let client = make_client(&server);
348 let err = client.list_catalogs().await.unwrap_err();
349 match err {
350 BrazeApiError::Http { status, body } => {
351 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
352 assert!(body.contains("internal explosion"));
353 }
354 other => panic!("expected Http, got {other:?}"),
355 }
356 }
357
358 #[tokio::test]
359 async fn retries_on_429_and_succeeds() {
360 let server = MockServer::start().await;
361 Mock::given(method("GET"))
365 .and(path("/catalogs"))
366 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
367 "catalogs": [{"name": "after_retry", "fields": []}]
368 })))
369 .mount(&server)
370 .await;
371 Mock::given(method("GET"))
372 .and(path("/catalogs"))
373 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
374 .up_to_n_times(1)
375 .mount(&server)
376 .await;
377
378 let client = make_client(&server);
379 let cats = client.list_catalogs().await.unwrap();
380 assert_eq!(cats.len(), 1);
381 assert_eq!(cats[0].name, "after_retry");
382 }
383
384 #[tokio::test]
385 async fn retries_exhausted_returns_rate_limit_exhausted() {
386 let server = MockServer::start().await;
387 Mock::given(method("GET"))
388 .and(path("/catalogs"))
389 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
390 .mount(&server)
391 .await;
392 let client = make_client(&server);
393 let err = client.list_catalogs().await.unwrap_err();
394 assert!(
395 matches!(err, BrazeApiError::RateLimitExhausted),
396 "got {err:?}"
397 );
398 }
399
400 #[tokio::test]
401 async fn get_catalog_happy_path() {
402 let server = MockServer::start().await;
403 Mock::given(method("GET"))
404 .and(path("/catalogs/cardiology"))
405 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
406 "catalogs": [
407 {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
408 ]
409 })))
410 .mount(&server)
411 .await;
412 let client = make_client(&server);
413 let cat = client.get_catalog("cardiology").await.unwrap();
414 assert_eq!(cat.name, "cardiology");
415 assert_eq!(cat.fields.len(), 1);
416 }
417
418 #[tokio::test]
419 async fn get_catalog_404_is_mapped_to_not_found() {
420 let server = MockServer::start().await;
421 Mock::given(method("GET"))
422 .and(path("/catalogs/missing"))
423 .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
424 .mount(&server)
425 .await;
426 let client = make_client(&server);
427 let err = client.get_catalog("missing").await.unwrap_err();
428 match err {
429 BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
430 other => panic!("expected NotFound, got {other:?}"),
431 }
432 }
433
434 #[tokio::test]
435 async fn get_catalog_empty_response_array_is_not_found() {
436 let server = MockServer::start().await;
437 Mock::given(method("GET"))
438 .and(path("/catalogs/ghost"))
439 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
440 .mount(&server)
441 .await;
442 let client = make_client(&server);
443 let err = client.get_catalog("ghost").await.unwrap_err();
444 assert!(matches!(err, BrazeApiError::NotFound { .. }), "got {err:?}");
445 }
446
447 #[tokio::test]
448 async fn debug_does_not_leak_api_key() {
449 let server = MockServer::start().await;
450 let client = make_client(&server);
451 let dbg = format!("{client:?}");
452 assert!(!dbg.contains("test-key"), "leaked api key in: {dbg}");
453 assert!(dbg.contains("<redacted>"));
454 }
455
456 #[tokio::test]
457 async fn add_catalog_field_happy_path_sends_correct_body() {
458 let server = MockServer::start().await;
459 Mock::given(method("POST"))
460 .and(path("/catalogs/cardiology/fields"))
461 .and(header("authorization", "Bearer test-key"))
462 .and(body_json(json!({
463 "fields": [{"name": "severity_level", "type": "number"}]
464 })))
465 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
466 .mount(&server)
467 .await;
468
469 let client = make_client(&server);
470 let field = CatalogField {
471 name: "severity_level".into(),
472 field_type: CatalogFieldType::Number,
473 };
474 client
475 .add_catalog_field("cardiology", &field)
476 .await
477 .unwrap();
478 }
479
480 #[tokio::test]
481 async fn add_catalog_field_unauthorized_propagates() {
482 let server = MockServer::start().await;
483 Mock::given(method("POST"))
484 .and(path("/catalogs/cardiology/fields"))
485 .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
486 .mount(&server)
487 .await;
488
489 let client = make_client(&server);
490 let field = CatalogField {
491 name: "x".into(),
492 field_type: CatalogFieldType::String,
493 };
494 let err = client
495 .add_catalog_field("cardiology", &field)
496 .await
497 .unwrap_err();
498 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
499 }
500
501 #[tokio::test]
502 async fn add_catalog_field_retries_on_429_then_succeeds() {
503 let server = MockServer::start().await;
504 Mock::given(method("POST"))
508 .and(path("/catalogs/cardiology/fields"))
509 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "ok"})))
510 .mount(&server)
511 .await;
512 Mock::given(method("POST"))
513 .and(path("/catalogs/cardiology/fields"))
514 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
515 .up_to_n_times(1)
516 .mount(&server)
517 .await;
518
519 let client = make_client(&server);
520 let field = CatalogField {
521 name: "x".into(),
522 field_type: CatalogFieldType::String,
523 };
524 client
525 .add_catalog_field("cardiology", &field)
526 .await
527 .unwrap();
528 }
529
530 #[tokio::test]
531 async fn create_catalog_happy_path_sends_correct_body() {
532 let server = MockServer::start().await;
533 Mock::given(method("POST"))
534 .and(path("/catalogs"))
535 .and(header("authorization", "Bearer test-key"))
536 .and(body_json(json!({
537 "catalogs": [{
538 "name": "cardiology",
539 "description": "Cardiology catalog",
540 "fields": [
541 {"name": "id", "type": "string"},
542 {"name": "severity_level", "type": "number"}
543 ]
544 }]
545 })))
546 .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "success"})))
547 .mount(&server)
548 .await;
549
550 let client = make_client(&server);
551 let cat = Catalog {
552 name: "cardiology".into(),
553 description: Some("Cardiology catalog".into()),
554 fields: vec![
555 CatalogField {
556 name: "id".into(),
557 field_type: CatalogFieldType::String,
558 },
559 CatalogField {
560 name: "severity_level".into(),
561 field_type: CatalogFieldType::Number,
562 },
563 ],
564 };
565 client.create_catalog(&cat).await.unwrap();
566 }
567
568 #[tokio::test]
569 async fn create_catalog_omits_description_when_none() {
570 let server = MockServer::start().await;
571 Mock::given(method("POST"))
572 .and(path("/catalogs"))
573 .and(body_json(json!({
574 "catalogs": [{
575 "name": "minimal",
576 "fields": [{"name": "id", "type": "string"}]
577 }]
578 })))
579 .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "success"})))
580 .mount(&server)
581 .await;
582
583 let client = make_client(&server);
584 let cat = Catalog {
585 name: "minimal".into(),
586 description: None,
587 fields: vec![CatalogField {
588 name: "id".into(),
589 field_type: CatalogFieldType::String,
590 }],
591 };
592 client.create_catalog(&cat).await.unwrap();
593 }
594
595 #[tokio::test]
596 async fn create_catalog_duplicate_name_propagates_400() {
597 let server = MockServer::start().await;
598 Mock::given(method("POST"))
599 .and(path("/catalogs"))
600 .respond_with(ResponseTemplate::new(400).set_body_json(json!({
601 "errors": [{
602 "id": "catalog-name-already-exists",
603 "message": "A catalog with that name already exists"
604 }]
605 })))
606 .mount(&server)
607 .await;
608
609 let client = make_client(&server);
610 let cat = Catalog {
611 name: "existing".into(),
612 description: None,
613 fields: vec![],
614 };
615 let err = client.create_catalog(&cat).await.unwrap_err();
616 assert!(
617 matches!(
618 &err,
619 BrazeApiError::Http { status, body }
620 if *status == StatusCode::BAD_REQUEST
621 && body.contains("catalog-name-already-exists")
622 ),
623 "got {err:?}"
624 );
625 }
626
627 #[tokio::test]
628 async fn create_catalog_unauthorized_propagates() {
629 let server = MockServer::start().await;
630 Mock::given(method("POST"))
631 .and(path("/catalogs"))
632 .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
633 .mount(&server)
634 .await;
635
636 let client = make_client(&server);
637 let cat = Catalog {
638 name: "x".into(),
639 description: None,
640 fields: vec![],
641 };
642 let err = client.create_catalog(&cat).await.unwrap_err();
643 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
644 }
645
646 #[tokio::test]
647 async fn create_catalog_retries_on_429_then_succeeds() {
648 let server = MockServer::start().await;
649 Mock::given(method("POST"))
650 .and(path("/catalogs"))
651 .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "ok"})))
652 .mount(&server)
653 .await;
654 Mock::given(method("POST"))
655 .and(path("/catalogs"))
656 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
657 .up_to_n_times(1)
658 .mount(&server)
659 .await;
660
661 let client = make_client(&server);
662 let cat = Catalog {
663 name: "x".into(),
664 description: None,
665 fields: vec![CatalogField {
666 name: "id".into(),
667 field_type: CatalogFieldType::String,
668 }],
669 };
670 client.create_catalog(&cat).await.unwrap();
671 }
672
673 #[tokio::test]
674 async fn delete_catalog_field_happy_path_uses_segment_encoded_path() {
675 let server = MockServer::start().await;
676 Mock::given(method("DELETE"))
677 .and(path("/catalogs/cardiology/fields/legacy_code"))
678 .and(header("authorization", "Bearer test-key"))
679 .respond_with(ResponseTemplate::new(204))
680 .mount(&server)
681 .await;
682
683 let client = make_client(&server);
684 client
685 .delete_catalog_field("cardiology", "legacy_code")
686 .await
687 .unwrap();
688 }
689
690 #[tokio::test]
691 async fn delete_catalog_field_server_error_returns_http() {
692 let server = MockServer::start().await;
693 Mock::given(method("DELETE"))
694 .and(path("/catalogs/cardiology/fields/x"))
695 .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
696 .mount(&server)
697 .await;
698
699 let client = make_client(&server);
700 let err = client
701 .delete_catalog_field("cardiology", "x")
702 .await
703 .unwrap_err();
704 match err {
705 BrazeApiError::Http { status, body } => {
706 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
707 assert!(body.contains("oops"));
708 }
709 other => panic!("expected Http, got {other:?}"),
710 }
711 }
712}