oxify_model/
webhook.rs

1//! Webhook types for event-driven workflow triggering
2//!
3//! Allows external systems to trigger workflows via HTTP callbacks
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use uuid::Uuid;
9
10pub type WebhookId = Uuid;
11
12/// Webhook configuration
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
15pub struct Webhook {
16    /// Unique webhook identifier
17    #[cfg_attr(feature = "openapi", schema(value_type = String))]
18    pub id: WebhookId,
19
20    /// Webhook name
21    pub name: String,
22
23    /// Optional description
24    pub description: Option<String>,
25
26    /// Workflow to trigger
27    #[cfg_attr(feature = "openapi", schema(value_type = String))]
28    pub workflow_id: Uuid,
29
30    /// Webhook secret for HMAC verification
31    #[serde(skip_serializing)]
32    pub secret: String,
33
34    /// Whether webhook is active
35    pub enabled: bool,
36
37    /// Event types to listen for (empty = all)
38    pub event_types: Vec<String>,
39
40    /// Custom headers to require
41    pub required_headers: HashMap<String, String>,
42
43    /// IP whitelist (empty = no restriction)
44    pub ip_whitelist: Vec<String>,
45
46    /// Maximum request body size (bytes)
47    pub max_body_size: usize,
48
49    /// Timeout for workflow execution (seconds)
50    pub timeout_seconds: u32,
51
52    /// Owner user ID
53    #[cfg_attr(feature = "openapi", schema(value_type = String))]
54    pub owner_id: Uuid,
55
56    /// Creation timestamp
57    #[cfg_attr(feature = "openapi", schema(value_type = String))]
58    pub created_at: DateTime<Utc>,
59
60    /// Last updated timestamp
61    #[cfg_attr(feature = "openapi", schema(value_type = String))]
62    pub updated_at: DateTime<Utc>,
63
64    /// Last triggered timestamp
65    #[cfg_attr(feature = "openapi", schema(value_type = String))]
66    pub last_triggered_at: Option<DateTime<Utc>>,
67
68    /// Total trigger count
69    pub trigger_count: u64,
70
71    /// Failed trigger count
72    pub failed_count: u64,
73}
74
75impl Webhook {
76    /// Create a new webhook
77    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, // 1MB default
90            timeout_seconds: 300,       // 5 minutes default
91            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    /// Check if event type matches
101    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    /// Check if IP is allowed
106    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    /// Check required headers
111    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    /// Increment trigger count
121    pub fn increment_trigger(&mut self) {
122        self.trigger_count += 1;
123        self.last_triggered_at = Some(Utc::now());
124    }
125
126    /// Increment failed count
127    pub fn increment_failed(&mut self) {
128        self.failed_count += 1;
129    }
130
131    /// Create safe view without secret
132    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    /// Calculate success rate
151    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/// Safe webhook view (no secret)
161#[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/// Webhook event received
186#[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/// Webhook event processing status
218#[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/// Request to create a webhook
241#[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/// Request to update a webhook
256#[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/// Webhook trigger request
270#[derive(Debug, Serialize, Deserialize)]
271pub struct WebhookTriggerRequest {
272    pub event_type: String,
273    pub payload: serde_json::Value,
274}
275
276/// Response after webhook registration
277#[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
287/// Helper to generate webhook secret
288pub 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
302/// Helper to verify HMAC signature
303pub 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
321/// Helper to create HMAC signature for testing
322pub 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        // Empty event_types means match all
367        assert!(webhook.matches_event("push"));
368        assert!(webhook.matches_event("pull_request"));
369
370        // Specific event types
371        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        // Empty whitelist means allow all
387        assert!(webhook.is_ip_allowed("192.168.1.1"));
388        assert!(webhook.is_ip_allowed("10.0.0.1"));
389
390        // With whitelist
391        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        // No required headers
406        let headers = HashMap::new();
407        assert!(webhook.validates_headers(&headers));
408
409        // With required headers
410        webhook
411            .required_headers
412            .insert("X-Custom-Header".to_string(), "expected-value".to_string());
413
414        // Missing header
415        assert!(!webhook.validates_headers(&headers));
416
417        // Wrong value
418        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        // Correct value
423        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        // No triggers yet
460        assert_eq!(webhook.success_rate(), 100.0);
461
462        // All successful
463        webhook.trigger_count = 10;
464        webhook.failed_count = 0;
465        assert_eq!(webhook.success_rate(), 100.0);
466
467        // 50% success rate
468        webhook.trigger_count = 10;
469        webhook.failed_count = 5;
470        assert_eq!(webhook.success_rate(), 50.0);
471
472        // All failed
473        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        // Note: secret is not in the view struct
493    }
494
495    #[test]
496    fn test_webhook_secret_generation() {
497        let secret1 = generate_webhook_secret();
498        let secret2 = generate_webhook_secret();
499
500        // Secrets should be 32 characters
501        assert_eq!(secret1.len(), 32);
502        assert_eq!(secret2.len(), 32);
503
504        // Secrets should be different
505        assert_ne!(secret1, secret2);
506
507        // Should only contain alphanumeric characters
508        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        // Create signature
517        let signature = create_webhook_signature(secret, payload);
518
519        // Verify should pass
520        assert!(verify_webhook_signature(secret, payload, &signature));
521
522        // Wrong secret should fail
523        assert!(!verify_webhook_signature(
524            "wrong_secret",
525            payload,
526            &signature
527        ));
528
529        // Wrong payload should fail
530        assert!(!verify_webhook_signature(
531            secret,
532            b"wrong payload",
533            &signature
534        ));
535
536        // Wrong signature format should fail
537        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}