Skip to main content

datasynth_runtime/
webhooks.rs

1//! Webhook notification system for generation events.
2//!
3//! Sends HTTP POST notifications to configured endpoints when
4//! generation events occur (started, completed, failed, gate_violation).
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use tracing::info;
9
10/// Webhook event types.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
12#[serde(rename_all = "snake_case")]
13pub enum WebhookEvent {
14    /// Generation run started.
15    RunStarted,
16    /// Generation run completed successfully.
17    RunCompleted,
18    /// Generation run failed.
19    RunFailed,
20    /// Quality gate violation detected.
21    GateViolation,
22}
23
24/// Payload sent to webhook endpoints.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct WebhookPayload {
27    /// Event type.
28    pub event: WebhookEvent,
29    /// Run identifier.
30    pub run_id: String,
31    /// ISO 8601 timestamp.
32    pub timestamp: String,
33    /// Additional event-specific data.
34    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
35    pub data: HashMap<String, serde_json::Value>,
36}
37
38/// Configuration for a single webhook endpoint.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct WebhookEndpoint {
41    /// Target URL for the webhook.
42    pub url: String,
43    /// Events this endpoint subscribes to.
44    pub events: Vec<WebhookEvent>,
45    /// Optional secret for HMAC-SHA256 signature (X-Webhook-Signature header).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub secret: Option<String>,
48    /// Optional custom headers.
49    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
50    pub headers: HashMap<String, String>,
51    /// Maximum retry attempts (default: 3).
52    #[serde(default = "default_max_retries")]
53    pub max_retries: u32,
54    /// Timeout in seconds (default: 10).
55    #[serde(default = "default_timeout_secs")]
56    pub timeout_secs: u64,
57}
58
59fn default_max_retries() -> u32 {
60    3
61}
62fn default_timeout_secs() -> u64 {
63    10
64}
65
66/// Webhook notification configuration.
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct WebhookConfig {
69    /// Whether webhooks are enabled.
70    #[serde(default)]
71    pub enabled: bool,
72    /// Configured webhook endpoints.
73    #[serde(default)]
74    pub endpoints: Vec<WebhookEndpoint>,
75}
76
77/// Webhook dispatcher that sends notifications to configured endpoints.
78///
79/// Uses a fire-and-forget pattern — delivery failures are logged but
80/// do not block generation.
81#[derive(Debug, Clone)]
82pub struct WebhookDispatcher {
83    config: WebhookConfig,
84}
85
86impl WebhookDispatcher {
87    /// Create a new dispatcher from configuration.
88    pub fn new(config: WebhookConfig) -> Self {
89        Self { config }
90    }
91
92    /// Create a disabled dispatcher.
93    pub fn disabled() -> Self {
94        Self {
95            config: WebhookConfig::default(),
96        }
97    }
98
99    /// Check if webhooks are enabled.
100    pub fn is_enabled(&self) -> bool {
101        self.config.enabled && !self.config.endpoints.is_empty()
102    }
103
104    /// Dispatch a webhook event to all matching endpoints.
105    ///
106    /// This is a synchronous logging-only implementation.
107    /// In production, this would use an async HTTP client.
108    pub fn dispatch(&self, payload: &WebhookPayload) {
109        if !self.is_enabled() {
110            return;
111        }
112
113        for endpoint in &self.config.endpoints {
114            if endpoint.events.contains(&payload.event) {
115                info!(
116                    url = %endpoint.url,
117                    event = ?payload.event,
118                    run_id = %payload.run_id,
119                    "Webhook notification queued"
120                );
121            }
122        }
123    }
124
125    /// Create a payload for a run-started event.
126    pub fn run_started_payload(run_id: &str) -> WebhookPayload {
127        WebhookPayload {
128            event: WebhookEvent::RunStarted,
129            run_id: run_id.to_string(),
130            timestamp: chrono::Utc::now().to_rfc3339(),
131            data: HashMap::new(),
132        }
133    }
134
135    /// Create a payload for a run-completed event.
136    pub fn run_completed_payload(
137        run_id: &str,
138        total_entries: usize,
139        duration_secs: f64,
140    ) -> WebhookPayload {
141        let mut data = HashMap::new();
142        data.insert(
143            "total_entries".to_string(),
144            serde_json::json!(total_entries),
145        );
146        data.insert(
147            "duration_secs".to_string(),
148            serde_json::json!(duration_secs),
149        );
150        WebhookPayload {
151            event: WebhookEvent::RunCompleted,
152            run_id: run_id.to_string(),
153            timestamp: chrono::Utc::now().to_rfc3339(),
154            data,
155        }
156    }
157
158    /// Create a payload for a gate-violation event.
159    pub fn gate_violation_payload(run_id: &str, failed_gates: Vec<String>) -> WebhookPayload {
160        let mut data = HashMap::new();
161        data.insert("failed_gates".to_string(), serde_json::json!(failed_gates));
162        WebhookPayload {
163            event: WebhookEvent::GateViolation,
164            run_id: run_id.to_string(),
165            timestamp: chrono::Utc::now().to_rfc3339(),
166            data,
167        }
168    }
169}
170
171#[cfg(test)]
172#[allow(clippy::unwrap_used)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn test_disabled_dispatcher() {
178        let d = WebhookDispatcher::disabled();
179        assert!(!d.is_enabled());
180    }
181
182    #[test]
183    fn test_enabled_dispatcher() {
184        let config = WebhookConfig {
185            enabled: true,
186            endpoints: vec![WebhookEndpoint {
187                url: "https://example.com/webhook".to_string(),
188                events: vec![WebhookEvent::RunCompleted],
189                secret: None,
190                headers: HashMap::new(),
191                max_retries: 3,
192                timeout_secs: 10,
193            }],
194        };
195        let d = WebhookDispatcher::new(config);
196        assert!(d.is_enabled());
197    }
198
199    #[test]
200    fn test_payload_serialization() {
201        let payload = WebhookDispatcher::run_completed_payload("run-123", 1000, 5.5);
202        let json = serde_json::to_string(&payload).expect("serialization should succeed");
203        assert!(json.contains("run_completed"));
204        assert!(json.contains("run-123"));
205        assert!(json.contains("1000"));
206    }
207
208    #[test]
209    fn test_gate_violation_payload() {
210        let payload = WebhookDispatcher::gate_violation_payload(
211            "run-456",
212            vec!["benford_mad".to_string(), "balance_coherence".to_string()],
213        );
214        assert_eq!(payload.event, WebhookEvent::GateViolation);
215        assert!(payload.data.contains_key("failed_gates"));
216    }
217
218    #[test]
219    fn test_webhook_config_default() {
220        let config = WebhookConfig::default();
221        assert!(!config.enabled);
222        assert!(config.endpoints.is_empty());
223    }
224
225    #[test]
226    fn test_dispatch_noop_when_disabled() {
227        let d = WebhookDispatcher::disabled();
228        let payload = WebhookDispatcher::run_started_payload("run-789");
229        d.dispatch(&payload); // Should not panic
230    }
231}