1use std::sync::Arc;
2
3use bytes::Bytes;
4use http::HeaderMap;
5
6use super::client::{self, WebhookResponse};
7use super::secret::WebhookSecret;
8use super::signature::sign_headers;
9use crate::error::{Error, Result};
10
11struct WebhookSenderInner {
12 client: reqwest::Client,
13 user_agent: String,
14}
15
16pub struct WebhookSender {
21 inner: Arc<WebhookSenderInner>,
22}
23
24impl Clone for WebhookSender {
25 fn clone(&self) -> Self {
26 Self {
27 inner: Arc::clone(&self.inner),
28 }
29 }
30}
31
32impl WebhookSender {
33 pub fn new(client: reqwest::Client) -> Self {
35 Self {
36 inner: Arc::new(WebhookSenderInner {
37 client,
38 user_agent: format!("modo-webhooks/{}", env!("CARGO_PKG_VERSION")),
39 }),
40 }
41 }
42
43 pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
53 let ua = user_agent.into();
54 if http::header::HeaderValue::from_str(&ua).is_err() {
56 return self;
57 }
58 let inner =
59 Arc::get_mut(&mut self.inner).expect("with_user_agent must be called before cloning");
60 inner.user_agent = ua;
61 self
62 }
63
64 pub fn default_client() -> Self {
67 let client = reqwest::Client::builder()
68 .timeout(std::time::Duration::from_secs(30))
69 .build()
70 .expect("failed to build default webhook HTTP client");
71 Self::new(client)
72 }
73
74 pub async fn send(
93 &self,
94 url: &str,
95 id: &str,
96 body: &[u8],
97 secrets: &[&WebhookSecret],
98 ) -> Result<WebhookResponse> {
99 if secrets.is_empty() {
100 return Err(Error::bad_request("at least one secret required"));
101 }
102 if id.is_empty() {
103 return Err(Error::bad_request("webhook id must not be empty"));
104 }
105 let _: http::Uri = url
107 .parse()
108 .map_err(|e| Error::bad_request(format!("invalid webhook url: {e}")))?;
109
110 let timestamp = chrono::Utc::now().timestamp();
111 let signed = sign_headers(secrets, id, timestamp, body);
112
113 let mut headers = HeaderMap::new();
114 headers.insert("content-type", "application/json".parse().unwrap());
115 headers.insert(
116 "user-agent",
117 self.inner
118 .user_agent
119 .parse()
120 .map_err(|_| Error::internal("invalid user-agent header value"))?,
121 );
122 headers.insert(
123 "webhook-id",
124 signed
125 .webhook_id
126 .parse()
127 .map_err(|_| Error::bad_request("webhook id contains invalid header characters"))?,
128 );
129 headers.insert(
130 "webhook-timestamp",
131 signed.webhook_timestamp.to_string().parse().unwrap(),
132 );
133 headers.insert(
134 "webhook-signature",
135 signed
136 .webhook_signature
137 .parse()
138 .map_err(|_| Error::internal("generated invalid webhook-signature header"))?,
139 );
140
141 client::post(
142 &self.inner.client,
143 url,
144 headers,
145 Bytes::copy_from_slice(body),
146 )
147 .await
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use std::time::Duration;
154
155 use http::StatusCode;
156 use tokio::io::{AsyncReadExt, AsyncWriteExt};
157 use tokio::net::TcpListener;
158
159 use super::*;
160
161 async fn start_test_server(response_status: u16) -> (String, tokio::task::JoinHandle<String>) {
163 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
164 let addr = listener.local_addr().unwrap();
165 let url = format!("http://127.0.0.1:{}", addr.port());
166
167 let handle = tokio::spawn(async move {
168 let (mut stream, _) = listener.accept().await.unwrap();
169 let mut buf = vec![0u8; 8192];
170 let n = stream.read(&mut buf).await.unwrap();
171 buf.truncate(n);
172 let raw = String::from_utf8_lossy(&buf).to_string();
173
174 let response = format!(
175 "HTTP/1.1 {response_status} OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
176 );
177 stream.write_all(response.as_bytes()).await.unwrap();
178 stream.shutdown().await.unwrap();
179
180 raw
181 });
182
183 (url, handle)
184 }
185
186 fn test_client() -> reqwest::Client {
187 reqwest::Client::builder()
188 .timeout(Duration::from_secs(5))
189 .build()
190 .expect("failed to build test HTTP client")
191 }
192
193 #[tokio::test]
194 async fn send_sets_correct_headers() {
195 let (url, handle) = start_test_server(200).await;
196
197 let sender = WebhookSender::new(test_client());
198 let secret = WebhookSecret::new(b"test-key".to_vec());
199
200 let result = sender.send(&url, "msg_123", b"{}", &[&secret]).await;
201 assert!(result.is_ok());
202
203 let raw = handle.await.unwrap();
204 assert!(raw.contains("content-type: application/json"));
205 assert!(raw.contains("webhook-id: msg_123"));
206 assert!(raw.contains("webhook-timestamp:"));
207 assert!(raw.contains("webhook-signature: v1,"));
208 }
209
210 #[tokio::test]
211 async fn send_default_user_agent() {
212 let (url, handle) = start_test_server(200).await;
213
214 let sender = WebhookSender::new(test_client());
215 let secret = WebhookSecret::new(b"key".to_vec());
216
217 sender.send(&url, "msg_1", b"{}", &[&secret]).await.unwrap();
218
219 let raw = handle.await.unwrap();
220 assert!(raw.contains("user-agent: modo-webhooks/"));
221 }
222
223 #[tokio::test]
224 async fn send_custom_user_agent() {
225 let (url, handle) = start_test_server(200).await;
226
227 let sender = WebhookSender::new(test_client()).with_user_agent("my-app/2.0");
228 let secret = WebhookSecret::new(b"key".to_vec());
229
230 sender.send(&url, "msg_1", b"{}", &[&secret]).await.unwrap();
231
232 let raw = handle.await.unwrap();
233 assert!(raw.contains("user-agent: my-app/2.0"));
234 }
235
236 #[tokio::test]
237 async fn send_empty_secrets_rejected() {
238 let sender = WebhookSender::new(test_client());
239
240 let result = sender
241 .send("http://example.com/hook", "msg_1", b"{}", &[])
242 .await;
243 assert!(result.is_err());
244 assert!(result.err().unwrap().message().contains("secret"));
245 }
246
247 #[tokio::test]
248 async fn send_empty_id_rejected() {
249 let sender = WebhookSender::new(test_client());
250 let secret = WebhookSecret::new(b"key".to_vec());
251
252 let result = sender
253 .send("http://example.com/hook", "", b"{}", &[&secret])
254 .await;
255 assert!(result.is_err());
256 assert!(result.err().unwrap().message().contains("id"));
257 }
258
259 #[tokio::test]
260 async fn send_empty_body_accepted() {
261 let (url, handle) = start_test_server(200).await;
262
263 let sender = WebhookSender::new(test_client());
264 let secret = WebhookSecret::new(b"key".to_vec());
265
266 let result = sender.send(&url, "msg_1", b"", &[&secret]).await;
267 assert!(result.is_ok());
268
269 let raw = handle.await.unwrap();
270 assert!(raw.contains("POST / HTTP/1.1"));
272 }
273
274 #[tokio::test]
275 async fn send_returns_response_status() {
276 let (url, handle) = start_test_server(410).await;
277
278 let sender = WebhookSender::new(test_client());
279 let secret = WebhookSecret::new(b"key".to_vec());
280
281 let response = sender.send(&url, "msg_1", b"{}", &[&secret]).await.unwrap();
282 assert_eq!(response.status, StatusCode::GONE);
283
284 handle.await.unwrap();
285 }
286
287 #[tokio::test]
288 async fn send_invalid_url_rejected() {
289 let sender = WebhookSender::new(test_client());
290 let secret = WebhookSecret::new(b"key".to_vec());
291
292 let result = sender
293 .send("not a valid url", "msg_1", b"{}", &[&secret])
294 .await;
295 assert!(result.is_err());
296 assert!(
297 result
298 .err()
299 .unwrap()
300 .message()
301 .contains("invalid webhook url")
302 );
303 }
304}