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    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    /// Send a webhook following the Standard Webhooks protocol.
75    ///
76    /// Signs the payload with every secret in `secrets` (supports key rotation)
77    /// and POSTs to `url` with the three Standard Webhooks headers:
78    /// `webhook-id`, `webhook-timestamp`, and `webhook-signature`.
79    ///
80    /// - `url`: the endpoint to POST to
81    /// - `id`: unique message ID for idempotency (e.g. `msg_<ulid>`)
82    /// - `body`: raw request body (typically JSON)
83    /// - `secrets`: one or more signing secrets; at least one is required
84    ///
85    /// # Errors
86    ///
87    /// Returns [`Error`](crate::Error) when:
88    /// - `secrets` is empty (400 Bad Request)
89    /// - `id` is empty (400 Bad Request)
90    /// - `url` is not a valid URI (400 Bad Request)
91    /// - the HTTP request fails (network error, timeout, etc.)
92    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        // Validate URL early — it comes from user/app input
106        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    /// Start a minimal HTTP server that captures the request and returns the given status.
162    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        // The request was sent — verify it reached the server
271        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}