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