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