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> {
94 let normalized = catalog.normalized();
95 let body = CreateCatalogRequest {
96 catalogs: vec![CreateCatalogEntry {
97 name: &normalized.name,
98 description: normalized.description.as_deref(),
99 fields: normalized
100 .fields
101 .iter()
102 .map(|f| WireField {
103 name: &f.name,
104 field_type: f.field_type,
105 })
106 .collect(),
107 }],
108 };
109 let req = self.post(&["catalogs"]).json(&body);
110 self.send_ok(req).await
111 }
112
113 pub async fn add_catalog_field(
119 &self,
120 catalog_name: &str,
121 field: &CatalogField,
122 ) -> Result<(), BrazeApiError> {
123 let body = AddFieldsRequest {
124 fields: vec![WireField {
125 name: &field.name,
126 field_type: field.field_type,
127 }],
128 };
129 let req = self.post(&["catalogs", catalog_name, "fields"]).json(&body);
130 self.send_ok(req).await
131 }
132
133 pub async fn delete_catalog_field(
142 &self,
143 catalog_name: &str,
144 field_name: &str,
145 ) -> Result<(), BrazeApiError> {
146 let req = self.delete(&["catalogs", catalog_name, "fields", field_name]);
147 self.send_ok(req).await
148 }
149}
150
151#[derive(Serialize)]
152struct AddFieldsRequest<'a> {
153 fields: Vec<WireField<'a>>,
154}
155
156#[derive(Serialize)]
157struct CreateCatalogRequest<'a> {
158 catalogs: Vec<CreateCatalogEntry<'a>>,
159}
160
161#[derive(Serialize)]
162struct CreateCatalogEntry<'a> {
163 name: &'a str,
164 #[serde(skip_serializing_if = "Option::is_none")]
165 description: Option<&'a str>,
166 fields: Vec<WireField<'a>>,
167}
168
169#[derive(Serialize)]
170struct WireField<'a> {
171 name: &'a str,
172 #[serde(rename = "type")]
175 field_type: CatalogFieldType,
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::braze::test_client as make_client;
182 use serde_json::json;
183 use wiremock::matchers::{body_json, header, method, path};
184 use wiremock::{Mock, MockServer, ResponseTemplate};
185
186 #[tokio::test]
187 async fn list_catalogs_happy_path() {
188 let server = MockServer::start().await;
189 Mock::given(method("GET"))
190 .and(path("/catalogs"))
191 .and(header("authorization", "Bearer test-key"))
192 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
193 "catalogs": [
194 {
195 "name": "cardiology",
196 "description": "Cardiology catalog",
197 "fields": [
198 {"name": "id", "type": "string"},
199 {"name": "score", "type": "number"}
200 ]
201 },
202 {
203 "name": "dermatology",
204 "fields": [
205 {"name": "id", "type": "string"}
206 ]
207 }
208 ],
209 "message": "success"
210 })))
211 .mount(&server)
212 .await;
213
214 let client = make_client(&server);
215 let cats = client.list_catalogs().await.unwrap();
216 assert_eq!(cats.len(), 2);
217 assert_eq!(cats[0].name, "cardiology");
218 assert_eq!(cats[0].description.as_deref(), Some("Cardiology catalog"));
219 assert_eq!(cats[0].fields.len(), 2);
220 assert_eq!(cats[0].fields[1].field_type, CatalogFieldType::Number);
221 assert_eq!(cats[1].name, "dermatology");
222 assert_eq!(cats[1].description, None);
223 }
224
225 #[tokio::test]
226 async fn list_catalogs_empty() {
227 let server = MockServer::start().await;
228 Mock::given(method("GET"))
229 .and(path("/catalogs"))
230 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
231 .mount(&server)
232 .await;
233 let client = make_client(&server);
234 let cats = client.list_catalogs().await.unwrap();
235 assert!(cats.is_empty());
236 }
237
238 #[tokio::test]
239 async fn list_catalogs_sets_user_agent() {
240 let server = MockServer::start().await;
241 Mock::given(method("GET"))
242 .and(path("/catalogs"))
243 .and(header(
244 "user-agent",
245 concat!("braze-sync/", env!("CARGO_PKG_VERSION")),
246 ))
247 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
248 .mount(&server)
249 .await;
250 let client = make_client(&server);
251 client.list_catalogs().await.unwrap();
252 }
253
254 #[tokio::test]
255 async fn list_catalogs_ignores_unknown_fields_in_response() {
256 let server = MockServer::start().await;
260 Mock::given(method("GET"))
261 .and(path("/catalogs"))
262 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
263 "catalogs": [
264 {
265 "name": "future",
266 "description": "tomorrow",
267 "future_metadata": {"foo": "bar"},
268 "num_items": 1234,
269 "fields": [
270 {"name": "id", "type": "string", "extra": "ignored"}
271 ]
272 }
273 ],
274 "future_top_level": {"whatever": true},
275 "message": "success"
276 })))
277 .mount(&server)
278 .await;
279 let client = make_client(&server);
280 let cats = client.list_catalogs().await.unwrap();
281 assert_eq!(cats.len(), 1);
282 assert_eq!(cats[0].name, "future");
283 }
284
285 #[tokio::test]
286 async fn list_catalogs_errors_when_next_cursor_present() {
287 let server = MockServer::start().await;
289 Mock::given(method("GET"))
290 .and(path("/catalogs"))
291 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
292 "catalogs": [
293 {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
294 ],
295 "next_cursor": "abc123"
296 })))
297 .mount(&server)
298 .await;
299 let client = make_client(&server);
300 let err = client.list_catalogs().await.unwrap_err();
301 match err {
302 BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
303 assert_eq!(endpoint, "/catalogs");
304 assert!(detail.contains("next_cursor"), "detail: {detail}");
305 assert!(detail.contains("1 catalog"), "detail: {detail}");
306 }
307 other => panic!("expected PaginationNotImplemented, got {other:?}"),
308 }
309 }
310
311 #[tokio::test]
312 async fn list_catalogs_empty_string_cursor_is_treated_as_no_more_pages() {
313 let server = MockServer::start().await;
319 Mock::given(method("GET"))
320 .and(path("/catalogs"))
321 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
322 "catalogs": [{"name": "only", "fields": []}],
323 "next_cursor": ""
324 })))
325 .mount(&server)
326 .await;
327 let client = make_client(&server);
328 let cats = client.list_catalogs().await.unwrap();
329 assert_eq!(cats.len(), 1);
330 assert_eq!(cats[0].name, "only");
331 }
332
333 #[tokio::test]
334 async fn unauthorized_returns_typed_error() {
335 let server = MockServer::start().await;
336 Mock::given(method("GET"))
337 .and(path("/catalogs"))
338 .respond_with(ResponseTemplate::new(401).set_body_string("invalid api key"))
339 .mount(&server)
340 .await;
341 let client = make_client(&server);
342 let err = client.list_catalogs().await.unwrap_err();
343 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
344 }
345
346 #[tokio::test]
347 async fn server_error_carries_status_and_body() {
348 let server = MockServer::start().await;
349 Mock::given(method("GET"))
350 .and(path("/catalogs"))
351 .respond_with(ResponseTemplate::new(500).set_body_string("internal explosion"))
352 .mount(&server)
353 .await;
354 let client = make_client(&server);
355 let err = client.list_catalogs().await.unwrap_err();
356 match err {
357 BrazeApiError::Http { status, body } => {
358 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
359 assert!(body.contains("internal explosion"));
360 }
361 other => panic!("expected Http, got {other:?}"),
362 }
363 }
364
365 #[tokio::test]
366 async fn retries_on_429_and_succeeds() {
367 let server = MockServer::start().await;
368 Mock::given(method("GET"))
372 .and(path("/catalogs"))
373 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
374 "catalogs": [{"name": "after_retry", "fields": []}]
375 })))
376 .mount(&server)
377 .await;
378 Mock::given(method("GET"))
379 .and(path("/catalogs"))
380 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
381 .up_to_n_times(1)
382 .mount(&server)
383 .await;
384
385 let client = make_client(&server);
386 let cats = client.list_catalogs().await.unwrap();
387 assert_eq!(cats.len(), 1);
388 assert_eq!(cats[0].name, "after_retry");
389 }
390
391 #[tokio::test]
392 async fn retries_exhausted_returns_rate_limit_exhausted() {
393 let server = MockServer::start().await;
394 Mock::given(method("GET"))
395 .and(path("/catalogs"))
396 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
397 .mount(&server)
398 .await;
399 let client = make_client(&server);
400 let err = client.list_catalogs().await.unwrap_err();
401 assert!(
402 matches!(err, BrazeApiError::RateLimitExhausted),
403 "got {err:?}"
404 );
405 }
406
407 #[tokio::test]
408 async fn get_catalog_happy_path() {
409 let server = MockServer::start().await;
410 Mock::given(method("GET"))
411 .and(path("/catalogs/cardiology"))
412 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
413 "catalogs": [
414 {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
415 ]
416 })))
417 .mount(&server)
418 .await;
419 let client = make_client(&server);
420 let cat = client.get_catalog("cardiology").await.unwrap();
421 assert_eq!(cat.name, "cardiology");
422 assert_eq!(cat.fields.len(), 1);
423 }
424
425 #[tokio::test]
426 async fn get_catalog_404_is_mapped_to_not_found() {
427 let server = MockServer::start().await;
428 Mock::given(method("GET"))
429 .and(path("/catalogs/missing"))
430 .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
431 .mount(&server)
432 .await;
433 let client = make_client(&server);
434 let err = client.get_catalog("missing").await.unwrap_err();
435 match err {
436 BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
437 other => panic!("expected NotFound, got {other:?}"),
438 }
439 }
440
441 #[tokio::test]
442 async fn get_catalog_empty_response_array_is_not_found() {
443 let server = MockServer::start().await;
444 Mock::given(method("GET"))
445 .and(path("/catalogs/ghost"))
446 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
447 .mount(&server)
448 .await;
449 let client = make_client(&server);
450 let err = client.get_catalog("ghost").await.unwrap_err();
451 assert!(matches!(err, BrazeApiError::NotFound { .. }), "got {err:?}");
452 }
453
454 #[tokio::test]
455 async fn debug_does_not_leak_api_key() {
456 let server = MockServer::start().await;
457 let client = make_client(&server);
458 let dbg = format!("{client:?}");
459 assert!(!dbg.contains("test-key"), "leaked api key in: {dbg}");
460 assert!(dbg.contains("<redacted>"));
461 }
462
463 #[tokio::test]
464 async fn add_catalog_field_happy_path_sends_correct_body() {
465 let server = MockServer::start().await;
466 Mock::given(method("POST"))
467 .and(path("/catalogs/cardiology/fields"))
468 .and(header("authorization", "Bearer test-key"))
469 .and(body_json(json!({
470 "fields": [{"name": "severity_level", "type": "number"}]
471 })))
472 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
473 .mount(&server)
474 .await;
475
476 let client = make_client(&server);
477 let field = CatalogField {
478 name: "severity_level".into(),
479 field_type: CatalogFieldType::Number,
480 };
481 client
482 .add_catalog_field("cardiology", &field)
483 .await
484 .unwrap();
485 }
486
487 #[tokio::test]
488 async fn add_catalog_field_unauthorized_propagates() {
489 let server = MockServer::start().await;
490 Mock::given(method("POST"))
491 .and(path("/catalogs/cardiology/fields"))
492 .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
493 .mount(&server)
494 .await;
495
496 let client = make_client(&server);
497 let field = CatalogField {
498 name: "x".into(),
499 field_type: CatalogFieldType::String,
500 };
501 let err = client
502 .add_catalog_field("cardiology", &field)
503 .await
504 .unwrap_err();
505 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
506 }
507
508 #[tokio::test]
509 async fn add_catalog_field_retries_on_429_then_succeeds() {
510 let server = MockServer::start().await;
511 Mock::given(method("POST"))
515 .and(path("/catalogs/cardiology/fields"))
516 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "ok"})))
517 .mount(&server)
518 .await;
519 Mock::given(method("POST"))
520 .and(path("/catalogs/cardiology/fields"))
521 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
522 .up_to_n_times(1)
523 .mount(&server)
524 .await;
525
526 let client = make_client(&server);
527 let field = CatalogField {
528 name: "x".into(),
529 field_type: CatalogFieldType::String,
530 };
531 client
532 .add_catalog_field("cardiology", &field)
533 .await
534 .unwrap();
535 }
536
537 #[tokio::test]
538 async fn create_catalog_happy_path_sends_correct_body() {
539 let server = MockServer::start().await;
540 Mock::given(method("POST"))
541 .and(path("/catalogs"))
542 .and(header("authorization", "Bearer test-key"))
543 .and(body_json(json!({
544 "catalogs": [{
545 "name": "cardiology",
546 "description": "Cardiology catalog",
547 "fields": [
548 {"name": "id", "type": "string"},
549 {"name": "severity_level", "type": "number"}
550 ]
551 }]
552 })))
553 .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "success"})))
554 .mount(&server)
555 .await;
556
557 let client = make_client(&server);
558 let cat = Catalog {
559 name: "cardiology".into(),
560 description: Some("Cardiology catalog".into()),
561 fields: vec![
562 CatalogField {
563 name: "id".into(),
564 field_type: CatalogFieldType::String,
565 },
566 CatalogField {
567 name: "severity_level".into(),
568 field_type: CatalogFieldType::Number,
569 },
570 ],
571 };
572 client.create_catalog(&cat).await.unwrap();
573 }
574
575 #[tokio::test]
576 async fn create_catalog_hoists_id_field_to_first_position() {
577 let server = MockServer::start().await;
582 Mock::given(method("POST"))
583 .and(path("/catalogs"))
584 .and(body_json(json!({
585 "catalogs": [{
586 "name": "alpha",
587 "fields": [
588 {"name": "id", "type": "string"},
589 {"name": "URL", "type": "string"},
590 {"name": "author", "type": "string"},
591 {"name": "title", "type": "string"}
592 ]
593 }]
594 })))
595 .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "success"})))
596 .mount(&server)
597 .await;
598
599 let client = make_client(&server);
600 let cat = Catalog {
601 name: "alpha".into(),
602 description: None,
603 fields: vec![
604 CatalogField {
605 name: "URL".into(),
606 field_type: CatalogFieldType::String,
607 },
608 CatalogField {
609 name: "author".into(),
610 field_type: CatalogFieldType::String,
611 },
612 CatalogField {
613 name: "id".into(),
614 field_type: CatalogFieldType::String,
615 },
616 CatalogField {
617 name: "title".into(),
618 field_type: CatalogFieldType::String,
619 },
620 ],
621 };
622 client.create_catalog(&cat).await.unwrap();
623 }
624
625 #[tokio::test]
626 async fn create_catalog_omits_description_when_none() {
627 let server = MockServer::start().await;
628 Mock::given(method("POST"))
629 .and(path("/catalogs"))
630 .and(body_json(json!({
631 "catalogs": [{
632 "name": "minimal",
633 "fields": [{"name": "id", "type": "string"}]
634 }]
635 })))
636 .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "success"})))
637 .mount(&server)
638 .await;
639
640 let client = make_client(&server);
641 let cat = Catalog {
642 name: "minimal".into(),
643 description: None,
644 fields: vec![CatalogField {
645 name: "id".into(),
646 field_type: CatalogFieldType::String,
647 }],
648 };
649 client.create_catalog(&cat).await.unwrap();
650 }
651
652 #[tokio::test]
653 async fn create_catalog_duplicate_name_propagates_400() {
654 let server = MockServer::start().await;
655 Mock::given(method("POST"))
656 .and(path("/catalogs"))
657 .respond_with(ResponseTemplate::new(400).set_body_json(json!({
658 "errors": [{
659 "id": "catalog-name-already-exists",
660 "message": "A catalog with that name already exists"
661 }]
662 })))
663 .mount(&server)
664 .await;
665
666 let client = make_client(&server);
667 let cat = Catalog {
668 name: "existing".into(),
669 description: None,
670 fields: vec![],
671 };
672 let err = client.create_catalog(&cat).await.unwrap_err();
673 assert!(
674 matches!(
675 &err,
676 BrazeApiError::Http { status, body }
677 if *status == StatusCode::BAD_REQUEST
678 && body.contains("catalog-name-already-exists")
679 ),
680 "got {err:?}"
681 );
682 }
683
684 #[tokio::test]
685 async fn create_catalog_unauthorized_propagates() {
686 let server = MockServer::start().await;
687 Mock::given(method("POST"))
688 .and(path("/catalogs"))
689 .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
690 .mount(&server)
691 .await;
692
693 let client = make_client(&server);
694 let cat = Catalog {
695 name: "x".into(),
696 description: None,
697 fields: vec![],
698 };
699 let err = client.create_catalog(&cat).await.unwrap_err();
700 assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
701 }
702
703 #[tokio::test]
704 async fn create_catalog_retries_on_429_then_succeeds() {
705 let server = MockServer::start().await;
706 Mock::given(method("POST"))
707 .and(path("/catalogs"))
708 .respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "ok"})))
709 .mount(&server)
710 .await;
711 Mock::given(method("POST"))
712 .and(path("/catalogs"))
713 .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
714 .up_to_n_times(1)
715 .mount(&server)
716 .await;
717
718 let client = make_client(&server);
719 let cat = Catalog {
720 name: "x".into(),
721 description: None,
722 fields: vec![CatalogField {
723 name: "id".into(),
724 field_type: CatalogFieldType::String,
725 }],
726 };
727 client.create_catalog(&cat).await.unwrap();
728 }
729
730 #[tokio::test]
731 async fn delete_catalog_field_happy_path_uses_segment_encoded_path() {
732 let server = MockServer::start().await;
733 Mock::given(method("DELETE"))
734 .and(path("/catalogs/cardiology/fields/legacy_code"))
735 .and(header("authorization", "Bearer test-key"))
736 .respond_with(ResponseTemplate::new(204))
737 .mount(&server)
738 .await;
739
740 let client = make_client(&server);
741 client
742 .delete_catalog_field("cardiology", "legacy_code")
743 .await
744 .unwrap();
745 }
746
747 #[tokio::test]
748 async fn delete_catalog_field_server_error_returns_http() {
749 let server = MockServer::start().await;
750 Mock::given(method("DELETE"))
751 .and(path("/catalogs/cardiology/fields/x"))
752 .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
753 .mount(&server)
754 .await;
755
756 let client = make_client(&server);
757 let err = client
758 .delete_catalog_field("cardiology", "x")
759 .await
760 .unwrap_err();
761 match err {
762 BrazeApiError::Http { status, body } => {
763 assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
764 assert!(body.contains("oops"));
765 }
766 other => panic!("expected Http, got {other:?}"),
767 }
768 }
769}