feat: add webhook body template and headers support for channels

This commit is contained in:
2026-03-16 01:10:26 +01:00
parent db461db270
commit e76167134b
12 changed files with 366 additions and 23 deletions

View File

@@ -4,6 +4,7 @@
//! webhook_url, and fires HTTP POST requests (fire-and-forget).
use chrono::Utc;
use handlebars::Handlebars;
use serde_json::{Value, json};
use std::sync::Arc;
use tokio::sync::broadcast;
@@ -30,8 +31,10 @@ pub async fn run_webhook_consumer(
Ok(Some(channel)) => {
if let Some(url) = channel.webhook_url {
let client = client.clone();
let template = channel.webhook_body_template.clone();
let headers = channel.webhook_headers.clone();
tokio::spawn(async move {
post_webhook(&client, &url, payload).await;
post_webhook(&client, &url, payload, template.as_deref(), headers.as_deref()).await;
});
}
// No webhook_url configured — skip silently
@@ -146,15 +149,60 @@ fn build_payload(event: &DomainEvent) -> Value {
}
/// Fire-and-forget HTTP POST to a webhook URL.
async fn post_webhook(client: &reqwest::Client, url: &str, payload: Value) {
match client.post(url).json(&payload).send().await {
///
/// If `template` is provided it is rendered with `payload` as context via Handlebars.
/// `headers_json` is a JSON object string of extra HTTP headers (e.g. `{"Authorization":"Bearer x"}`).
/// Content-Type defaults to `application/json` unless overridden in `headers_json`.
async fn post_webhook(
client: &reqwest::Client,
url: &str,
payload: Value,
template: Option<&str>,
headers_json: Option<&str>,
) {
let body = if let Some(tmpl) = template {
let hbs = Handlebars::new();
match hbs.render_template(tmpl, &payload) {
Ok(rendered) => rendered,
Err(e) => {
warn!("webhook template render failed for {}: {}", url, e);
return;
}
}
} else {
match serde_json::to_string(&payload) {
Ok(s) => s,
Err(e) => {
warn!("webhook payload serialize failed: {}", e);
return;
}
}
};
let mut req = client.post(url).body(body);
let mut has_content_type = false;
if let Some(h) = headers_json {
if let Ok(map) = serde_json::from_str::<serde_json::Map<String, Value>>(h) {
for (k, v) in &map {
if k.to_lowercase() == "content-type" {
has_content_type = true;
}
if let Some(v_str) = v.as_str() {
req = req.header(k.as_str(), v_str);
}
}
}
}
if !has_content_type {
req = req.header("Content-Type", "application/json");
}
match req.send().await {
Ok(resp) => {
if !resp.status().is_success() {
warn!(
"webhook POST to {} returned status {}",
url,
resp.status()
);
warn!("webhook POST to {} returned status {}", url, resp.status());
}
}
Err(e) => {