Skip to main content

shipper_core/
webhook.rs

1//! Webhook notifications backed by the `shipper-webhook` microcrate.
2//!
3//! This module keeps `shipper`'s public webhook API stable while delegating the
4//! HTTP transport behavior to the dedicated microcrate.
5
6use std::collections::BTreeMap;
7
8use anyhow::Result;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12/// Webhook configuration type provided by the `shipper-webhook` microcrate.
13pub type WebhookConfig = shipper_webhook::WebhookConfig;
14
15/// Webhook events published during a publish run.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "event", rename_all = "snake_case")]
18pub enum WebhookEvent {
19    /// Publish workflow started.
20    PublishStarted {
21        plan_id: String,
22        package_count: usize,
23        registry: String,
24    },
25    /// A crate publish succeeded.
26    PublishSucceeded {
27        plan_id: String,
28        package_name: String,
29        package_version: String,
30        duration_ms: u64,
31    },
32    /// A crate publish failed.
33    PublishFailed {
34        plan_id: String,
35        package_name: String,
36        package_version: String,
37        error_class: String,
38        message: String,
39    },
40    /// All publish operations completed.
41    PublishCompleted {
42        plan_id: String,
43        total_packages: usize,
44        success_count: usize,
45        failure_count: usize,
46        skipped_count: usize,
47        result: String,
48    },
49}
50
51/// Typed webhook payload payload.
52#[derive(Debug, Serialize, Deserialize)]
53pub struct WebhookPayload {
54    /// Timestamp of the event (ISO 8601).
55    pub timestamp: DateTime<Utc>,
56    /// Event details.
57    pub event: WebhookEvent,
58}
59
60/// Webhook client for dispatching publish events.
61#[derive(Clone)]
62pub struct WebhookClient {
63    config: WebhookConfig,
64}
65
66impl WebhookClient {
67    /// Create a webhook client with the given configuration.
68    pub fn new(config: &WebhookConfig) -> Result<Self> {
69        if config.url.trim().is_empty() {
70            anyhow::bail!("webhook URL is required when webhooks are enabled");
71        }
72        Ok(Self {
73            config: config.clone(),
74        })
75    }
76
77    /// Send a webhook event asynchronously.
78    pub fn send_event(&self, event: WebhookEvent) {
79        let payload = WebhookPayload {
80            timestamp: Utc::now(),
81            event,
82        };
83
84        let client = self.clone();
85        let _ = std::thread::spawn(move || {
86            if let Err(e) =
87                shipper_webhook::send_webhook(&client.config, &to_micro_payload(&payload))
88            {
89                eprintln!("[warn] webhook delivery failed (non-blocking): {:#}", e);
90            }
91        });
92    }
93}
94
95/// Send a webhook event if webhooks are configured.
96pub fn maybe_send_event(config: &WebhookConfig, event: WebhookEvent) {
97    if config.url.trim().is_empty() {
98        return;
99    }
100
101    let client = match WebhookClient::new(config) {
102        Ok(client) => client,
103        Err(e) => {
104            eprintln!("[warn] failed to build webhook client: {:#}", e);
105            return;
106        }
107    };
108
109    let _ = std::thread::spawn(move || {
110        let payload = WebhookPayload {
111            timestamp: Utc::now(),
112            event,
113        };
114
115        if let Err(e) = shipper_webhook::send_webhook(&client.config, &to_micro_payload(&payload)) {
116            eprintln!("[warn] webhook delivery failed (non-blocking): {:#}", e);
117        }
118    });
119}
120
121pub(crate) fn to_micro_payload(payload: &WebhookPayload) -> shipper_webhook::WebhookPayload {
122    let (message, title, success, package, version, registry, error, extra) = match &payload.event {
123        WebhookEvent::PublishStarted {
124            plan_id,
125            package_count,
126            registry,
127        } => (
128            format!("publish started for plan {plan_id} ({package_count} packages) on {registry}"),
129            Some("Publish Started".to_string()),
130            true,
131            None,
132            None,
133            Some(registry.clone()),
134            None,
135            serde_json::json!({
136                "event": "publish_started",
137                "plan_id": plan_id,
138                "package_count": package_count,
139                "registry": registry,
140            }),
141        ),
142        WebhookEvent::PublishSucceeded {
143            plan_id,
144            package_name,
145            package_version,
146            duration_ms,
147            ..
148        } => (
149            format!(
150                "publish succeeded for package {package_name} version {package_version} in {duration_ms}ms (plan {plan_id})"
151            ),
152            Some("Publish Succeeded".to_string()),
153            true,
154            Some(package_name.clone()),
155            Some(package_version.clone()),
156            None,
157            None,
158            serde_json::json!({
159                "event": "publish_succeeded",
160                "plan_id": plan_id,
161                "duration_ms": duration_ms,
162            }),
163        ),
164        WebhookEvent::PublishFailed {
165            plan_id,
166            package_name,
167            package_version,
168            error_class,
169            message,
170            ..
171        } => (
172            format!(
173                "publish failed for package {package_name} version {package_version} ({error_class}): {message}"
174            ),
175            Some("Publish Failed".to_string()),
176            false,
177            Some(package_name.clone()),
178            Some(package_version.clone()),
179            None,
180            Some(message.clone()),
181            serde_json::json!({
182                "event": "publish_failed",
183                "plan_id": plan_id,
184                "error_class": error_class,
185            }),
186        ),
187        WebhookEvent::PublishCompleted {
188            plan_id,
189            total_packages,
190            success_count,
191            failure_count,
192            skipped_count,
193            result,
194        } => (
195            format!(
196                "publish completed: {success_count}/{total_packages} succeeded, {failure_count} failed, {skipped_count} skipped (plan {plan_id}, result: {result})"
197            ),
198            Some("Publish Completed".to_string()),
199            *failure_count == 0,
200            None,
201            None,
202            None,
203            None,
204            serde_json::json!({
205                "event": "publish_completed",
206                "plan_id": plan_id,
207                "total_packages": total_packages,
208                "success_count": success_count,
209                "failure_count": failure_count,
210            "skipped_count": skipped_count,
211            "result": result,
212            }),
213        ),
214    };
215
216    let mut extra_fields = BTreeMap::new();
217    extra_fields.insert("legacy".to_string(), extra);
218
219    shipper_webhook::WebhookPayload {
220        message,
221        title,
222        success,
223        package,
224        version,
225        registry,
226        error,
227        extra: extra_fields,
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use shipper_webhook::WebhookType;
235
236    fn sample_config(url: &str) -> WebhookConfig {
237        WebhookConfig {
238            url: url.to_string(),
239            webhook_type: WebhookType::Generic,
240            secret: None,
241            timeout_secs: 30,
242        }
243    }
244
245    fn started_event() -> WebhookEvent {
246        WebhookEvent::PublishStarted {
247            plan_id: "plan-1".to_string(),
248            package_count: 3,
249            registry: "crates-io".to_string(),
250        }
251    }
252
253    fn succeeded_event() -> WebhookEvent {
254        WebhookEvent::PublishSucceeded {
255            plan_id: "plan-1".to_string(),
256            package_name: "pkg-a".to_string(),
257            package_version: "1.2.3".to_string(),
258            duration_ms: 1500,
259        }
260    }
261
262    fn failed_event() -> WebhookEvent {
263        WebhookEvent::PublishFailed {
264            plan_id: "plan-1".to_string(),
265            package_name: "pkg-b".to_string(),
266            package_version: "0.1.0".to_string(),
267            error_class: "Permanent".to_string(),
268            message: "auth failed".to_string(),
269        }
270    }
271
272    fn completed_event(failure_count: usize) -> WebhookEvent {
273        WebhookEvent::PublishCompleted {
274            plan_id: "plan-1".to_string(),
275            total_packages: 4,
276            success_count: 4 - failure_count,
277            failure_count,
278            skipped_count: 0,
279            result: if failure_count == 0 {
280                "success".to_string()
281            } else {
282                "partial".to_string()
283            },
284        }
285    }
286
287    fn sample_payload(event: WebhookEvent) -> WebhookPayload {
288        WebhookPayload {
289            timestamp: Utc::now(),
290            event,
291        }
292    }
293
294    #[test]
295    fn webhook_client_new_rejects_empty_url() {
296        let cfg = sample_config("");
297        let err = match WebhookClient::new(&cfg) {
298            Err(e) => e,
299            Ok(_) => panic!("empty url must be rejected"),
300        };
301        let msg = format!("{err:#}");
302        assert!(msg.contains("required"), "unexpected error: {msg}");
303    }
304
305    #[test]
306    fn webhook_client_new_rejects_whitespace_only_url() {
307        let cfg = sample_config("   ");
308        let err = match WebhookClient::new(&cfg) {
309            Err(e) => e,
310            Ok(_) => panic!("whitespace url must be rejected"),
311        };
312        assert!(format!("{err:#}").contains("required"));
313    }
314
315    #[test]
316    fn webhook_client_new_accepts_valid_url() {
317        let cfg = sample_config("https://example.invalid/hook");
318        WebhookClient::new(&cfg).expect("valid url must be accepted");
319    }
320
321    #[test]
322    fn webhook_client_is_clone() {
323        let cfg = sample_config("https://example.invalid/hook");
324        let client = WebhookClient::new(&cfg).expect("ok");
325        let _ = client.clone();
326    }
327
328    #[test]
329    fn maybe_send_event_returns_early_on_empty_url() {
330        let cfg = sample_config("");
331        maybe_send_event(&cfg, started_event());
332    }
333
334    #[test]
335    fn maybe_send_event_returns_early_on_whitespace_url() {
336        let cfg = sample_config(" \t ");
337        maybe_send_event(&cfg, succeeded_event());
338    }
339
340    #[test]
341    fn to_micro_payload_publish_started_fields() {
342        let micro = to_micro_payload(&sample_payload(started_event()));
343        assert!(micro.success);
344        assert_eq!(micro.title.as_deref(), Some("Publish Started"));
345        assert!(micro.package.is_none());
346        assert!(micro.version.is_none());
347        assert_eq!(micro.registry.as_deref(), Some("crates-io"));
348        assert!(micro.error.is_none());
349        assert!(micro.message.contains("plan-1"));
350        assert!(micro.message.contains("3 packages"));
351        assert!(micro.message.contains("crates-io"));
352        let legacy = micro.extra.get("legacy").expect("legacy");
353        assert_eq!(
354            legacy.get("event").and_then(|v| v.as_str()),
355            Some("publish_started")
356        );
357        assert_eq!(
358            legacy.get("plan_id").and_then(|v| v.as_str()),
359            Some("plan-1")
360        );
361        assert_eq!(
362            legacy.get("package_count").and_then(|v| v.as_u64()),
363            Some(3)
364        );
365        assert_eq!(
366            legacy.get("registry").and_then(|v| v.as_str()),
367            Some("crates-io")
368        );
369    }
370
371    #[test]
372    fn to_micro_payload_publish_succeeded_fields() {
373        let micro = to_micro_payload(&sample_payload(succeeded_event()));
374        assert!(micro.success);
375        assert_eq!(micro.title.as_deref(), Some("Publish Succeeded"));
376        assert_eq!(micro.package.as_deref(), Some("pkg-a"));
377        assert_eq!(micro.version.as_deref(), Some("1.2.3"));
378        assert!(micro.registry.is_none());
379        assert!(micro.error.is_none());
380        assert!(micro.message.contains("pkg-a"));
381        assert!(micro.message.contains("1.2.3"));
382        assert!(micro.message.contains("1500ms"));
383        let legacy = micro.extra.get("legacy").expect("legacy");
384        assert_eq!(
385            legacy.get("event").and_then(|v| v.as_str()),
386            Some("publish_succeeded")
387        );
388        assert_eq!(
389            legacy.get("duration_ms").and_then(|v| v.as_u64()),
390            Some(1500)
391        );
392    }
393
394    #[test]
395    fn to_micro_payload_publish_failed_fields() {
396        let micro = to_micro_payload(&sample_payload(failed_event()));
397        assert!(!micro.success);
398        assert_eq!(micro.title.as_deref(), Some("Publish Failed"));
399        assert_eq!(micro.package.as_deref(), Some("pkg-b"));
400        assert_eq!(micro.version.as_deref(), Some("0.1.0"));
401        assert!(micro.registry.is_none());
402        assert_eq!(micro.error.as_deref(), Some("auth failed"));
403        assert!(micro.message.contains("Permanent"));
404        assert!(micro.message.contains("auth failed"));
405        let legacy = micro.extra.get("legacy").expect("legacy");
406        assert_eq!(
407            legacy.get("event").and_then(|v| v.as_str()),
408            Some("publish_failed")
409        );
410        assert_eq!(
411            legacy.get("error_class").and_then(|v| v.as_str()),
412            Some("Permanent")
413        );
414    }
415
416    #[test]
417    fn to_micro_payload_publish_completed_success_when_no_failures() {
418        let micro = to_micro_payload(&sample_payload(completed_event(0)));
419        assert!(micro.success, "no failures -> success=true");
420        assert_eq!(micro.title.as_deref(), Some("Publish Completed"));
421        assert!(micro.package.is_none());
422        assert!(micro.version.is_none());
423        assert!(micro.registry.is_none());
424        assert!(micro.error.is_none());
425        assert!(micro.message.contains("4/4 succeeded"));
426        let legacy = micro.extra.get("legacy").expect("legacy");
427        assert_eq!(
428            legacy.get("total_packages").and_then(|v| v.as_u64()),
429            Some(4)
430        );
431        assert_eq!(
432            legacy.get("success_count").and_then(|v| v.as_u64()),
433            Some(4)
434        );
435        assert_eq!(
436            legacy.get("failure_count").and_then(|v| v.as_u64()),
437            Some(0)
438        );
439        assert_eq!(
440            legacy.get("result").and_then(|v| v.as_str()),
441            Some("success")
442        );
443    }
444
445    #[test]
446    fn to_micro_payload_publish_completed_failure_when_any_failed() {
447        let micro = to_micro_payload(&sample_payload(completed_event(2)));
448        assert!(!micro.success, "any failures -> success=false");
449        assert!(micro.message.contains("2/4 succeeded"));
450        assert!(micro.message.contains("2 failed"));
451        let legacy = micro.extra.get("legacy").expect("legacy");
452        assert_eq!(
453            legacy.get("failure_count").and_then(|v| v.as_u64()),
454            Some(2)
455        );
456        assert_eq!(
457            legacy.get("result").and_then(|v| v.as_str()),
458            Some("partial")
459        );
460    }
461
462    #[test]
463    fn to_micro_payload_includes_legacy_key() {
464        for event in [
465            started_event(),
466            succeeded_event(),
467            failed_event(),
468            completed_event(1),
469        ] {
470            let micro = to_micro_payload(&sample_payload(event));
471            assert!(
472                micro.extra.contains_key("legacy"),
473                "to_micro_payload must always include `legacy` key"
474            );
475        }
476    }
477
478    #[test]
479    fn webhook_event_serde_roundtrip_for_all_variants() {
480        for event in [
481            started_event(),
482            succeeded_event(),
483            failed_event(),
484            completed_event(1),
485        ] {
486            let json = serde_json::to_string(&event).expect("serialize");
487            let back: WebhookEvent = serde_json::from_str(&json).expect("deserialize");
488            // Reuse serialization for structural equality.
489            assert_eq!(
490                json,
491                serde_json::to_string(&back).expect("re-serialize"),
492                "roundtrip must preserve event payload"
493            );
494        }
495    }
496
497    #[test]
498    fn webhook_event_publish_started_tag_in_json() {
499        let json = serde_json::to_string(&started_event()).expect("serialize");
500        assert!(
501            json.contains("\"event\":\"publish_started\""),
502            "expected tagged variant, got {json}"
503        );
504    }
505
506    #[test]
507    fn webhook_event_publish_failed_tag_in_json() {
508        let json = serde_json::to_string(&failed_event()).expect("serialize");
509        assert!(
510            json.contains("\"event\":\"publish_failed\""),
511            "expected tagged variant, got {json}"
512        );
513    }
514
515    #[test]
516    fn webhook_payload_serializes_with_event_and_timestamp() {
517        let payload = sample_payload(started_event());
518        let json = serde_json::to_string(&payload).expect("serialize");
519        assert!(json.contains("\"timestamp\""));
520        assert!(json.contains("\"event\":\"publish_started\""));
521    }
522}