Skip to main content

braze_sync/braze/
catalog.rs

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