use crate::braze::error::BrazeApiError;
use crate::braze::{
check_duplicate_names, classify_info_message, BrazeClient, InfoMessageClass,
LIST_SAFETY_CAP_ITEMS,
};
use crate::resource::{ContentBlock, ContentBlockState};
use serde::{Deserialize, Serialize};
const LIST_LIMIT: u32 = 1000;
#[derive(Debug, Clone, PartialEq)]
pub struct ContentBlockSummary {
pub content_block_id: String,
pub name: String,
}
impl BrazeClient {
pub async fn list_content_blocks(&self) -> Result<Vec<ContentBlockSummary>, BrazeApiError> {
let mut all: Vec<ContentBlockListEntry> = Vec::with_capacity(LIST_LIMIT as usize);
let mut offset: u32 = 0;
loop {
let mut req = self
.get(&["content_blocks", "list"])
.query(&[("limit", LIST_LIMIT.to_string())]);
if offset > 0 {
req = req.query(&[("offset", offset.to_string())]);
}
let resp: ContentBlockListResponse = self.send_json(req).await?;
let page_len = resp.content_blocks.len();
if all.len().saturating_add(page_len) > LIST_SAFETY_CAP_ITEMS {
return Err(BrazeApiError::PaginationNotImplemented {
endpoint: "/content_blocks/list",
detail: format!("would exceed {LIST_SAFETY_CAP_ITEMS} item safety cap"),
});
}
all.extend(resp.content_blocks);
if page_len < LIST_LIMIT as usize {
break;
}
offset += LIST_LIMIT;
}
check_duplicate_names(
all.iter().map(|e| e.name.as_str()),
all.len(),
"/content_blocks/list",
)?;
Ok(all
.into_iter()
.map(|w| ContentBlockSummary {
content_block_id: w.content_block_id,
name: w.name,
})
.collect())
}
pub async fn get_content_block(&self, id: &str) -> Result<ContentBlock, BrazeApiError> {
let req = self
.get(&["content_blocks", "info"])
.query(&[("content_block_id", id)]);
let wire: ContentBlockInfoResponse = self.send_json(req).await?;
match classify_info_message(wire.message.as_deref(), "no content block") {
InfoMessageClass::Success => {}
InfoMessageClass::NotFound => {
return Err(BrazeApiError::NotFound {
resource: format!("content_block id '{id}'"),
});
}
InfoMessageClass::Unexpected(message) => {
return Err(BrazeApiError::UnexpectedApiMessage {
endpoint: "/content_blocks/info",
message,
});
}
}
Ok(ContentBlock {
name: wire.name,
description: wire.description,
content: wire.content,
tags: wire.tags,
state: ContentBlockState::Active,
})
}
pub async fn create_content_block(&self, cb: &ContentBlock) -> Result<String, BrazeApiError> {
let body = ContentBlockWriteBody {
content_block_id: None,
name: &cb.name,
description: cb.description.as_deref(),
content: &cb.content,
tags: &cb.tags,
state: Some(cb.state),
};
let req = self.post(&["content_blocks", "create"]).json(&body);
let resp: ContentBlockCreateResponse = self.send_json(req).await?;
Ok(resp.content_block_id)
}
pub async fn update_content_block(
&self,
id: &str,
cb: &ContentBlock,
) -> Result<(), BrazeApiError> {
let body = ContentBlockWriteBody {
content_block_id: Some(id),
name: &cb.name,
description: cb.description.as_deref(),
content: &cb.content,
tags: &cb.tags,
state: None,
};
let req = self.post(&["content_blocks", "update"]).json(&body);
self.send_ok(req).await
}
}
#[derive(Debug, Deserialize)]
struct ContentBlockListResponse {
#[serde(default)]
content_blocks: Vec<ContentBlockListEntry>,
}
#[derive(Debug, Deserialize)]
struct ContentBlockListEntry {
content_block_id: String,
name: String,
}
#[derive(Debug, Deserialize)]
struct ContentBlockInfoResponse {
#[serde(default)]
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(default)]
content: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
message: Option<String>,
}
#[derive(Serialize)]
struct ContentBlockWriteBody<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
content_block_id: Option<&'a str>,
name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<&'a str>,
content: &'a str,
tags: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
state: Option<ContentBlockState>,
}
#[derive(Debug, Deserialize)]
struct ContentBlockCreateResponse {
content_block_id: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::braze::test_client as make_client;
use reqwest::StatusCode;
use serde_json::json;
use wiremock::matchers::{
body_json, header, method, path, query_param, query_param_is_missing,
};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn list_happy_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.and(header("authorization", "Bearer test-key"))
.and(query_param("limit", "1000"))
.and(query_param_is_missing("offset"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": [
{"content_block_id": "id-1", "name": "promo"},
{"content_block_id": "id-2", "name": "header"}
],
"message": "success"
})))
.mount(&server)
.await;
let client = make_client(&server);
let summaries = client.list_content_blocks().await.unwrap();
assert_eq!(summaries.len(), 2);
assert_eq!(summaries[0].content_block_id, "id-1");
assert_eq!(summaries[0].name, "promo");
}
#[tokio::test]
async fn list_empty_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"content_blocks": []})))
.mount(&server)
.await;
let client = make_client(&server);
assert!(client.list_content_blocks().await.unwrap().is_empty());
}
#[tokio::test]
async fn list_ignores_unknown_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": [{
"content_block_id": "id-1",
"name": "promo",
"content_type": "html",
"liquid_tag": "{{content_blocks.${promo}}}",
"future_metadata": {"foo": "bar"}
}]
})))
.mount(&server)
.await;
let client = make_client(&server);
let summaries = client.list_content_blocks().await.unwrap();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].name, "promo");
}
#[tokio::test]
async fn list_unauthorized() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.list_content_blocks().await.unwrap_err();
assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
}
#[tokio::test]
async fn info_happy_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/info"))
.and(query_param("content_block_id", "id-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_block_id": "id-1",
"name": "promo",
"description": "Promo banner",
"content": "Hello {{ user.${first_name} }}",
"tags": ["pr", "dialog"],
"content_type": "html",
"message": "success"
})))
.mount(&server)
.await;
let client = make_client(&server);
let cb = client.get_content_block("id-1").await.unwrap();
assert_eq!(cb.name, "promo");
assert_eq!(cb.description.as_deref(), Some("Promo banner"));
assert_eq!(cb.content, "Hello {{ user.${first_name} }}");
assert_eq!(cb.tags, vec!["pr".to_string(), "dialog".to_string()]);
assert_eq!(cb.state, ContentBlockState::Active);
}
#[tokio::test]
async fn info_with_unrecognised_error_message_surfaces_as_unexpected() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"message": "Internal server hiccup, please retry"
})))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.get_content_block("some-id").await.unwrap_err();
match err {
BrazeApiError::UnexpectedApiMessage { endpoint, message } => {
assert_eq!(endpoint, "/content_blocks/info");
assert!(
message.contains("Internal server hiccup"),
"message not preserved verbatim: {message}"
);
}
other => panic!("expected UnexpectedApiMessage, got {other:?}"),
}
}
#[tokio::test]
async fn info_with_unsuccessful_message_is_not_found() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/info"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"message": "No content block with id 'missing' found"
})))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.get_content_block("missing").await.unwrap_err();
match err {
BrazeApiError::NotFound { resource } => assert!(resource.contains("missing")),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[tokio::test]
async fn create_sends_correct_body_and_returns_id() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/content_blocks/create"))
.and(header("authorization", "Bearer test-key"))
.and(body_json(json!({
"name": "promo",
"description": "Promo banner",
"content": "Hello",
"tags": ["pr"],
"state": "active"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_block_id": "new-id-123",
"message": "success"
})))
.mount(&server)
.await;
let client = make_client(&server);
let cb = ContentBlock {
name: "promo".into(),
description: Some("Promo banner".into()),
content: "Hello".into(),
tags: vec!["pr".into()],
state: ContentBlockState::Active,
};
let id = client.create_content_block(&cb).await.unwrap();
assert_eq!(id, "new-id-123");
}
#[tokio::test]
async fn create_omits_description_when_none() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/content_blocks/create"))
.and(body_json(json!({
"name": "minimal",
"content": "x",
"tags": [],
"state": "active"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_block_id": "id-min"
})))
.mount(&server)
.await;
let client = make_client(&server);
let cb = ContentBlock {
name: "minimal".into(),
description: None,
content: "x".into(),
tags: vec![],
state: ContentBlockState::Active,
};
client.create_content_block(&cb).await.unwrap();
}
#[tokio::test]
async fn create_forwards_draft_state_to_request_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/content_blocks/create"))
.and(body_json(json!({
"name": "wip",
"content": "draft body",
"tags": [],
"state": "draft"
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_block_id": "id-wip"
})))
.expect(1)
.mount(&server)
.await;
let client = make_client(&server);
let cb = ContentBlock {
name: "wip".into(),
description: None,
content: "draft body".into(),
tags: vec![],
state: ContentBlockState::Draft,
};
client.create_content_block(&cb).await.unwrap();
}
#[tokio::test]
async fn update_sends_id_in_body_and_omits_state() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/content_blocks/update"))
.and(body_json(json!({
"content_block_id": "id-1",
"name": "promo",
"content": "Updated body",
"tags": []
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"message": "success"})))
.mount(&server)
.await;
let client = make_client(&server);
let cb = ContentBlock {
name: "promo".into(),
description: None,
content: "Updated body".into(),
tags: vec![],
state: ContentBlockState::Draft,
};
client.update_content_block("id-1", &cb).await.unwrap();
}
#[tokio::test]
async fn update_unauthorized_propagates() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/content_blocks/update"))
.respond_with(ResponseTemplate::new(401).set_body_string("invalid"))
.mount(&server)
.await;
let client = make_client(&server);
let cb = ContentBlock {
name: "x".into(),
description: None,
content: String::new(),
tags: vec![],
state: ContentBlockState::Active,
};
let err = client.update_content_block("id", &cb).await.unwrap_err();
assert!(matches!(err, BrazeApiError::Unauthorized), "got {err:?}");
}
#[tokio::test]
async fn update_server_error_is_http() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/content_blocks/update"))
.respond_with(ResponseTemplate::new(500).set_body_string("oops"))
.mount(&server)
.await;
let client = make_client(&server);
let cb = ContentBlock {
name: "x".into(),
description: None,
content: String::new(),
tags: vec![],
state: ContentBlockState::Active,
};
let err = client.update_content_block("id", &cb).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:?}"),
}
}
#[tokio::test]
async fn list_short_page_is_treated_as_complete() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.and(query_param_is_missing("offset"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": [
{"content_block_id": "id-1", "name": "a"},
{"content_block_id": "id-2", "name": "b"}
]
})))
.mount(&server)
.await;
let client = make_client(&server);
let summaries = client.list_content_blocks().await.unwrap();
assert_eq!(summaries.len(), 2);
}
#[tokio::test]
async fn list_offset_pagination_across_three_pages() {
let server = MockServer::start().await;
let page1: Vec<serde_json::Value> = (0..1000)
.map(|i| {
json!({
"content_block_id": format!("id-p1-{i}"),
"name": format!("p1_{i}")
})
})
.collect();
let page2: Vec<serde_json::Value> = (0..1000)
.map(|i| {
json!({
"content_block_id": format!("id-p2-{i}"),
"name": format!("p2_{i}")
})
})
.collect();
let page3: Vec<serde_json::Value> = (0..234)
.map(|i| {
json!({
"content_block_id": format!("id-p3-{i}"),
"name": format!("p3_{i}")
})
})
.collect();
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.and(query_param("offset", "2000"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": page3 })),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.and(query_param("offset", "1000"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": page2 })),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.and(query_param_is_missing("offset"))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({ "content_blocks": page1 })),
)
.mount(&server)
.await;
let client = make_client(&server);
let summaries = client.list_content_blocks().await.unwrap();
assert_eq!(summaries.len(), 2234);
assert_eq!(summaries[0].name, "p1_0");
assert_eq!(summaries[999].name, "p1_999");
assert_eq!(summaries[1000].name, "p2_0");
assert_eq!(summaries[2233].name, "p3_233");
}
#[tokio::test]
async fn list_errors_on_duplicate_name_in_response() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": [
{"content_block_id": "id-a", "name": "dup"},
{"content_block_id": "id-b", "name": "dup"}
]
})))
.mount(&server)
.await;
let client = make_client(&server);
let err = client.list_content_blocks().await.unwrap_err();
match err {
BrazeApiError::DuplicateNameInListResponse { endpoint, name } => {
assert_eq!(endpoint, "/content_blocks/list");
assert_eq!(name, "dup");
}
other => panic!("expected DuplicateNameInListResponse, got {other:?}"),
}
}
#[tokio::test]
async fn list_retries_on_429_then_succeeds() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"content_blocks": [{"content_block_id": "id-x", "name": "x"}]
})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/content_blocks/list"))
.respond_with(ResponseTemplate::new(429).insert_header("retry-after", "0"))
.up_to_n_times(1)
.mount(&server)
.await;
let client = make_client(&server);
let summaries = client.list_content_blocks().await.unwrap();
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].name, "x");
}
}