datasynth_runtime/
webhooks.rs1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use tracing::info;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
12#[serde(rename_all = "snake_case")]
13pub enum WebhookEvent {
14 RunStarted,
16 RunCompleted,
18 RunFailed,
20 GateViolation,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct WebhookPayload {
27 pub event: WebhookEvent,
29 pub run_id: String,
31 pub timestamp: String,
33 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
35 pub data: HashMap<String, serde_json::Value>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct WebhookEndpoint {
41 pub url: String,
43 pub events: Vec<WebhookEvent>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub secret: Option<String>,
48 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
50 pub headers: HashMap<String, String>,
51 #[serde(default = "default_max_retries")]
53 pub max_retries: u32,
54 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct WebhookConfig {
69 #[serde(default)]
71 pub enabled: bool,
72 #[serde(default)]
74 pub endpoints: Vec<WebhookEndpoint>,
75}
76
77#[derive(Debug, Clone)]
82pub struct WebhookDispatcher {
83 config: WebhookConfig,
84}
85
86impl WebhookDispatcher {
87 pub fn new(config: WebhookConfig) -> Self {
89 Self { config }
90 }
91
92 pub fn disabled() -> Self {
94 Self {
95 config: WebhookConfig::default(),
96 }
97 }
98
99 pub fn is_enabled(&self) -> bool {
101 self.config.enabled && !self.config.endpoints.is_empty()
102 }
103
104 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 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 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 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); }
231}