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)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_disabled_dispatcher() {
177        let d = WebhookDispatcher::disabled();
178        assert!(!d.is_enabled());
179    }
180
181    #[test]
182    fn test_enabled_dispatcher() {
183        let config = WebhookConfig {
184            enabled: true,
185            endpoints: vec![WebhookEndpoint {
186                url: "https://example.com/webhook".to_string(),
187                events: vec![WebhookEvent::RunCompleted],
188                secret: None,
189                headers: HashMap::new(),
190                max_retries: 3,
191                timeout_secs: 10,
192            }],
193        };
194        let d = WebhookDispatcher::new(config);
195        assert!(d.is_enabled());
196    }
197
198    #[test]
199    fn test_payload_serialization() {
200        let payload = WebhookDispatcher::run_completed_payload("run-123", 1000, 5.5);
201        let json = serde_json::to_string(&payload).expect("serialization should succeed");
202        assert!(json.contains("run_completed"));
203        assert!(json.contains("run-123"));
204        assert!(json.contains("1000"));
205    }
206
207    #[test]
208    fn test_gate_violation_payload() {
209        let payload = WebhookDispatcher::gate_violation_payload(
210            "run-456",
211            vec!["benford_mad".to_string(), "balance_coherence".to_string()],
212        );
213        assert_eq!(payload.event, WebhookEvent::GateViolation);
214        assert!(payload.data.contains_key("failed_gates"));
215    }
216
217    #[test]
218    fn test_webhook_config_default() {
219        let config = WebhookConfig::default();
220        assert!(!config.enabled);
221        assert!(config.endpoints.is_empty());
222    }
223
224    #[test]
225    fn test_dispatch_noop_when_disabled() {
226        let d = WebhookDispatcher::disabled();
227        let payload = WebhookDispatcher::run_started_payload("run-789");
228        d.dispatch(&payload); // Should not panic
229    }
230}