use crate::braze::error::BrazeApiError;
use crate::braze::BrazeClient;
use crate::resource::{Catalog, CatalogField, CatalogFieldType};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
struct CatalogsResponse {
#[serde(default)]
catalogs: Vec<Catalog>,
#[serde(default)]
next_cursor: Option<String>,
}
impl BrazeClient {
pub async fn list_catalogs(&self) -> Result<Vec<Catalog>, BrazeApiError> {
let req = self.get(&["catalogs"]);
let resp: CatalogsResponse = self.send_json(req).await?;
if let Some(cursor) = resp.next_cursor.as_deref() {
if !cursor.is_empty() {
return Err(BrazeApiError::PaginationNotImplemented {
endpoint: "/catalogs",
detail: format!(
"got {} catalog(s) plus a non-empty next_cursor; \
aborting to prevent silent truncation",
resp.catalogs.len()
),
});
}
}
Ok(resp.catalogs)
}
pub async fn get_catalog(&self, name: &str) -> Result<Catalog, BrazeApiError> {
let req = self.get(&["catalogs", name]);
match self.send_json::<CatalogsResponse>(req).await {
Ok(resp) => resp
.catalogs
.into_iter()
.next()
.ok_or_else(|| BrazeApiError::NotFound {
resource: format!("catalog '{name}'"),
}),
Err(BrazeApiError::Http { status, .. }) if status == StatusCode::NOT_FOUND => {
Err(BrazeApiError::NotFound {
resource: format!("catalog '{name}'"),
})
}
Err(e) => Err(e),
}
}
pub async fn create_catalog(&self, catalog: &Catalog) -> Result<(), BrazeApiError> {
let normalized = catalog.normalized();
let body = CreateCatalogRequest {
catalogs: vec![CreateCatalogEntry {
name: &normalized.name,
description: normalized.description.as_deref(),
fields: normalized
.fields
.iter()
.map(|f| WireField {
name: &f.name,
field_type: f.field_type,
})
.collect(),
}],
};
let req = self.post(&["catalogs"]).json(&body);
self.send_ok(req).await
}
pub async fn add_catalog_field(
&self,
catalog_name: &str,
field: &CatalogField,
) -> Result<(), BrazeApiError> {
let body = AddFieldsRequest {
fields: vec![WireField {
name: &field.name,
field_type: field.field_type,
}],
};
let req = self.post(&["catalogs", catalog_name, "fields"]).json(&body);
self.send_ok(req).await
}
pub async fn delete_catalog_field(
&self,
catalog_name: &str,
field_name: &str,
) -> Result<(), BrazeApiError> {
let req = self.delete(&["catalogs", catalog_name, "fields", field_name]);
self.send_ok(req).await
}
}
#[derive(Serialize)]
struct AddFieldsRequest<'a> {
fields: Vec<WireField<'a>>,
}
#[derive(Serialize)]
struct CreateCatalogRequest<'a> {
catalogs: Vec<CreateCatalogEntry<'a>>,
}
#[derive(Serialize)]
struct CreateCatalogEntry<'a> {
name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<&'a str>,
fields: Vec<WireField<'a>>,
}
#[derive(Serialize)]
struct WireField<'a> {
name: &'a str,
#[serde(rename = "type")]
field_type: CatalogFieldType,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::braze::test_client as make_client;
use serde_json::json;
use wiremock::matchers::{body_json, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn list_catalogs_happy_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.and(header("authorization", "Bearer test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"catalogs": [
{
"name": "cardiology",
"description": "Cardiology catalog",
"fields": [
{"name": "id", "type": "string"},
{"name": "score", "type": "number"}
]
},
{
"name": "dermatology",
"fields": [
{"name": "id", "type": "string"}
]
}
],
"message": "success"
})))
.mount(&server)
.await;
let client = make_client(&server);
let cats = client.list_catalogs().await.unwrap();
assert_eq!(cats.len(), 2);
assert_eq!(cats[0].name, "cardiology");
assert_eq!(cats[0].description.as_deref(), Some("Cardiology catalog"));
assert_eq!(cats[0].fields.len(), 2);
assert_eq!(cats[0].fields[1].field_type, CatalogFieldType::Number);
assert_eq!(cats[1].name, "dermatology");
assert_eq!(cats[1].description, None);
}
#[tokio::test]
async fn list_catalogs_empty() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
.mount(&server)
.await;
let client = make_client(&server);
let cats = client.list_catalogs().await.unwrap();
assert!(cats.is_empty());
}
#[tokio::test]
async fn list_catalogs_sets_user_agent() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.and(header(
"user-agent",
concat!("braze-sync/", env!("CARGO_PKG_VERSION")),
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
.mount(&server)
.await;
let client = make_client(&server);
client.list_catalogs().await.unwrap();
}
#[tokio::test]
async fn list_catalogs_ignores_unknown_fields_in_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"catalogs": [
{
"name": "future",
"description": "tomorrow",
"future_metadata": {"foo": "bar"},
"num_items": 1234,
"fields": [
{"name": "id", "type": "string", "extra": "ignored"}
]
}
],
"future_top_level": {"whatever": true},
"message": "success"
})))
.mount(&server)
.await;
let client = make_client(&server);
let cats = client.list_catalogs().await.unwrap();
assert_eq!(cats.len(), 1);
assert_eq!(cats[0].name, "future");
}
#[tokio::test]
async fn list_catalogs_errors_when_next_cursor_present() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"catalogs": [
{"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
],
"next_cursor": "abc123"
})))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.list_catalogs().await.unwrap_err();
match err {
BrazeApiError::PaginationNotImplemented { endpoint, detail } => {
assert_eq!(endpoint, "/catalogs");
assert!(detail.contains("next_cursor"), "detail: {detail}");
assert!(detail.contains("1 catalog"), "detail: {detail}");
}
other => panic!("expected PaginationNotImplemented, got {other:?}"),
}
}
#[tokio::test]
async fn list_catalogs_empty_string_cursor_is_treated_as_no_more_pages() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"catalogs": [{"name": "only", "fields": []}],
"next_cursor": ""
})))
.mount(&server)
.await;
let client = make_client(&server);
let cats = client.list_catalogs().await.unwrap();
assert_eq!(cats.len(), 1);
assert_eq!(cats[0].name, "only");
}
#[tokio::test]
async fn unauthorized_returns_typed_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(401).set_body_string("invalid api key"))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.list_catalogs().await.unwrap_err();
assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
}
#[tokio::test]
async fn server_error_carries_status_and_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(500).set_body_string("internal explosion"))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.list_catalogs().await.unwrap_err();
match err {
BrazeApiError::Http { status, body } => {
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(body.contains("internal explosion"));
}
other => panic!("expected Http, got {other:?}"),
}
}
#[tokio::test]
async fn retries_on_429_and_succeeds() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"catalogs": [{"name": "after_retry", "fields": []}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.up_to_n_times(1)
.mount(&server)
.await;
let client = make_client(&server);
let cats = client.list_catalogs().await.unwrap();
assert_eq!(cats.len(), 1);
assert_eq!(cats[0].name, "after_retry");
}
#[tokio::test]
async fn retries_exhausted_returns_rate_limit_exhausted() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.list_catalogs().await.unwrap_err();
assert!(
matches!(err, BrazeApiError::RateLimitExhausted),
"got {err:?}"
);
}
#[tokio::test]
async fn get_catalog_happy_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs/cardiology"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"catalogs": [
{"name": "cardiology", "fields": [{"name": "id", "type": "string"}]}
]
})))
.mount(&server)
.await;
let client = make_client(&server);
let cat = client.get_catalog("cardiology").await.unwrap();
assert_eq!(cat.name, "cardiology");
assert_eq!(cat.fields.len(), 1);
}
#[tokio::test]
async fn get_catalog_404_is_mapped_to_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs/missing"))
.respond_with(ResponseTemplate::new(404).set_body_string("not found"))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.get_catalog("missing").await.unwrap_err();
match err {
BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[tokio::test]
async fn get_catalog_empty_response_array_is_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/catalogs/ghost"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"catalogs": []})))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.get_catalog("ghost").await.unwrap_err();
assert!(matches!(err, BrazeApiError::NotFound { .. }), "got {err:?}");
}
#[tokio::test]
async fn debug_does_not_leak_api_key() {
let server = MockServer::start().await;
let client = make_client(&server);
let dbg = format!("{client:?}");
assert!(!dbg.contains("test-key"), "leaked api key in: {dbg}");
assert!(dbg.contains("<redacted>"));
}
#[tokio::test]
async fn add_catalog_field_happy_path_sends_correct_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/catalogs/cardiology/fields"))
.and(header("authorization", "Bearer test-key"))
.and(body_json(json!({
"fields": [{"name": "severity_level", "type": "number"}]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
.mount(&server)
.await;
let client = make_client(&server);
let field = CatalogField {
name: "severity_level".into(),
field_type: CatalogFieldType::Number,
};
client
.add_catalog_field("cardiology", &field)
.await
.unwrap();
}
#[tokio::test]
async fn add_catalog_field_unauthorized_propagates() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/catalogs/cardiology/fields"))
.respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
.mount(&server)
.await;
let client = make_client(&server);
let field = CatalogField {
name: "x".into(),
field_type: CatalogFieldType::String,
};
let err = client
.add_catalog_field("cardiology", &field)
.await
.unwrap_err();
assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
}
#[tokio::test]
async fn add_catalog_field_retries_on_429_then_succeeds() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/catalogs/cardiology/fields"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "ok"})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/catalogs/cardiology/fields"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.up_to_n_times(1)
.mount(&server)
.await;
let client = make_client(&server);
let field = CatalogField {
name: "x".into(),
field_type: CatalogFieldType::String,
};
client
.add_catalog_field("cardiology", &field)
.await
.unwrap();
}
#[tokio::test]
async fn create_catalog_happy_path_sends_correct_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/catalogs"))
.and(header("authorization", "Bearer test-key"))
.and(body_json(json!({
"catalogs": [{
"name": "cardiology",
"description": "Cardiology catalog",
"fields": [
{"name": "id", "type": "string"},
{"name": "severity_level", "type": "number"}
]
}]
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "success"})))
.mount(&server)
.await;
let client = make_client(&server);
let cat = Catalog {
name: "cardiology".into(),
description: Some("Cardiology catalog".into()),
fields: vec![
CatalogField {
name: "id".into(),
field_type: CatalogFieldType::String,
},
CatalogField {
name: "severity_level".into(),
field_type: CatalogFieldType::Number,
},
],
};
client.create_catalog(&cat).await.unwrap();
}
#[tokio::test]
async fn create_catalog_hoists_id_field_to_first_position() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/catalogs"))
.and(body_json(json!({
"catalogs": [{
"name": "alpha",
"fields": [
{"name": "id", "type": "string"},
{"name": "URL", "type": "string"},
{"name": "author", "type": "string"},
{"name": "title", "type": "string"}
]
}]
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "success"})))
.mount(&server)
.await;
let client = make_client(&server);
let cat = Catalog {
name: "alpha".into(),
description: None,
fields: vec![
CatalogField {
name: "URL".into(),
field_type: CatalogFieldType::String,
},
CatalogField {
name: "author".into(),
field_type: CatalogFieldType::String,
},
CatalogField {
name: "id".into(),
field_type: CatalogFieldType::String,
},
CatalogField {
name: "title".into(),
field_type: CatalogFieldType::String,
},
],
};
client.create_catalog(&cat).await.unwrap();
}
#[tokio::test]
async fn create_catalog_omits_description_when_none() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/catalogs"))
.and(body_json(json!({
"catalogs": [{
"name": "minimal",
"fields": [{"name": "id", "type": "string"}]
}]
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "success"})))
.mount(&server)
.await;
let client = make_client(&server);
let cat = Catalog {
name: "minimal".into(),
description: None,
fields: vec![CatalogField {
name: "id".into(),
field_type: CatalogFieldType::String,
}],
};
client.create_catalog(&cat).await.unwrap();
}
#[tokio::test]
async fn create_catalog_duplicate_name_propagates_400() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!({
"errors": [{
"id": "catalog-name-already-exists",
"message": "A catalog with that name already exists"
}]
})))
.mount(&server)
.await;
let client = make_client(&server);
let cat = Catalog {
name: "existing".into(),
description: None,
fields: vec![],
};
let err = client.create_catalog(&cat).await.unwrap_err();
assert!(
matches!(
&err,
BrazeApiError::Http { status, body }
if *status == StatusCode::BAD_REQUEST
&& body.contains("catalog-name-already-exists")
),
"got {err:?}"
);
}
#[tokio::test]
async fn create_catalog_unauthorized_propagates() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(401).set_body_string("invalid key"))
.mount(&server)
.await;
let client = make_client(&server);
let cat = Catalog {
name: "x".into(),
description: None,
fields: vec![],
};
let err = client.create_catalog(&cat).await.unwrap_err();
assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
}
#[tokio::test]
async fn create_catalog_retries_on_429_then_succeeds() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({"message": "ok"})))
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/catalogs"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.up_to_n_times(1)
.mount(&server)
.await;
let client = make_client(&server);
let cat = Catalog {
name: "x".into(),
description: None,
fields: vec![CatalogField {
name: "id".into(),
field_type: CatalogFieldType::String,
}],
};
client.create_catalog(&cat).await.unwrap();
}
#[tokio::test]
async fn delete_catalog_field_happy_path_uses_segment_encoded_path() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/catalogs/cardiology/fields/legacy_code"))
.and(header("authorization", "Bearer test-key"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let client = make_client(&server);
client
.delete_catalog_field("cardiology", "legacy_code")
.await
.unwrap();
}
#[tokio::test]
async fn delete_catalog_field_server_error_returns_http() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/catalogs/cardiology/fields/x"))
.respond_with(ResponseTemplate::new(500).set_body_string("oops"))
.mount(&server)
.await;
let client = make_client(&server);
let err = client
.delete_catalog_field("cardiology", "x")
.await
.unwrap_err();
match err {
BrazeApiError::Http { status, body } => {
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert!(body.contains("oops"));
}
other => panic!("expected Http, got {other:?}"),
}
}
}