Skip to main content

ironflow_engine/notify/
webhook.rs

1//! [`WebhookSubscriber`] -- POSTs events as JSON to a URL.
2
3use reqwest::Client;
4
5use super::retry::{RetryConfig, deliver_with_retry, is_success_2xx};
6use super::{Event, EventSubscriber, SubscriberFuture};
7
8/// Subscriber that POSTs the event as JSON to a webhook URL.
9///
10/// Retries failed deliveries with exponential backoff (up to 3 attempts,
11/// 5 s timeout per attempt). The HTTP client is created once and reused.
12///
13/// Event type filtering is handled by the
14/// [`EventPublisher`](super::EventPublisher) at subscription time -- this
15/// subscriber receives only events that already passed the filter.
16///
17/// # Examples
18///
19/// ```no_run
20/// use ironflow_engine::notify::{Event, EventPublisher, WebhookSubscriber};
21///
22/// let mut publisher = EventPublisher::new();
23/// publisher.subscribe(
24///     WebhookSubscriber::new("https://hooks.example.com/events"),
25///     &[Event::RUN_STATUS_CHANGED, Event::STEP_FAILED],
26/// );
27/// ```
28pub struct WebhookSubscriber {
29    url: String,
30    client: Client,
31    retry_config: RetryConfig,
32}
33
34impl WebhookSubscriber {
35    /// Create a new webhook subscriber targeting the given URL.
36    ///
37    /// Uses the default [`RetryConfig`] (3 retries, 5 s timeout, 500 ms
38    /// base backoff).
39    ///
40    /// # Panics
41    ///
42    /// Panics if the HTTP client cannot be built (TLS backend unavailable).
43    ///
44    /// # Examples
45    ///
46    /// ```
47    /// use ironflow_engine::notify::WebhookSubscriber;
48    ///
49    /// let subscriber = WebhookSubscriber::new("https://example.com/hook");
50    /// assert_eq!(subscriber.url(), "https://example.com/hook");
51    /// ```
52    pub fn new(url: &str) -> Self {
53        Self::with_retry_config(url, RetryConfig::default())
54    }
55
56    /// Create a webhook subscriber with a custom retry configuration.
57    ///
58    /// # Panics
59    ///
60    /// Panics if the HTTP client cannot be built (TLS backend unavailable).
61    ///
62    /// # Examples
63    ///
64    /// ```
65    /// use ironflow_engine::notify::{RetryConfig, WebhookSubscriber};
66    ///
67    /// let config = RetryConfig::new(
68    ///     5,
69    ///     std::time::Duration::from_secs(10),
70    ///     std::time::Duration::from_secs(1),
71    /// );
72    /// let subscriber = WebhookSubscriber::with_retry_config("https://example.com/hook", config);
73    /// ```
74    pub fn with_retry_config(url: &str, retry_config: RetryConfig) -> Self {
75        let client = retry_config.build_client();
76        Self {
77            url: url.to_string(),
78            client,
79            retry_config,
80        }
81    }
82
83    /// Returns the target URL.
84    pub fn url(&self) -> &str {
85        &self.url
86    }
87}
88
89impl EventSubscriber for WebhookSubscriber {
90    fn name(&self) -> &str {
91        "webhook"
92    }
93
94    fn handle<'a>(&'a self, event: &'a Event) -> SubscriberFuture<'a> {
95        Box::pin(async move {
96            deliver_with_retry(
97                &self.retry_config,
98                || self.client.post(&self.url).json(event),
99                is_success_2xx,
100                "webhook",
101                &self.url,
102            )
103            .await;
104        })
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn url_accessor() {
114        let sub = WebhookSubscriber::new("https://example.com/hook");
115        assert_eq!(sub.url(), "https://example.com/hook");
116    }
117
118    #[test]
119    fn name_is_webhook() {
120        let sub = WebhookSubscriber::new("https://example.com");
121        assert_eq!(sub.name(), "webhook");
122    }
123}