opssignal_core/providers/
webhook.rs1use 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 pub fn with_templates(mut self, templates: Option<Templates>) -> Self {
22 self.templates = templates;
23 self
24 }
25
26 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}