Skip to main content

braze_sync/braze/
catalog.rs

1//! Catalog Schema endpoints. See IMPLEMENTATION.md §8.3.
2//!
3//! Catalog **items** endpoints (list / upsert / delete items) are not
4//! wrapped by this client: braze-sync manages Braze configuration, not
5//! runtime data. See docs/scope-boundaries.md.
6
7use crate::braze::error::BrazeApiError;
8use crate::braze::BrazeClient;
9use crate::resource::{Catalog, CatalogField, CatalogFieldType};
10use reqwest::StatusCode;
11use serde::{Deserialize, Serialize};
12
13/// Wire shape of `GET /catalogs` and `GET /catalogs/{name}` responses.
14///
15/// **ASSUMED** based on IMPLEMENTATION.md §8.3 and Braze public docs.
16/// If the actual shape differs, only this struct and the wrapping
17/// logic in this file need to change.
18///
19/// Fields use serde defaults so an unexpected-but-related shape from
20/// Braze (e.g. an extra status field) doesn't break parsing.
21#[derive(Debug, Deserialize)]
22struct CatalogsResponse {
23    #[serde(default)]
24    catalogs: Vec<Catalog>,
25    /// Pagination cursor returned by Braze when more pages exist.
26    /// Its presence is the signal we use to fail closed — see
27    /// `list_catalogs`.
28    #[serde(default)]
29    next_cursor: Option<String>,
30}
31
32impl BrazeClient {
33    /// `GET /catalogs` — list every catalog schema in the workspace.
34    ///
35    /// Fails closed on `next_cursor` rather than returning page 1: a
36    /// partial view would let `apply` re-create page-2 catalogs and
37    /// mis-report drift. Mirrors `list_content_blocks`.
38    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    /// `GET /catalogs/{name}` — fetch a single catalog schema.
57    ///
58    /// 404 from Braze and an empty `catalogs` array in the response are
59    /// both mapped to [`BrazeApiError::NotFound`] so callers can branch
60    /// on "this catalog doesn't exist" without string matching on the
61    /// HTTP body.
62    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    /// `POST /catalogs/{name}/fields` — add one field to a catalog schema.
82    ///
83    /// **ASSUMED** wire format `{"fields": [{"name": "...", "type": "..."}]}`
84    /// per IMPLEMENTATION.md §8.3 + Braze public docs. v0.1.0 sends one
85    /// POST per added field.
86    pub async fn add_catalog_field(
87        &self,
88        catalog_name: &str,
89        field: &CatalogField,
90    ) -> Result<(), BrazeApiError> {
91        let body = AddFieldsRequest {
92            fields: vec![WireField {
93                name: &field.name,
94                field_type: field.field_type,
95            }],
96        };
97        let req = self.post(&["catalogs", catalog_name, "fields"]).json(&body);
98        self.send_ok(req).await
99    }
100
101    /// `DELETE /catalogs/{name}/fields/{field}` — remove a field. **Destructive**.
102    ///
103    /// 404 from Braze stays as `Http { status: 404, .. }` rather than
104    /// being mapped to `NotFound`. The use case is different from
105    /// get_catalog: a 404 here means "the field you wanted to delete is
106    /// already gone", which is a state-drift signal the user should see
107    /// rather than silently no-op. A future `--ignore-missing` flag in
108    /// `apply` can opt into idempotent behavior.
109    pub async fn delete_catalog_field(
110        &self,
111        catalog_name: &str,
112        field_name: &str,
113    ) -> Result<(), BrazeApiError> {
114        let req = self.delete(&["catalogs", catalog_name, "fields", field_name]);
115        self.send_ok(req).await
116    }
117}
118
119#[derive(Serialize)]
120struct AddFieldsRequest<'a> {
121    fields: Vec<WireField<'a>>,
122}
123
124#[derive(Serialize)]
125struct WireField<'a> {
126    name: &'a str,
127    /// Reuses the domain type's snake_case `Serialize` impl so the
128    /// wire string stays in sync with `CatalogFieldType` automatically.
129    #[serde(rename = "type")]
130    field_type: CatalogFieldType,
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::braze::test_client as make_client;
137    use serde_json::json;
138    use wiremock::matchers::{body_json, header, method, path};
139    use wiremock::{Mock, MockServer, ResponseTemplate};
140
141    #[tokio::test]
142    async fn list_catalogs_happy_path() {
143        let server = MockServer::start().await;
144        Mock::given(method("GET"))
145            .and(path("/catalogs"))
146            .and(header("authorization", "Bearer test-key"))
147            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
148                "catalogs": [
149                    {
150                        "name": "cardiology",
151                        "description": "Cardiology catalog",
152                        "fields": [
153                            {"name": "id", "type": "string"},
154                            {"name": "score", "type": "number"}
155                        ]
156                    },
157                    {
158                        "name": "dermatology",
159                        "fields": [
160                            {"name": "id", "type": "string"}
161                        ]
162                    }
163                ],
164                "message": "success"
165            })))
166            .mount(&server)
167            .await;
168
169        let client = make_client(&server);
170        let cats = client.list_catalogs().await.unwrap();
171        assert_eq!(cats.len(), 2);
172        assert_eq!(cats[0].name, "cardiology");
173        assert_eq!(cats[0].description.as_deref(), Some("Cardiology catalog"));
174        assert_eq!(cats[0].fields.len(), 2);
175        assert_eq!(cats[0].fields[1].field_type, CatalogFieldType::Number);
176        assert_eq!(cats[1].name, "dermatology");
177        assert_eq!(cats[1].description, None);
178    }
179
180    #[tokio::test]
181    async fn list_catalogs_empty() {
182        let server = MockServer::start().await;
183        Mock::given(method("GET"))
184            .and(path("/catalogs"))
185            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
186            .mount(&server)
187            .await;
188        let client = make_client(&server);
189        let cats = client.list_catalogs().await.unwrap();
190        assert!(cats.is_empty());
191    }
192
193    #[tokio::test]
194    async fn list_catalogs_sets_user_agent() {
195        let server = MockServer::start().await;
196        Mock::given(method("GET"))
197            .and(path("/catalogs"))
198            .and(header(
199                "user-agent",
200                concat!("braze-sync/", env!("CARGO_PKG_VERSION")),
201            ))
202            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
203            .mount(&server)
204            .await;
205        let client = make_client(&server);
206        client.list_catalogs().await.unwrap();
207    }
208
209    #[tokio::test]
210    async fn list_catalogs_ignores_unknown_fields_in_response() {
211        // Forward compat: a future Braze response with extra fields
212        // (top-level and inside catalog entries) should still parse
213        // because no struct in the chain uses deny_unknown_fields.
214        let server = MockServer::start().await;
215        Mock::given(method("GET"))
216            .and(path("/catalogs"))
217            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
218                "catalogs": [
219                    {
220                        "name": "future",
221                        "description": "tomorrow",
222                        "future_metadata": {"foo": "bar"},
223                        "num_items": 1234,
224                        "fields": [
225                            {"name": "id", "type": "string", "extra": "ignored"}
226                        ]
227                    }
228                ],
229                "future_top_level": {"whatever": true},
230                "message": "success"
231            })))
232            .mount(&server)
233            .await;
234        let client = make_client(&server);
235        let cats = client.list_catalogs().await.unwrap();
236        assert_eq!(cats.len(), 1);
237        assert_eq!(cats[0].name, "future");
238    }
239
240    #[tokio::test]
241    async fn list_catalogs_errors_when_next_cursor_present() {
242        // Regression guard: v0.2.0 silently returned page 1 here.
243        let server = MockServer::start().await;
244        Mock::given(method("GET"))
245            .and(path("/catalogs"))
246            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
247                "catalogs": [
248                    {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
249                ],
250                "next_cursor": "abc123"
251            })))
252            .mount(&server)
253            .await;
254        let client = make_client(&server);
255        let err = client.list_catalogs().await.unwrap_err();
256        match err {
257            BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
258                assert_eq!(endpoint, "/catalogs");
259                assert!(detail.contains("next_cursor"), "detail: {detail}");
260                assert!(detail.contains("1 catalog"), "detail: {detail}");
261            }
262            other => panic!("expected PaginationNotImplemented, got {other:?}"),
263        }
264    }
265
266    #[tokio::test]
267    async fn list_catalogs_empty_string_cursor_is_treated_as_no_more_pages() {
268        // Some paginated APIs return `next_cursor: ""` on the last
269        // page instead of omitting the field. Treat that as "no more
270        // pages" rather than tripping the fail-closed guard — the
271        // alternative would turn every workspace under one page into
272        // an error.
273        let server = MockServer::start().await;
274        Mock::given(method("GET"))
275            .and(path("/catalogs"))
276            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
277                "catalogs": [{"name": "only", "fields": []}],
278                "next_cursor": ""
279            })))
280            .mount(&server)
281            .await;
282        let client = make_client(&server);
283        let cats = client.list_catalogs().await.unwrap();
284        assert_eq!(cats.len(), 1);
285        assert_eq!(cats[0].name, "only");
286    }
287
288    #[tokio::test]
289    async fn unauthorized_returns_typed_error() {
290        let server = MockServer::start().await;
291        Mock::given(method("GET"))
292            .and(path("/catalogs"))
293            .respond_with(ResponseTemplate::new(401).set_body_string("invalid api key"))
294            .mount(&server)
295            .await;
296        let client = make_client(&server);
297        let err = client.list_catalogs().await.unwrap_err();
298        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
299    }
300
301    #[tokio::test]
302    async fn server_error_carries_status_and_body() {
303        let server = MockServer::start().await;
304        Mock::given(method("GET"))
305            .and(path("/catalogs"))
306            .respond_with(ResponseTemplate::new(500).set_body_string("internal explosion"))
307            .mount(&server)
308            .await;
309        let client = make_client(&server);
310        let err = client.list_catalogs().await.unwrap_err();
311        match err {
312            BrazeApiError::Http { status, body } => {
313                assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
314                assert!(body.contains("internal explosion"));
315            }
316            other => panic!("expected Http, got {other:?}"),
317        }
318    }
319
320    #[tokio::test]
321    async fn retries_on_429_and_succeeds() {
322        let server = MockServer::start().await;
323        // wiremock matches the *most recently mounted* mock first; the
324        // limited 429 mock is mounted second so it preempts until used
325        // up, after which the success mock takes over.
326        Mock::given(method("GET"))
327            .and(path("/catalogs"))
328            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
329                "catalogs": [{"name": "after_retry", "fields": []}]
330            })))
331            .mount(&server)
332            .await;
333        Mock::given(method("GET"))
334            .and(path("/catalogs"))
335            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
336            .up_to_n_times(1)
337            .mount(&server)
338            .await;
339
340        let client = make_client(&server);
341        let cats = client.list_catalogs().await.unwrap();
342        assert_eq!(cats.len(), 1);
343        assert_eq!(cats[0].name, "after_retry");
344    }
345
346    #[tokio::test]
347    async fn retries_exhausted_returns_rate_limit_exhausted() {
348        let server = MockServer::start().await;
349        Mock::given(method("GET"))
350            .and(path("/catalogs"))
351            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
352            .mount(&server)
353            .await;
354        let client = make_client(&server);
355        let err = client.list_catalogs().await.unwrap_err();
356        assert!(
357            matches!(err, BrazeApiError::RateLimitExhausted),
358            "got {err:?}"
359        );
360    }
361
362    #[tokio::test]
363    async fn get_catalog_happy_path() {
364        let server = MockServer::start().await;
365        Mock::given(method("GET"))
366            .and(path("/catalogs/cardiology"))
367            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
368                "catalogs": [
369                    {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
370                ]
371            })))
372            .mount(&server)
373            .await;
374        let client = make_client(&server);
375        let cat = client.get_catalog("cardiology").await.unwrap();
376        assert_eq!(cat.name, "cardiology");
377        assert_eq!(cat.fields.len(), 1);
378    }
379
380    #[tokio::test]
381    async fn get_catalog_404_is_mapped_to_not_found() {
382        let server = MockServer::start().await;
383        Mock::given(method("GET"))
384            .and(path("/catalogs/missing"))
385            .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
386            .mount(&server)
387            .await;
388        let client = make_client(&server);
389        let err = client.get_catalog("missing").await.unwrap_err();
390        match err {
391            BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
392            other => panic!("expected NotFound, got {other:?}"),
393        }
394    }
395
396    #[tokio::test]
397    async fn get_catalog_empty_response_array_is_not_found() {
398        let server = MockServer::start().await;
399        Mock::given(method("GET"))
400            .and(path("/catalogs/ghost"))
401            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
402            .mount(&server)
403            .await;
404        let client = make_client(&server);
405        let err = client.get_catalog("ghost").await.unwrap_err();
406        assert!(matches!(err, BrazeApiError::NotFound { .. }), "got {err:?}");
407    }
408
409    #[tokio::test]
410    async fn debug_does_not_leak_api_key() {
411        let server = MockServer::start().await;
412        let client = make_client(&server);
413        let dbg = format!("{client:?}");
414        assert!(!dbg.contains("test-key"), "leaked api key in: {dbg}");
415        assert!(dbg.contains("<redacted>"));
416    }
417
418    #[tokio::test]
419    async fn add_catalog_field_happy_path_sends_correct_body() {
420        let server = MockServer::start().await;
421        Mock::given(method("POST"))
422            .and(path("/catalogs/cardiology/fields"))
423            .and(header("authorization", "Bearer test-key"))
424            .and(body_json(json!({
425                "fields": [{"name": "severity_level", "type": "number"}]
426            })))
427            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
428            .mount(&server)
429            .await;
430
431        let client = make_client(&server);
432        let field = CatalogField {
433            name: "severity_level".into(),
434            field_type: CatalogFieldType::Number,
435        };
436        client
437            .add_catalog_field("cardiology", &field)
438            .await
439            .unwrap();
440    }
441
442    #[tokio::test]
443    async fn add_catalog_field_unauthorized_propagates() {
444        let server = MockServer::start().await;
445        Mock::given(method("POST"))
446            .and(path("/catalogs/cardiology/fields"))
447            .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
448            .mount(&server)
449            .await;
450
451        let client = make_client(&server);
452        let field = CatalogField {
453            name: "x".into(),
454            field_type: CatalogFieldType::String,
455        };
456        let err = client
457            .add_catalog_field("cardiology", &field)
458            .await
459            .unwrap_err();
460        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
461    }
462
463    #[tokio::test]
464    async fn add_catalog_field_retries_on_429_then_succeeds() {
465        let server = MockServer::start().await;
466        // Success mounted first; the limited 429 mock is mounted second
467        // and wiremock matches the most-recently-mounted one until it
468        // exhausts its `up_to_n_times` budget.
469        Mock::given(method("POST"))
470            .and(path("/catalogs/cardiology/fields"))
471            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "ok"})))
472            .mount(&server)
473            .await;
474        Mock::given(method("POST"))
475            .and(path("/catalogs/cardiology/fields"))
476            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
477            .up_to_n_times(1)
478            .mount(&server)
479            .await;
480
481        let client = make_client(&server);
482        let field = CatalogField {
483            name: "x".into(),
484            field_type: CatalogFieldType::String,
485        };
486        client
487            .add_catalog_field("cardiology", &field)
488            .await
489            .unwrap();
490    }
491
492    #[tokio::test]
493    async fn delete_catalog_field_happy_path_uses_segment_encoded_path() {
494        let server = MockServer::start().await;
495        Mock::given(method("DELETE"))
496            .and(path("/catalogs/cardiology/fields/legacy_code"))
497            .and(header("authorization", "Bearer test-key"))
498            .respond_with(ResponseTemplate::new(204))
499            .mount(&server)
500            .await;
501
502        let client = make_client(&server);
503        client
504            .delete_catalog_field("cardiology", "legacy_code")
505            .await
506            .unwrap();
507    }
508
509    #[tokio::test]
510    async fn delete_catalog_field_server_error_returns_http() {
511        let server = MockServer::start().await;
512        Mock::given(method("DELETE"))
513            .and(path("/catalogs/cardiology/fields/x"))
514            .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
515            .mount(&server)
516            .await;
517
518        let client = make_client(&server);
519        let err = client
520            .delete_catalog_field("cardiology", "x")
521            .await
522            .unwrap_err();
523        match err {
524            BrazeApiError::Http { status, body } => {
525                assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
526                assert!(body.contains("oops"));
527            }
528            other => panic!("expected Http, got {other:?}"),
529        }
530    }
531}