1use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use uuid::Uuid;
9
10pub type WebhookId = Uuid;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15pub struct Webhook {
16 #[cfg_attr(feature = "openapi", schema(value_type = String))]
18 pub id: WebhookId,
19
20 pub name: String,
22
23 pub description: Option<String>,
25
26 #[cfg_attr(feature = "openapi", schema(value_type = String))]
28 pub workflow_id: Uuid,
29
30 #[serde(skip_serializing)]
32 pub secret: String,
33
34 pub enabled: bool,
36
37 pub event_types: Vec<String>,
39
40 pub required_headers: HashMap<String, String>,
42
43 pub ip_whitelist: Vec<String>,
45
46 pub max_body_size: usize,
48
49 pub timeout_seconds: u32,
51
52 #[cfg_attr(feature = "openapi", schema(value_type = String))]
54 pub owner_id: Uuid,
55
56 #[cfg_attr(feature = "openapi", schema(value_type = String))]
58 pub created_at: DateTime<Utc>,
59
60 #[cfg_attr(feature = "openapi", schema(value_type = String))]
62 pub updated_at: DateTime<Utc>,
63
64 #[cfg_attr(feature = "openapi", schema(value_type = String))]
66 pub last_triggered_at: Option<DateTime<Utc>>,
67
68 pub trigger_count: u64,
70
71 pub failed_count: u64,
73}
74
75impl Webhook {
76 pub fn new(name: String, workflow_id: Uuid, secret: String, owner_id: Uuid) -> Self {
78 let now = Utc::now();
79 Self {
80 id: Uuid::new_v4(),
81 name,
82 description: None,
83 workflow_id,
84 secret,
85 enabled: true,
86 event_types: Vec::new(),
87 required_headers: HashMap::new(),
88 ip_whitelist: Vec::new(),
89 max_body_size: 1024 * 1024, timeout_seconds: 300, owner_id,
92 created_at: now,
93 updated_at: now,
94 last_triggered_at: None,
95 trigger_count: 0,
96 failed_count: 0,
97 }
98 }
99
100 pub fn matches_event(&self, event_type: &str) -> bool {
102 self.event_types.is_empty() || self.event_types.contains(&event_type.to_string())
103 }
104
105 pub fn is_ip_allowed(&self, ip: &str) -> bool {
107 self.ip_whitelist.is_empty() || self.ip_whitelist.contains(&ip.to_string())
108 }
109
110 pub fn validates_headers(&self, headers: &HashMap<String, String>) -> bool {
112 for (key, value) in &self.required_headers {
113 if headers.get(key) != Some(value) {
114 return false;
115 }
116 }
117 true
118 }
119
120 pub fn increment_trigger(&mut self) {
122 self.trigger_count += 1;
123 self.last_triggered_at = Some(Utc::now());
124 }
125
126 pub fn increment_failed(&mut self) {
128 self.failed_count += 1;
129 }
130
131 pub fn to_safe_view(&self) -> WebhookView {
133 WebhookView {
134 id: self.id,
135 name: self.name.clone(),
136 description: self.description.clone(),
137 workflow_id: self.workflow_id,
138 enabled: self.enabled,
139 event_types: self.event_types.clone(),
140 owner_id: self.owner_id,
141 created_at: self.created_at,
142 updated_at: self.updated_at,
143 last_triggered_at: self.last_triggered_at,
144 trigger_count: self.trigger_count,
145 failed_count: self.failed_count,
146 success_rate: self.success_rate(),
147 }
148 }
149
150 pub fn success_rate(&self) -> f64 {
152 if self.trigger_count == 0 {
153 return 100.0;
154 }
155 let successful = self.trigger_count - self.failed_count;
156 (successful as f64 / self.trigger_count as f64) * 100.0
157 }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
163pub struct WebhookView {
164 #[cfg_attr(feature = "openapi", schema(value_type = String))]
165 pub id: WebhookId,
166 pub name: String,
167 pub description: Option<String>,
168 #[cfg_attr(feature = "openapi", schema(value_type = String))]
169 pub workflow_id: Uuid,
170 pub enabled: bool,
171 pub event_types: Vec<String>,
172 #[cfg_attr(feature = "openapi", schema(value_type = String))]
173 pub owner_id: Uuid,
174 #[cfg_attr(feature = "openapi", schema(value_type = String))]
175 pub created_at: DateTime<Utc>,
176 #[cfg_attr(feature = "openapi", schema(value_type = String))]
177 pub updated_at: DateTime<Utc>,
178 #[cfg_attr(feature = "openapi", schema(value_type = String))]
179 pub last_triggered_at: Option<DateTime<Utc>>,
180 pub trigger_count: u64,
181 pub failed_count: u64,
182 pub success_rate: f64,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
187#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
188pub struct WebhookEvent {
189 #[cfg_attr(feature = "openapi", schema(value_type = String))]
190 pub id: Uuid,
191
192 #[cfg_attr(feature = "openapi", schema(value_type = String))]
193 pub webhook_id: WebhookId,
194
195 pub event_type: String,
196
197 pub payload: serde_json::Value,
198
199 pub headers: HashMap<String, String>,
200
201 pub source_ip: String,
202
203 #[cfg_attr(feature = "openapi", schema(value_type = String))]
204 pub received_at: DateTime<Utc>,
205
206 #[cfg_attr(feature = "openapi", schema(value_type = String))]
207 pub processed_at: Option<DateTime<Utc>>,
208
209 pub status: WebhookEventStatus,
210
211 #[cfg_attr(feature = "openapi", schema(value_type = String))]
212 pub execution_id: Option<Uuid>,
213
214 pub error_message: Option<String>,
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
219#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
220pub enum WebhookEventStatus {
221 Pending,
222 Processing,
223 Completed,
224 Failed,
225 Rejected,
226}
227
228impl std::fmt::Display for WebhookEventStatus {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 match self {
231 WebhookEventStatus::Pending => write!(f, "PENDING"),
232 WebhookEventStatus::Processing => write!(f, "PROCESSING"),
233 WebhookEventStatus::Completed => write!(f, "COMPLETED"),
234 WebhookEventStatus::Failed => write!(f, "FAILED"),
235 WebhookEventStatus::Rejected => write!(f, "REJECTED"),
236 }
237 }
238}
239
240#[derive(Debug, Serialize, Deserialize)]
242#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
243pub struct CreateWebhookRequest {
244 pub name: String,
245 pub description: Option<String>,
246 #[cfg_attr(feature = "openapi", schema(value_type = String))]
247 pub workflow_id: Uuid,
248 pub event_types: Vec<String>,
249 pub required_headers: Option<HashMap<String, String>>,
250 pub ip_whitelist: Option<Vec<String>>,
251 pub max_body_size: Option<usize>,
252 pub timeout_seconds: Option<u32>,
253}
254
255#[derive(Debug, Serialize, Deserialize)]
257#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
258pub struct UpdateWebhookRequest {
259 pub name: Option<String>,
260 pub description: Option<String>,
261 pub enabled: Option<bool>,
262 pub event_types: Option<Vec<String>>,
263 pub required_headers: Option<HashMap<String, String>>,
264 pub ip_whitelist: Option<Vec<String>>,
265 pub max_body_size: Option<usize>,
266 pub timeout_seconds: Option<u32>,
267}
268
269#[derive(Debug, Serialize, Deserialize)]
271pub struct WebhookTriggerRequest {
272 pub event_type: String,
273 pub payload: serde_json::Value,
274}
275
276#[derive(Debug, Serialize, Deserialize)]
278#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
279pub struct WebhookRegistrationResponse {
280 #[cfg_attr(feature = "openapi", schema(value_type = String))]
281 pub webhook_id: WebhookId,
282 pub webhook_url: String,
283 pub secret: String,
284 pub created_at: DateTime<Utc>,
285}
286
287pub fn generate_webhook_secret() -> String {
289 use rand::Rng;
290 const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
291 const SECRET_LEN: usize = 32;
292
293 let mut rng = rand::rng();
294 (0..SECRET_LEN)
295 .map(|_| {
296 let idx = rng.random_range(0..CHARSET.len());
297 CHARSET[idx] as char
298 })
299 .collect()
300}
301
302pub fn verify_webhook_signature(secret: &str, payload: &[u8], signature: &str) -> bool {
304 use hmac::{Hmac, Mac};
305 use sha2::Sha256;
306
307 type HmacSha256 = Hmac<Sha256>;
308
309 let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) {
310 Ok(m) => m,
311 Err(_) => return false,
312 };
313
314 mac.update(payload);
315
316 let expected = format!("sha256={}", hex::encode(mac.finalize().into_bytes()));
317
318 expected == signature
319}
320
321pub fn create_webhook_signature(secret: &str, payload: &[u8]) -> String {
323 use hmac::{Hmac, Mac};
324 use sha2::Sha256;
325
326 type HmacSha256 = Hmac<Sha256>;
327
328 let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("Invalid secret");
329 mac.update(payload);
330
331 format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn test_webhook_creation() {
340 let workflow_id = Uuid::new_v4();
341 let owner_id = Uuid::new_v4();
342 let webhook = Webhook::new(
343 "Test Webhook".to_string(),
344 workflow_id,
345 "test_secret".to_string(),
346 owner_id,
347 );
348
349 assert_eq!(webhook.name, "Test Webhook");
350 assert_eq!(webhook.workflow_id, workflow_id);
351 assert_eq!(webhook.owner_id, owner_id);
352 assert!(webhook.enabled);
353 assert_eq!(webhook.trigger_count, 0);
354 assert_eq!(webhook.failed_count, 0);
355 }
356
357 #[test]
358 fn test_event_type_matching() {
359 let mut webhook = Webhook::new(
360 "Test".to_string(),
361 Uuid::new_v4(),
362 "secret".to_string(),
363 Uuid::new_v4(),
364 );
365
366 assert!(webhook.matches_event("push"));
368 assert!(webhook.matches_event("pull_request"));
369
370 webhook.event_types = vec!["push".to_string(), "pull_request".to_string()];
372 assert!(webhook.matches_event("push"));
373 assert!(webhook.matches_event("pull_request"));
374 assert!(!webhook.matches_event("release"));
375 }
376
377 #[test]
378 fn test_ip_whitelist() {
379 let mut webhook = Webhook::new(
380 "Test".to_string(),
381 Uuid::new_v4(),
382 "secret".to_string(),
383 Uuid::new_v4(),
384 );
385
386 assert!(webhook.is_ip_allowed("192.168.1.1"));
388 assert!(webhook.is_ip_allowed("10.0.0.1"));
389
390 webhook.ip_whitelist = vec!["192.168.1.1".to_string(), "10.0.0.0/8".to_string()];
392 assert!(webhook.is_ip_allowed("192.168.1.1"));
393 assert!(!webhook.is_ip_allowed("172.16.0.1"));
394 }
395
396 #[test]
397 fn test_header_validation() {
398 let mut webhook = Webhook::new(
399 "Test".to_string(),
400 Uuid::new_v4(),
401 "secret".to_string(),
402 Uuid::new_v4(),
403 );
404
405 let headers = HashMap::new();
407 assert!(webhook.validates_headers(&headers));
408
409 webhook
411 .required_headers
412 .insert("X-Custom-Header".to_string(), "expected-value".to_string());
413
414 assert!(!webhook.validates_headers(&headers));
416
417 let mut headers = HashMap::new();
419 headers.insert("X-Custom-Header".to_string(), "wrong-value".to_string());
420 assert!(!webhook.validates_headers(&headers));
421
422 headers.insert("X-Custom-Header".to_string(), "expected-value".to_string());
424 assert!(webhook.validates_headers(&headers));
425 }
426
427 #[test]
428 fn test_trigger_counting() {
429 let mut webhook = Webhook::new(
430 "Test".to_string(),
431 Uuid::new_v4(),
432 "secret".to_string(),
433 Uuid::new_v4(),
434 );
435
436 assert_eq!(webhook.trigger_count, 0);
437 assert!(webhook.last_triggered_at.is_none());
438
439 webhook.increment_trigger();
440 assert_eq!(webhook.trigger_count, 1);
441 assert!(webhook.last_triggered_at.is_some());
442
443 webhook.increment_trigger();
444 assert_eq!(webhook.trigger_count, 2);
445
446 webhook.increment_failed();
447 assert_eq!(webhook.failed_count, 1);
448 }
449
450 #[test]
451 fn test_success_rate() {
452 let mut webhook = Webhook::new(
453 "Test".to_string(),
454 Uuid::new_v4(),
455 "secret".to_string(),
456 Uuid::new_v4(),
457 );
458
459 assert_eq!(webhook.success_rate(), 100.0);
461
462 webhook.trigger_count = 10;
464 webhook.failed_count = 0;
465 assert_eq!(webhook.success_rate(), 100.0);
466
467 webhook.trigger_count = 10;
469 webhook.failed_count = 5;
470 assert_eq!(webhook.success_rate(), 50.0);
471
472 webhook.trigger_count = 10;
474 webhook.failed_count = 10;
475 assert_eq!(webhook.success_rate(), 0.0);
476 }
477
478 #[test]
479 fn test_safe_view() {
480 let webhook = Webhook::new(
481 "Test Webhook".to_string(),
482 Uuid::new_v4(),
483 "super_secret_value".to_string(),
484 Uuid::new_v4(),
485 );
486
487 let view = webhook.to_safe_view();
488
489 assert_eq!(view.id, webhook.id);
490 assert_eq!(view.name, webhook.name);
491 assert_eq!(view.workflow_id, webhook.workflow_id);
492 }
494
495 #[test]
496 fn test_webhook_secret_generation() {
497 let secret1 = generate_webhook_secret();
498 let secret2 = generate_webhook_secret();
499
500 assert_eq!(secret1.len(), 32);
502 assert_eq!(secret2.len(), 32);
503
504 assert_ne!(secret1, secret2);
506
507 assert!(secret1.chars().all(|c| c.is_ascii_alphanumeric()));
509 }
510
511 #[test]
512 fn test_signature_verification() {
513 let secret = "test_secret_key";
514 let payload = b"test payload data";
515
516 let signature = create_webhook_signature(secret, payload);
518
519 assert!(verify_webhook_signature(secret, payload, &signature));
521
522 assert!(!verify_webhook_signature(
524 "wrong_secret",
525 payload,
526 &signature
527 ));
528
529 assert!(!verify_webhook_signature(
531 secret,
532 b"wrong payload",
533 &signature
534 ));
535
536 assert!(!verify_webhook_signature(
538 secret,
539 payload,
540 "invalid_signature"
541 ));
542 }
543
544 #[test]
545 fn test_webhook_event_status_display() {
546 assert_eq!(WebhookEventStatus::Pending.to_string(), "PENDING");
547 assert_eq!(WebhookEventStatus::Processing.to_string(), "PROCESSING");
548 assert_eq!(WebhookEventStatus::Completed.to_string(), "COMPLETED");
549 assert_eq!(WebhookEventStatus::Failed.to_string(), "FAILED");
550 assert_eq!(WebhookEventStatus::Rejected.to_string(), "REJECTED");
551 }
552}