guts_auth/
webhook.rs

1//! Webhook types for event notifications.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashSet;
5
6/// Events that can trigger webhooks.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum WebhookEvent {
10    /// Push to repository.
11    Push,
12    /// Pull request opened, closed, merged, etc.
13    PullRequest,
14    /// Review submitted on a pull request.
15    PullRequestReview,
16    /// Comment on a pull request.
17    PullRequestComment,
18    /// Issue opened, closed, etc.
19    Issue,
20    /// Comment on an issue.
21    IssueComment,
22    /// Branch or tag created.
23    Create,
24    /// Branch or tag deleted.
25    Delete,
26    /// Repository forked.
27    Fork,
28    /// Repository starred.
29    Star,
30}
31
32impl WebhookEvent {
33    /// Parse from string.
34    pub fn parse(s: &str) -> Option<Self> {
35        match s.to_lowercase().as_str() {
36            "push" => Some(WebhookEvent::Push),
37            "pull_request" | "pr" => Some(WebhookEvent::PullRequest),
38            "pull_request_review" | "pr_review" => Some(WebhookEvent::PullRequestReview),
39            "pull_request_comment" | "pr_comment" => Some(WebhookEvent::PullRequestComment),
40            "issue" | "issues" => Some(WebhookEvent::Issue),
41            "issue_comment" => Some(WebhookEvent::IssueComment),
42            "create" => Some(WebhookEvent::Create),
43            "delete" => Some(WebhookEvent::Delete),
44            "fork" => Some(WebhookEvent::Fork),
45            "star" => Some(WebhookEvent::Star),
46            _ => None,
47        }
48    }
49
50    /// Get all available events.
51    pub fn all() -> Vec<WebhookEvent> {
52        vec![
53            WebhookEvent::Push,
54            WebhookEvent::PullRequest,
55            WebhookEvent::PullRequestReview,
56            WebhookEvent::PullRequestComment,
57            WebhookEvent::Issue,
58            WebhookEvent::IssueComment,
59            WebhookEvent::Create,
60            WebhookEvent::Delete,
61            WebhookEvent::Fork,
62            WebhookEvent::Star,
63        ]
64    }
65}
66
67impl std::fmt::Display for WebhookEvent {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            WebhookEvent::Push => write!(f, "push"),
71            WebhookEvent::PullRequest => write!(f, "pull_request"),
72            WebhookEvent::PullRequestReview => write!(f, "pull_request_review"),
73            WebhookEvent::PullRequestComment => write!(f, "pull_request_comment"),
74            WebhookEvent::Issue => write!(f, "issue"),
75            WebhookEvent::IssueComment => write!(f, "issue_comment"),
76            WebhookEvent::Create => write!(f, "create"),
77            WebhookEvent::Delete => write!(f, "delete"),
78            WebhookEvent::Fork => write!(f, "fork"),
79            WebhookEvent::Star => write!(f, "star"),
80        }
81    }
82}
83
84/// A webhook subscription for a repository.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct Webhook {
87    /// Unique webhook ID.
88    pub id: u64,
89    /// Repository key (e.g., "owner/repo").
90    pub repo_key: String,
91    /// Callback URL for webhook delivery.
92    pub url: String,
93    /// Optional HMAC secret for payload signing.
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub secret: Option<String>,
96    /// Events that trigger this webhook.
97    pub events: HashSet<WebhookEvent>,
98    /// Whether the webhook is active.
99    pub active: bool,
100    /// Content type for delivery (application/json or application/x-www-form-urlencoded).
101    pub content_type: String,
102    /// Whether to send SSL verification.
103    pub insecure_ssl: bool,
104    /// When the webhook was created (Unix timestamp).
105    pub created_at: u64,
106    /// When the webhook was last updated (Unix timestamp).
107    pub updated_at: u64,
108    /// Number of recent deliveries.
109    pub delivery_count: u64,
110    /// Number of failed deliveries.
111    pub failure_count: u64,
112}
113
114impl Webhook {
115    /// Create a new webhook.
116    pub fn new(id: u64, repo_key: String, url: String, events: HashSet<WebhookEvent>) -> Self {
117        let now = Self::now();
118        Self {
119            id,
120            repo_key,
121            url,
122            secret: None,
123            events,
124            active: true,
125            content_type: "application/json".into(),
126            insecure_ssl: false,
127            created_at: now,
128            updated_at: now,
129            delivery_count: 0,
130            failure_count: 0,
131        }
132    }
133
134    /// Set the webhook secret for HMAC signing.
135    pub fn with_secret(mut self, secret: String) -> Self {
136        self.secret = Some(secret);
137        self
138    }
139
140    /// Check if this webhook should fire for an event.
141    pub fn should_fire(&self, event: WebhookEvent) -> bool {
142        self.active && self.events.contains(&event)
143    }
144
145    /// Add an event to trigger this webhook.
146    pub fn add_event(&mut self, event: WebhookEvent) {
147        self.events.insert(event);
148        self.updated_at = Self::now();
149    }
150
151    /// Remove an event.
152    pub fn remove_event(&mut self, event: WebhookEvent) -> bool {
153        let removed = self.events.remove(&event);
154        if removed {
155            self.updated_at = Self::now();
156        }
157        removed
158    }
159
160    /// Enable the webhook.
161    pub fn enable(&mut self) {
162        self.active = true;
163        self.updated_at = Self::now();
164    }
165
166    /// Disable the webhook.
167    pub fn disable(&mut self) {
168        self.active = false;
169        self.updated_at = Self::now();
170    }
171
172    /// Record a successful delivery.
173    pub fn record_success(&mut self) {
174        self.delivery_count += 1;
175    }
176
177    /// Record a failed delivery.
178    pub fn record_failure(&mut self) {
179        self.delivery_count += 1;
180        self.failure_count += 1;
181    }
182
183    fn now() -> u64 {
184        std::time::SystemTime::now()
185            .duration_since(std::time::UNIX_EPOCH)
186            .unwrap_or_default()
187            .as_secs()
188    }
189}
190
191/// Request to create a webhook.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct CreateWebhookRequest {
194    /// Callback URL.
195    pub url: String,
196    /// Optional secret for HMAC signing.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub secret: Option<String>,
199    /// Events to trigger the webhook.
200    pub events: Vec<String>,
201    /// Content type (default: application/json).
202    #[serde(default = "default_content_type")]
203    pub content_type: String,
204    /// Whether to verify SSL (default: true, so insecure_ssl is false).
205    #[serde(default)]
206    pub insecure_ssl: bool,
207}
208
209fn default_content_type() -> String {
210    "application/json".into()
211}
212
213/// Request to update a webhook.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct UpdateWebhookRequest {
216    /// New callback URL.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub url: Option<String>,
219    /// New secret.
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub secret: Option<String>,
222    /// New events.
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub events: Option<Vec<String>>,
225    /// New active status.
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub active: Option<bool>,
228}
229
230/// Webhook delivery payload.
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct WebhookPayload {
233    /// Event type.
234    pub event: WebhookEvent,
235    /// Delivery ID (unique per delivery attempt).
236    pub delivery_id: String,
237    /// Repository information.
238    pub repository: WebhookRepository,
239    /// Event-specific payload.
240    pub payload: serde_json::Value,
241    /// Timestamp of the event.
242    pub timestamp: u64,
243}
244
245/// Repository information for webhook payloads.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct WebhookRepository {
248    /// Repository key.
249    pub key: String,
250    /// Repository name.
251    pub name: String,
252    /// Repository owner.
253    pub owner: String,
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn test_webhook_event_parsing() {
262        assert_eq!(WebhookEvent::parse("push"), Some(WebhookEvent::Push));
263        assert_eq!(WebhookEvent::parse("PUSH"), Some(WebhookEvent::Push));
264        assert_eq!(
265            WebhookEvent::parse("pull_request"),
266            Some(WebhookEvent::PullRequest)
267        );
268        assert_eq!(WebhookEvent::parse("pr"), Some(WebhookEvent::PullRequest));
269        assert_eq!(WebhookEvent::parse("invalid"), None);
270    }
271
272    #[test]
273    fn test_webhook_creation() {
274        let mut events = HashSet::new();
275        events.insert(WebhookEvent::Push);
276        events.insert(WebhookEvent::PullRequest);
277
278        let webhook = Webhook::new(
279            1,
280            "acme/api".into(),
281            "https://example.com/hook".into(),
282            events,
283        );
284
285        assert_eq!(webhook.id, 1);
286        assert!(webhook.active);
287        assert!(webhook.should_fire(WebhookEvent::Push));
288        assert!(webhook.should_fire(WebhookEvent::PullRequest));
289        assert!(!webhook.should_fire(WebhookEvent::Issue));
290    }
291
292    #[test]
293    fn test_webhook_disable() {
294        let mut events = HashSet::new();
295        events.insert(WebhookEvent::Push);
296
297        let mut webhook = Webhook::new(
298            1,
299            "acme/api".into(),
300            "https://example.com/hook".into(),
301            events,
302        );
303
304        assert!(webhook.should_fire(WebhookEvent::Push));
305
306        webhook.disable();
307        assert!(!webhook.should_fire(WebhookEvent::Push));
308
309        webhook.enable();
310        assert!(webhook.should_fire(WebhookEvent::Push));
311    }
312
313    #[test]
314    fn test_webhook_events() {
315        let events = HashSet::new();
316        let mut webhook = Webhook::new(
317            1,
318            "acme/api".into(),
319            "https://example.com/hook".into(),
320            events,
321        );
322
323        assert!(!webhook.should_fire(WebhookEvent::Push));
324
325        webhook.add_event(WebhookEvent::Push);
326        assert!(webhook.should_fire(WebhookEvent::Push));
327
328        webhook.remove_event(WebhookEvent::Push);
329        assert!(!webhook.should_fire(WebhookEvent::Push));
330    }
331
332    #[test]
333    fn test_webhook_delivery_tracking() {
334        let events = HashSet::new();
335        let mut webhook = Webhook::new(
336            1,
337            "acme/api".into(),
338            "https://example.com/hook".into(),
339            events,
340        );
341
342        assert_eq!(webhook.delivery_count, 0);
343        assert_eq!(webhook.failure_count, 0);
344
345        webhook.record_success();
346        assert_eq!(webhook.delivery_count, 1);
347        assert_eq!(webhook.failure_count, 0);
348
349        webhook.record_failure();
350        assert_eq!(webhook.delivery_count, 2);
351        assert_eq!(webhook.failure_count, 1);
352    }
353}