use std::time::Duration;
use crate::Notification;
use super::TriggerError;
use super::http_method::HttpMethod;
use super::template::{CompiledTemplate, TemplateError, compile};
use super::webhook::WebhookConfig;
use super::webhook::dispatch_webhook;
#[derive(Clone)]
pub(super) struct PostConfig {
pub(super) url_template: Result<CompiledTemplate, TemplateError>,
pub(super) headers: Vec<(String, Result<CompiledTemplate, TemplateError>)>,
}
impl std::fmt::Debug for PostConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let PostConfig {
url_template,
headers,
} = self;
let url_state: &dyn std::fmt::Display = match url_template {
Ok(_) => &"<compiled-url-template-redacted>",
Err(_) => &"<bad-url-template-redacted>",
};
f.debug_struct("PostConfig")
.field("url_template", &format_args!("{url_state}"))
.field("header_count", &headers.len())
.finish()
}
}
pub(super) const POST_CLOUDEVENT_TYPE: &str = "co.ecmwf.aviso.event";
pub(super) const POST_CLOUDEVENT_SOURCE: &str = "aviso-client";
pub(super) async fn dispatch_post(
cfg: &PostConfig,
http: &reqwest::Client,
timeout: Option<Duration>,
notification: &Notification,
) -> Result<(), TriggerError> {
let envelope = match ¬ification.cloudevent {
Some(raw) => raw.clone(),
None => build_reconstructed_envelope(notification),
};
let body_string = serde_json::to_string(&envelope).map_err(TriggerError::Encode)?;
let webhook_cfg = synthesise_webhook_config(cfg, &body_string);
dispatch_webhook(&webhook_cfg, http, timeout, notification).await
}
fn build_reconstructed_envelope(notification: &Notification) -> serde_json::Value {
serde_json::json!({
"specversion": "1.0",
"type": POST_CLOUDEVENT_TYPE,
"source": POST_CLOUDEVENT_SOURCE,
"id": format!("{}@{}", notification.event_type, notification.sequence),
"data": {
"identifier": ¬ification.identifier,
"payload": ¬ification.payload,
},
})
}
fn synthesise_webhook_config(cfg: &PostConfig, body_string: &str) -> WebhookConfig {
let mut headers = cfg.headers.clone();
let has_content_type = headers
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case("content-type"));
if !has_content_type {
headers.push((
"Content-Type".to_string(),
compile("application/cloudevents+json"),
));
}
let escaped = body_string.replace("{{", "\\{{");
WebhookConfig {
url_template: cfg.url_template.clone(),
method: HttpMethod::Post,
headers,
body_template: Some(compile(&escaped)),
}
}
pub(super) fn build_post_config(url: impl Into<String>) -> PostConfig {
let url_str = url.into();
PostConfig {
url_template: compile(&url_str),
headers: Vec::new(),
}
}
pub(super) fn post_add_header(
cfg: &mut PostConfig,
name: impl Into<String>,
value: impl Into<String>,
) {
let value_str = value.into();
let compiled = compile(&value_str);
cfg.headers.push((name.into(), compiled));
}
impl super::Trigger {
#[must_use]
pub fn post(url: impl Into<String>) -> Self {
Self {
kind: super::kind::TriggerKind::Post(Box::new(build_post_config(url))),
retries: 0,
required: true,
timeout: Some(super::DEFAULT_WEBHOOK_TIMEOUT),
fail_fast: true,
}
}
#[must_use]
pub fn post_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
if let super::kind::TriggerKind::Post(cfg) = &mut self.kind {
post_add_header(cfg, name, value);
}
self
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "test code: unwrap/expect on synthesised inputs is the standard test diagnostic"
)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::Notification;
fn make_notification(
event_type: &str,
sequence: u64,
identifier: BTreeMap<String, String>,
payload: serde_json::Value,
) -> Notification {
Notification {
event_type: event_type.to_string(),
sequence,
identifier,
payload,
cloudevent: None,
}
}
#[test]
fn envelope_has_specversion_type_source_id_and_data_fields() {
let mut identifier = BTreeMap::new();
identifier.insert("class".to_string(), "od".to_string());
let n = make_notification("mars", 42, identifier, serde_json::json!({"seed": "x"}));
let envelope = build_reconstructed_envelope(&n);
assert_eq!(envelope["specversion"], "1.0");
assert_eq!(envelope["type"], POST_CLOUDEVENT_TYPE);
assert_eq!(envelope["source"], POST_CLOUDEVENT_SOURCE);
assert_eq!(envelope["id"], "mars@42");
assert_eq!(envelope["data"]["identifier"]["class"], "od");
assert_eq!(envelope["data"]["payload"]["seed"], "x");
}
#[test]
fn reconstructed_envelope_omits_time_field() {
let n = make_notification("mars", 1, BTreeMap::new(), serde_json::Value::Null);
let envelope = build_reconstructed_envelope(&n);
assert!(
envelope.get("time").is_none(),
"the reconstructed envelope MUST omit the time field; faking one with dispatch-time would be misleading: {envelope}"
);
}
#[test]
fn reconstructed_envelope_id_is_event_type_at_sequence() {
let n = make_notification("test_polygon", 7, BTreeMap::new(), serde_json::Value::Null);
let envelope = build_reconstructed_envelope(&n);
assert_eq!(envelope["id"], "test_polygon@7");
}
#[test]
fn reconstructed_envelope_round_trips_through_json_with_special_chars_in_identifier() {
let mut identifier = BTreeMap::new();
identifier.insert(
"weird".to_string(),
"has \"quote\" and \\backslash".to_string(),
);
let n = make_notification("mars", 1, identifier, serde_json::Value::Null);
let envelope = build_reconstructed_envelope(&n);
let serialised = serde_json::to_string(&envelope).unwrap();
let reparsed: serde_json::Value = serde_json::from_str(&serialised).unwrap();
assert_eq!(
reparsed["data"]["identifier"]["weird"],
"has \"quote\" and \\backslash"
);
}
#[test]
fn dispatch_uses_raw_cloudevent_when_some_else_falls_back_to_reconstruction() {
let mut n_raw = make_notification("mars", 99, BTreeMap::new(), serde_json::json!({"a": 1}));
let raw_envelope = serde_json::json!({
"specversion": "1.0",
"type": "co.ecmwf.aviso.event.real",
"source": "https://aviso-server.example/",
"id": "mars@99",
"time": "2026-05-23T22:00:00.123456Z",
"data": {
"identifier": {},
"payload": {"server": "supplied"}
}
});
n_raw.cloudevent = Some(raw_envelope.clone());
let selected = match &n_raw.cloudevent {
Some(raw) => raw.clone(),
None => build_reconstructed_envelope(&n_raw),
};
assert_eq!(
selected, raw_envelope,
"with cloudevent Some, the selected envelope must be the raw one byte-for-byte; reconstruction must not run"
);
let n_test = make_notification("mars", 1, BTreeMap::new(), serde_json::Value::Null);
assert!(
n_test.cloudevent.is_none(),
"test fixture has no raw envelope"
);
let fallback_selected = match &n_test.cloudevent {
Some(raw) => raw.clone(),
None => build_reconstructed_envelope(&n_test),
};
assert_eq!(
fallback_selected["type"], POST_CLOUDEVENT_TYPE,
"with cloudevent None, the selected envelope is the reconstruction using the constant POST_CLOUDEVENT_TYPE"
);
}
#[test]
fn synthesise_webhook_config_injects_content_type_when_missing() {
let cfg = PostConfig {
url_template: compile("https://example/post"),
headers: vec![("X-Custom".to_string(), compile("value"))],
};
let webhook = synthesise_webhook_config(&cfg, "{}");
let names: Vec<String> = webhook.headers.iter().map(|(n, _)| n.clone()).collect();
assert!(
names.iter().any(|n| n.eq_ignore_ascii_case("content-type")),
"synthesised webhook config MUST inject a Content-Type header when the operator did not supply one: {names:?}"
);
assert!(
names.iter().any(|n| n == "X-Custom"),
"user-supplied X-Custom header MUST be preserved: {names:?}"
);
}
#[test]
fn synthesise_webhook_config_preserves_user_content_type_when_present() {
let cfg = PostConfig {
url_template: compile("https://example/post"),
headers: vec![(
"Content-Type".to_string(),
compile("application/vnd.example+json"),
)],
};
let webhook = synthesise_webhook_config(&cfg, "{}");
let content_types: Vec<&String> = webhook
.headers
.iter()
.filter(|(n, _)| n.eq_ignore_ascii_case("content-type"))
.map(|(n, _)| n)
.collect();
assert_eq!(
content_types.len(),
1,
"exactly one Content-Type header must be present (the user-supplied one); the dispatcher must NOT inject a duplicate default Content-Type: {:?}",
webhook
.headers
.iter()
.map(|(n, _)| n.clone())
.collect::<Vec<_>>(),
);
}
}