sirr_server/webhooks.rs
1//! Per-key, fire-and-forget webhook delivery.
2//!
3//! When a key has a `webhook_url` set, the server POSTs a `WebhookEvent` JSON
4//! payload to that URL after each successful secret lifecycle event. The POST
5//! is dispatched in a background tokio task — it never blocks the HTTP response.
6//! No retries, no queue, no acknowledgement required.
7
8use reqwest::Client;
9use serde::Serialize;
10
11/// The JSON body sent to a webhook URL.
12#[derive(Debug, Clone, Serialize)]
13pub struct WebhookEvent {
14 /// Lifecycle event type. One of:
15 /// `secret.created`, `secret.read`, `secret.patched`, `secret.burned`, `secret.expired`.
16 #[serde(rename = "type")]
17 pub event_type: String,
18 /// The secret hash that triggered the event.
19 pub hash: String,
20 /// Unix seconds when the event occurred.
21 pub at: i64,
22 /// IP address of the caller (empty string if unknown).
23 pub ip: String,
24}
25
26/// Shared HTTP client for webhook delivery. Clone-cheap (Arc internally).
27#[derive(Clone)]
28pub struct WebhookSender {
29 client: Client,
30}
31
32impl WebhookSender {
33 /// Build a sender with a 10-second timeout per request.
34 pub fn new() -> Self {
35 Self {
36 client: Client::builder()
37 .timeout(std::time::Duration::from_secs(10))
38 .build()
39 .unwrap_or_default(),
40 }
41 }
42
43 /// Fire-and-forget: spawn a tokio task, POST `event` to `url`, never block.
44 pub fn fire(&self, url: String, event: WebhookEvent) {
45 let client = self.client.clone();
46 tokio::spawn(async move {
47 match client.post(&url).json(&event).send().await {
48 Ok(resp) => {
49 tracing::debug!("webhook {} → {}", url, resp.status());
50 }
51 Err(e) => {
52 tracing::warn!("webhook {} failed: {}", url, e);
53 }
54 }
55 });
56 }
57}
58
59impl Default for WebhookSender {
60 fn default() -> Self {
61 Self::new()
62 }
63}