use super::{ChannelAdapter, ChannelError};
use crate::escalation::trait_::{EscalationTicket, Severity};
use async_trait::async_trait;
use reqwest::Client;
use std::time::Duration;
pub struct WebhookChannel {
url: String,
client: Client,
}
impl WebhookChannel {
pub fn new(url: impl Into<String>) -> Result<Self, ChannelError> {
let url = url.into();
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(ChannelError::InvalidUrl(format!(
"webhook URL must start with http:// or https://: {url}"
)));
}
let client = Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| ChannelError::Internal(format!("reqwest build: {e}")))?;
Ok(Self { url, client })
}
#[must_use]
pub fn with_client(url: impl Into<String>, client: Client) -> Self {
Self {
url: url.into(),
client,
}
}
fn severity_header_value(severity: Severity) -> &'static str {
match severity {
Severity::Critical => "critical",
Severity::High => "high",
Severity::Medium => "medium",
Severity::Low => "low",
}
}
}
#[async_trait]
impl ChannelAdapter for WebhookChannel {
async fn deliver(&self, ticket: &EscalationTicket) -> Result<(), ChannelError> {
let severity_header = Self::severity_header_value(ticket.severity);
let resp = self
.client
.post(&self.url)
.header("X-Klieo-Severity", severity_header)
.json(ticket)
.send()
.await
.map_err(|e| ChannelError::Unavailable(format!("POST {}: {e}", self.url)))?;
if !resp.status().is_success() {
return Err(ChannelError::Unavailable(format!(
"webhook returned non-2xx: {}",
resp.status()
)));
}
Ok(())
}
fn name(&self) -> &'static str {
"WebhookChannel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::escalation::trait_::{EscalationTicket, Severity};
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn webhook_channel_posts_with_severity_header() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/escalations"))
.and(header("X-Klieo-Severity", "high"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
let ch = WebhookChannel::new(format!("{}/escalations", server.uri())).expect("build");
let ticket = EscalationTicket {
tenant: None,
severity: Severity::High,
reason: "test".into(),
provenance: None,
};
ch.deliver(&ticket).await.expect("webhook delivers");
}
#[tokio::test]
async fn webhook_channel_rejects_non_2xx() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(503))
.mount(&server)
.await;
let ch = WebhookChannel::new(server.uri()).expect("build");
let ticket = EscalationTicket {
tenant: None,
severity: Severity::Critical,
reason: "test".into(),
provenance: None,
};
let err = ch.deliver(&ticket).await.expect_err("must error on 5xx");
assert!(matches!(err, ChannelError::Unavailable(_)));
}
#[test]
fn new_rejects_non_http_url() {
let result = WebhookChannel::new("not-a-url");
assert!(result.is_err(), "invalid URL must return Err");
match result {
Err(ChannelError::InvalidUrl(_)) => {}
Err(other) => panic!("expected InvalidUrl, got {other:?}"),
Ok(_) => panic!("expected Err, got Ok"),
}
}
}