klieo-ops 0.41.2

Operational layer above klieo-core: supervisor, governor, gates, escalation, worklog, handoff.
Documentation
//! HTTP webhook channel adapter. POSTs a JSON-serialised
//! `EscalationTicket` to a configured URL. Includes a `Severity`
//! header so the downstream system can route without parsing the body.
//!
//! Phase B scope: no auth, no retry-with-backoff, no signature.
//! Production deployments wrap this in their own auth proxy or
//! customise via a sibling `WebhookChannel::with_client(reqwest_middleware_client)`
//! constructor when reqwest-middleware is desired.

use super::{ChannelAdapter, ChannelError};
use crate::escalation::trait_::{EscalationTicket, Severity};
use async_trait::async_trait;
use reqwest::Client;
use std::time::Duration;

/// HTTP webhook channel adapter.
pub struct WebhookChannel {
    url: String,
    client: Client,
}

impl WebhookChannel {
    /// Build with default `reqwest::Client` (10s timeout).
    ///
    /// Returns `Err(ChannelError::InvalidUrl)` when `url` is not a valid
    /// absolute HTTP or HTTPS URL.
    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 })
    }

    /// Build with a caller-supplied `reqwest::Client` (allowing custom
    /// auth, timeouts, middleware).
    #[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"),
        }
    }
}