Skip to main content

opssignal_core/providers/
webhook.rs

1use crate::delivery::Provider;
2use crate::signal::{Signal, SignalError};
3use crate::template::{self, Templates};
4
5pub struct WebhookProvider {
6    pub url: String,
7    client: reqwest::Client,
8    templates: Option<Templates>,
9}
10
11impl WebhookProvider {
12    pub fn new(url: String) -> Self {
13        WebhookProvider {
14            url,
15            client: reqwest::Client::new(),
16            templates: None,
17        }
18    }
19
20    /// Uses `templates` for the parts they set; see [`crate::template`].
21    pub fn with_templates(mut self, templates: Option<Templates>) -> Self {
22        self.templates = templates;
23        self
24    }
25
26    /// The templated `body` when it renders to valid JSON, else the
27    /// signal itself.
28    fn body_for(&self, signal: &Signal) -> serde_json::Value {
29        if let Some(text) = template::render(&self.templates, "body", signal, &[]) {
30            match serde_json::from_str(&text) {
31                Ok(body) => return body,
32                Err(e) => tracing::warn!(
33                    signal_id = %signal.id,
34                    error = %e,
35                    "webhook body template is not valid JSON; sending the signal instead"
36                ),
37            }
38        }
39        serde_json::to_value(signal).unwrap_or_default()
40    }
41}
42
43#[async_trait::async_trait]
44impl Provider for WebhookProvider {
45    fn name(&self) -> &'static str {
46        "webhook"
47    }
48
49    fn preview(&self, signal: &Signal) -> Option<serde_json::Value> {
50        Some(self.body_for(signal))
51    }
52
53    async fn send(&self, signal: &Signal) -> Result<(), SignalError> {
54        let resp = self
55            .client
56            .post(&self.url)
57            .json(&self.body_for(signal))
58            .send()
59            .await
60            .map_err(|e| SignalError::Delivery(e.to_string()))?;
61
62        if !resp.status().is_success() {
63            return Err(SignalError::Delivery(format!(
64                "webhook returned status {}",
65                resp.status()
66            )));
67        }
68        Ok(())
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use crate::signal::{Severity, SignalInput};
76    use wiremock::matchers::{body_partial_json, method, path};
77    use wiremock::{Mock, MockServer, ResponseTemplate};
78
79    fn signal() -> Signal {
80        Signal::from_input(SignalInput {
81            source: "ci".into(),
82            event_type: "build_failed".into(),
83            title: "main is red".into(),
84            severity: Severity::Critical,
85            environment: Some("prod".into()),
86            ..Default::default()
87        })
88    }
89
90    #[tokio::test]
91    async fn posts_the_full_signal_as_json() {
92        let server = MockServer::start().await;
93        let signal = signal();
94        Mock::given(method("POST"))
95            .and(path("/hook"))
96            .and(body_partial_json(serde_json::json!({
97                "id": signal.id.to_string(),
98                "source": "ci",
99                "event_type": "build_failed",
100                "severity": "critical",
101                "title": "main is red",
102                "environment": "prod",
103                "dedup_key": signal.dedup_key,
104            })))
105            .respond_with(ResponseTemplate::new(202))
106            .expect(1)
107            .mount(&server)
108            .await;
109
110        WebhookProvider::new(format!("{}/hook", server.uri()))
111            .send(&signal)
112            .await
113            .expect("any 2xx is success");
114    }
115
116    #[tokio::test]
117    async fn non_success_status_is_a_delivery_error() {
118        let server = MockServer::start().await;
119        Mock::given(method("POST"))
120            .respond_with(ResponseTemplate::new(404))
121            .mount(&server)
122            .await;
123
124        let err = WebhookProvider::new(server.uri())
125            .send(&signal())
126            .await
127            .expect_err("404 must fail");
128        assert!(matches!(err, SignalError::Delivery(_)));
129        assert!(err.to_string().contains("404"));
130    }
131
132    #[tokio::test]
133    async fn invalid_url_is_a_delivery_error() {
134        let err = WebhookProvider::new("not a url".into())
135            .send(&signal())
136            .await
137            .expect_err("invalid URL must fail");
138        assert!(matches!(err, SignalError::Delivery(_)));
139    }
140
141    #[tokio::test]
142    async fn body_template_sends_custom_json() {
143        let server = MockServer::start().await;
144        Mock::given(method("POST"))
145            .and(wiremock::matchers::body_json(serde_json::json!({
146                "alert": "main is red",
147                "level": "CRITICAL",
148                "labels": { "env": "prod" },
149            })))
150            .respond_with(ResponseTemplate::new(200))
151            .expect(1)
152            .mount(&server)
153            .await;
154
155        WebhookProvider::new(server.uri())
156            .with_templates(template::for_test(
157                "webhook",
158                &[(
159                    "body",
160                    r#"{"alert": {{ title|tojson }}, "level": {{ severity|upper|tojson }}, "labels": {"env": {{ environment|tojson }}}}"#,
161                )],
162            ))
163            .send(&signal())
164            .await
165            .expect("delivered");
166    }
167
168    #[tokio::test]
169    async fn body_that_is_not_json_sends_the_signal() {
170        let server = MockServer::start().await;
171        Mock::given(method("POST"))
172            .and(body_partial_json(
173                serde_json::json!({ "title": "main is red" }),
174            ))
175            .respond_with(ResponseTemplate::new(200))
176            .expect(1)
177            .mount(&server)
178            .await;
179
180        WebhookProvider::new(server.uri())
181            .with_templates(template::for_test(
182                "webhook",
183                &[("body", "alert: {{ title }}")],
184            ))
185            .send(&signal())
186            .await
187            .expect("delivered");
188    }
189}