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 secrecy::SecretString;
128    use serde_json::json;
129    use url::Url;
130    use wiremock::matchers::{body_json, header, method, path};
131    use wiremock::{Mock, MockServer, ResponseTemplate};
132
133    fn make_client(server: &MockServer) -> BrazeClient {
134        BrazeClient::new(
135            Url::parse(&server.uri()).unwrap(),
136            SecretString::from("test-key".to_string()),
137            // Very high rpm so the limiter is effectively a no-op in tests.
138            10_000,
139        )
140    }
141
142    #[tokio::test]
143    async fn list_catalogs_happy_path() {
144        let server = MockServer::start().await;
145        Mock::given(method("GET"))
146            .and(path("/catalogs"))
147            .and(header("authorization", "Bearer test-key"))
148            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
149                "catalogs": [
150                    {
151                        "name": "cardiology",
152                        "description": "Cardiology catalog",
153                        "fields": [
154                            {"name": "id", "type": "string"},
155                            {"name": "score", "type": "number"}
156                        ]
157                    },
158                    {
159                        "name": "dermatology",
160                        "fields": [
161                            {"name": "id", "type": "string"}
162                        ]
163                    }
164                ],
165                "message": "success"
166            })))
167            .mount(&server)
168            .await;
169
170        let client = make_client(&server);
171        let cats = client.list_catalogs().await.unwrap();
172        assert_eq!(cats.len(), 2);
173        assert_eq!(cats[0].name, "cardiology");
174        assert_eq!(cats[0].description.as_deref(), Some("Cardiology catalog"));
175        assert_eq!(cats[0].fields.len(), 2);
176        assert_eq!(cats[0].fields[1].field_type, CatalogFieldType::Number);
177        assert_eq!(cats[1].name, "dermatology");
178        assert_eq!(cats[1].description, None);
179    }
180
181    #[tokio::test]
182    async fn list_catalogs_empty() {
183        let server = MockServer::start().await;
184        Mock::given(method("GET"))
185            .and(path("/catalogs"))
186            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
187            .mount(&server)
188            .await;
189        let client = make_client(&server);
190        let cats = client.list_catalogs().await.unwrap();
191        assert!(cats.is_empty());
192    }
193
194    #[tokio::test]
195    async fn list_catalogs_sets_user_agent() {
196        let server = MockServer::start().await;
197        Mock::given(method("GET"))
198            .and(path("/catalogs"))
199            .and(header(
200                "user-agent",
201                concat!("braze-sync/", env!("CARGO_PKG_VERSION")),
202            ))
203            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
204            .mount(&server)
205            .await;
206        let client = make_client(&server);
207        client.list_catalogs().await.unwrap();
208    }
209
210    #[tokio::test]
211    async fn list_catalogs_ignores_unknown_fields_in_response() {
212        // Forward compat: a future Braze response with extra fields
213        // (both at the top level and inside catalog entries) should
214        // still parse cleanly because no struct in the chain uses
215        // deny_unknown_fields.
216        let server = MockServer::start().await;
217        Mock::given(method("GET"))
218            .and(path("/catalogs"))
219            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
220                "catalogs": [
221                    {
222                        "name": "future",
223                        "description": "tomorrow",
224                        "future_metadata": {"foo": "bar"},
225                        "num_items": 1234,
226                        "fields": [
227                            {"name": "id", "type": "string", "extra": "ignored"}
228                        ]
229                    }
230                ],
231                "next_cursor": "abc",
232                "message": "success"
233            })))
234            .mount(&server)
235            .await;
236        let client = make_client(&server);
237        let cats = client.list_catalogs().await.unwrap();
238        assert_eq!(cats.len(), 1);
239        assert_eq!(cats[0].name, "future");
240    }
241
242    #[tokio::test]
243    async fn unauthorized_returns_typed_error() {
244        let server = MockServer::start().await;
245        Mock::given(method("GET"))
246            .and(path("/catalogs"))
247            .respond_with(ResponseTemplate::new(401).set_body_string("invalid api key"))
248            .mount(&server)
249            .await;
250        let client = make_client(&server);
251        let err = client.list_catalogs().await.unwrap_err();
252        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
253    }
254
255    #[tokio::test]
256    async fn server_error_carries_status_and_body() {
257        let server = MockServer::start().await;
258        Mock::given(method("GET"))
259            .and(path("/catalogs"))
260            .respond_with(ResponseTemplate::new(500).set_body_string("internal explosion"))
261            .mount(&server)
262            .await;
263        let client = make_client(&server);
264        let err = client.list_catalogs().await.unwrap_err();
265        match err {
266            BrazeApiError::Http { status, body } => {
267                assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
268                assert!(body.contains("internal explosion"));
269            }
270            other => panic!("expected Http, got {other:?}"),
271        }
272    }
273
274    #[tokio::test]
275    async fn retries_on_429_and_succeeds() {
276        let server = MockServer::start().await;
277        // wiremock matches the *most recently mounted* mock first; the
278        // limited 429 mock is mounted second so it preempts until used
279        // up, after which the success mock takes over.
280        Mock::given(method("GET"))
281            .and(path("/catalogs"))
282            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
283                "catalogs": [{"name": "after_retry", "fields": []}]
284            })))
285            .mount(&server)
286            .await;
287        Mock::given(method("GET"))
288            .and(path("/catalogs"))
289            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
290            .up_to_n_times(1)
291            .mount(&server)
292            .await;
293
294        let client = make_client(&server);
295        let cats = client.list_catalogs().await.unwrap();
296        assert_eq!(cats.len(), 1);
297        assert_eq!(cats[0].name, "after_retry");
298    }
299
300    #[tokio::test]
301    async fn retries_exhausted_returns_rate_limit_exhausted() {
302        let server = MockServer::start().await;
303        Mock::given(method("GET"))
304            .and(path("/catalogs"))
305            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
306            .mount(&server)
307            .await;
308        let client = make_client(&server);
309        let err = client.list_catalogs().await.unwrap_err();
310        assert!(
311            matches!(err, BrazeApiError::RateLimitExhausted),
312            "got {err:?}"
313        );
314    }
315
316    #[tokio::test]
317    async fn get_catalog_happy_path() {
318        let server = MockServer::start().await;
319        Mock::given(method("GET"))
320            .and(path("/catalogs/cardiology"))
321            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
322                "catalogs": [
323                    {"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
324                ]
325            })))
326            .mount(&server)
327            .await;
328        let client = make_client(&server);
329        let cat = client.get_catalog("cardiology").await.unwrap();
330        assert_eq!(cat.name, "cardiology");
331        assert_eq!(cat.fields.len(), 1);
332    }
333
334    #[tokio::test]
335    async fn get_catalog_404_is_mapped_to_not_found() {
336        let server = MockServer::start().await;
337        Mock::given(method("GET"))
338            .and(path("/catalogs/missing"))
339            .respond_with(ResponseTemplate::new(404).set_body_string("not found"))
340            .mount(&server)
341            .await;
342        let client = make_client(&server);
343        let err = client.get_catalog("missing").await.unwrap_err();
344        match err {
345            BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
346            other => panic!("expected NotFound, got {other:?}"),
347        }
348    }
349
350    #[tokio::test]
351    async fn get_catalog_empty_response_array_is_not_found() {
352        let server = MockServer::start().await;
353        Mock::given(method("GET"))
354            .and(path("/catalogs/ghost"))
355            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
356            .mount(&server)
357            .await;
358        let client = make_client(&server);
359        let err = client.get_catalog("ghost").await.unwrap_err();
360        assert!(matches!(err, BrazeApiError::NotFound { .. }), "got {err:?}");
361    }
362
363    #[tokio::test]
364    async fn debug_does_not_leak_api_key() {
365        let server = MockServer::start().await;
366        let client = make_client(&server);
367        let dbg = format!("{client:?}");
368        assert!(!dbg.contains("test-key"), "leaked api key in: {dbg}");
369        assert!(dbg.contains("<redacted>"));
370    }
371
372    #[tokio::test]
373    async fn add_catalog_field_happy_path_sends_correct_body() {
374        let server = MockServer::start().await;
375        Mock::given(method("POST"))
376            .and(path("/catalogs/cardiology/fields"))
377            .and(header("authorization", "Bearer test-key"))
378            .and(body_json(json!({
379                "fields": [{"name": "severity_level", "type": "number"}]
380            })))
381            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
382            .mount(&server)
383            .await;
384
385        let client = make_client(&server);
386        let field = CatalogField {
387            name: "severity_level".into(),
388            field_type: CatalogFieldType::Number,
389        };
390        client
391            .add_catalog_field("cardiology", &field)
392            .await
393            .unwrap();
394    }
395
396    #[tokio::test]
397    async fn add_catalog_field_unauthorized_propagates() {
398        let server = MockServer::start().await;
399        Mock::given(method("POST"))
400            .and(path("/catalogs/cardiology/fields"))
401            .respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
402            .mount(&server)
403            .await;
404
405        let client = make_client(&server);
406        let field = CatalogField {
407            name: "x".into(),
408            field_type: CatalogFieldType::String,
409        };
410        let err = client
411            .add_catalog_field("cardiology", &field)
412            .await
413            .unwrap_err();
414        assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
415    }
416
417    #[tokio::test]
418    async fn add_catalog_field_retries_on_429_then_succeeds() {
419        let server = MockServer::start().await;
420        // Success mounted first; the limited 429 mock is mounted second
421        // and wiremock matches the most-recently-mounted one until it
422        // exhausts its `up_to_n_times` budget.
423        Mock::given(method("POST"))
424            .and(path("/catalogs/cardiology/fields"))
425            .respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "ok"})))
426            .mount(&server)
427            .await;
428        Mock::given(method("POST"))
429            .and(path("/catalogs/cardiology/fields"))
430            .respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
431            .up_to_n_times(1)
432            .mount(&server)
433            .await;
434
435        let client = make_client(&server);
436        let field = CatalogField {
437            name: "x".into(),
438            field_type: CatalogFieldType::String,
439        };
440        client
441            .add_catalog_field("cardiology", &field)
442            .await
443            .unwrap();
444    }
445
446    #[tokio::test]
447    async fn delete_catalog_field_happy_path_uses_segment_encoded_path() {
448        let server = MockServer::start().await;
449        Mock::given(method("DELETE"))
450            .and(path("/catalogs/cardiology/fields/legacy_code"))
451            .and(header("authorization", "Bearer test-key"))
452            .respond_with(ResponseTemplate::new(204))
453            .mount(&server)
454            .await;
455
456        let client = make_client(&server);
457        client
458            .delete_catalog_field("cardiology", "legacy_code")
459            .await
460            .unwrap();
461    }
462
463    #[tokio::test]
464    async fn delete_catalog_field_server_error_returns_http() {
465        let server = MockServer::start().await;
466        Mock::given(method("DELETE"))
467            .and(path("/catalogs/cardiology/fields/x"))
468            .respond_with(ResponseTemplate::new(500).set_body_string("oops"))
469            .mount(&server)
470            .await;
471
472        let client = make_client(&server);
473        let err = client
474            .delete_catalog_field("cardiology", "x")
475            .await
476            .unwrap_err();
477        match err {
478            BrazeApiError::Http { status, body } => {
479                assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
480                assert!(body.contains("oops"));
481            }
482            other => panic!("expected Http, got {other:?}"),
483        }
484    }
485}