Skip to main content

modo/webhook/
sender.rs

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
16/// High-level webhook sender that signs and delivers payloads using the
17/// Standard Webhooks protocol.
18///
19/// Clone-cheap: the inner state is wrapped in `Arc`.
20pub 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    /// Create a new sender with the given HTTP client.
34    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    /// Override the default `User-Agent` header sent with every request.
44    ///
45    /// The value must be a valid HTTP header value (visible ASCII only, no
46    /// control characters). Invalid values are silently ignored.
47    ///
48    /// # Panics
49    ///
50    /// Panics if called after the sender has been cloned. Call this immediately
51    /// after [`WebhookSender::new`] before handing clones to other tasks.
52    pub fn with_user_agent(mut self, user_agent: impl Into<String>) -> Self {
53        let ua = user_agent.into();
54        // Validate before storing — prevents panic in send().
55        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    /// Convenience constructor using a default `reqwest::Client` with a
65    /// 30-second timeout.
66    ///
67    /// # Panics
68    ///
69    /// Panics if the underlying `reqwest::Client` cannot be built (e.g. the
70    /// platform TLS backend fails to initialize).
71    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    /// Send a webhook following the Standard Webhooks protocol.
80    ///
81    /// Signs the payload with every secret in `secrets` (supports key rotation)
82    /// and POSTs to `url` with the three Standard Webhooks headers:
83    /// `webhook-id`, `webhook-timestamp`, and `webhook-signature`.
84    ///
85    /// - `url`: the endpoint to POST to
86    /// - `id`: unique message ID for idempotency (e.g. `msg_<ulid>`)
87    /// - `body`: raw request body (typically JSON)
88    /// - `secrets`: one or more signing secrets; at least one is required
89    ///
90    /// # Errors
91    ///
92    /// Returns [`Error`](crate::Error) when:
93    /// - `secrets` is empty (400 Bad Request)
94    /// - `id` is empty (400 Bad Request)
95    /// - `url` is not a valid URI (400 Bad Request)
96    /// - the HTTP request fails (network error, timeout, etc.)
97    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        // Validate URL early — it comes from user/app input
111        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    /// Start a minimal HTTP server that captures the request and returns the given status.
167    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        // The request was sent — verify it reached the server
276        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}