ddns_a/webhook/
sender.rs

1//! Webhook sender trait and HTTP implementation.
2
3use crate::monitor::IpChange;
4use crate::time::{Sleeper, TokioSleeper};
5
6use super::{HttpClient, HttpError, HttpRequest, RetryPolicy, RetryableError, WebhookError};
7use handlebars::Handlebars;
8use serde::Serialize;
9
10/// Trait for sending IP change notifications to external services.
11///
12/// This abstraction allows for different notification mechanisms
13/// (HTTP webhooks, message queues, etc.) and enables testing with mocks.
14///
15/// # Implementation Notes
16///
17/// Implementations should handle retries internally if appropriate,
18/// returning [`WebhookError::MaxRetriesExceeded`] when all attempts fail.
19pub trait WebhookSender: Send + Sync {
20    /// Sends a notification about IP address changes.
21    ///
22    /// # Arguments
23    ///
24    /// * `changes` - The IP address changes to report
25    ///
26    /// # Errors
27    ///
28    /// Returns [`WebhookError`] if the notification fails after all retries.
29    fn send(
30        &self,
31        changes: &[IpChange],
32    ) -> impl std::future::Future<Output = Result<(), WebhookError>> + Send;
33}
34
35/// HTTP-based webhook sender with retry support.
36///
37/// Sends IP change notifications via HTTP requests, with configurable
38/// retry behavior using exponential backoff.
39///
40/// # Template Support
41///
42/// The body can be templated using Handlebars syntax. Available variables:
43/// - `changes`: Array of change objects, each with:
44///   - `adapter`: Adapter name
45///   - `address`: IP address string
46///   - `kind`: "added" or "removed"
47///   - `timestamp`: Unix timestamp (seconds)
48///
49/// # Type Parameters
50///
51/// - `H`: The HTTP client implementation
52/// - `S`: The sleeper implementation for retry delays (defaults to [`TokioSleeper`])
53///
54/// # Example
55///
56/// ```
57/// use ddns_a::webhook::{HttpWebhook, ReqwestClient, RetryPolicy};
58/// use url::Url;
59///
60/// let webhook = HttpWebhook::new(
61///     ReqwestClient::new(),
62///     Url::parse("https://api.example.com/ddns").unwrap(),
63/// );
64/// ```
65#[derive(Debug)]
66pub struct HttpWebhook<H, S = TokioSleeper> {
67    client: H,
68    sleeper: S,
69    url: url::Url,
70    method: http::Method,
71    headers: http::HeaderMap,
72    body_template: Option<String>,
73    retry_policy: RetryPolicy,
74}
75
76impl<H> HttpWebhook<H, TokioSleeper> {
77    /// Creates a new HTTP webhook with default settings.
78    ///
79    /// Uses POST method, no custom headers, no body template,
80    /// default retry policy, and [`TokioSleeper`] for delays.
81    #[must_use]
82    pub fn new(client: H, url: url::Url) -> Self {
83        Self {
84            client,
85            sleeper: TokioSleeper,
86            url,
87            method: http::Method::POST,
88            headers: http::HeaderMap::new(),
89            body_template: None,
90            retry_policy: RetryPolicy::default(),
91        }
92    }
93}
94
95impl<H, S> HttpWebhook<H, S> {
96    /// Sets a custom sleeper for retry delays.
97    ///
98    /// This is primarily useful for testing to avoid actual delays.
99    #[must_use]
100    pub fn with_sleeper<S2>(self, sleeper: S2) -> HttpWebhook<H, S2> {
101        HttpWebhook {
102            client: self.client,
103            sleeper,
104            url: self.url,
105            method: self.method,
106            headers: self.headers,
107            body_template: self.body_template,
108            retry_policy: self.retry_policy,
109        }
110    }
111
112    /// Sets the HTTP method.
113    #[must_use]
114    pub fn with_method(mut self, method: http::Method) -> Self {
115        self.method = method;
116        self
117    }
118
119    /// Sets the HTTP headers.
120    #[must_use]
121    pub fn with_headers(mut self, headers: http::HeaderMap) -> Self {
122        self.headers = headers;
123        self
124    }
125
126    /// Sets the body template (Handlebars syntax).
127    #[must_use]
128    pub fn with_body_template(mut self, template: impl Into<String>) -> Self {
129        self.body_template = Some(template.into());
130        self
131    }
132
133    /// Sets the retry policy.
134    #[must_use]
135    pub const fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
136        self.retry_policy = policy;
137        self
138    }
139
140    /// Returns the configured URL.
141    #[must_use]
142    pub const fn url(&self) -> &url::Url {
143        &self.url
144    }
145
146    /// Returns the configured HTTP method.
147    #[must_use]
148    pub const fn method(&self) -> &http::Method {
149        &self.method
150    }
151
152    /// Returns the configured retry policy.
153    #[must_use]
154    pub const fn retry_policy(&self) -> &RetryPolicy {
155        &self.retry_policy
156    }
157}
158
159/// Template data for rendering webhook body.
160#[derive(Serialize)]
161struct TemplateData<'a> {
162    changes: Vec<ChangeData<'a>>,
163}
164
165/// Individual change data for template rendering.
166#[derive(Serialize)]
167struct ChangeData<'a> {
168    adapter: &'a str,
169    address: String,
170    kind: &'static str,
171    timestamp: u64,
172}
173
174impl<'a> From<&'a IpChange> for ChangeData<'a> {
175    fn from(change: &'a IpChange) -> Self {
176        let kind = if change.is_added() {
177            "added"
178        } else {
179            "removed"
180        };
181        // Pre-epoch timestamps (shouldn't occur in practice for DDNS events) default to 0
182        let timestamp = change
183            .timestamp
184            .duration_since(std::time::UNIX_EPOCH)
185            .map_or(0, |d| d.as_secs());
186
187        Self {
188            adapter: &change.adapter,
189            address: change.address.to_string(),
190            kind,
191            timestamp,
192        }
193    }
194}
195
196impl<H: HttpClient, S: Sleeper> HttpWebhook<H, S> {
197    /// Renders the body template with the given changes.
198    fn render_body(&self, changes: &[IpChange]) -> Result<Option<Vec<u8>>, RetryableError> {
199        let Some(template) = &self.body_template else {
200            return Ok(None);
201        };
202
203        let data = TemplateData {
204            changes: changes.iter().map(ChangeData::from).collect(),
205        };
206
207        let handlebars = Handlebars::new();
208        let rendered = handlebars
209            .render_template(template, &data)
210            .map_err(|e| RetryableError::Template(e.to_string()))?;
211
212        Ok(Some(rendered.into_bytes()))
213    }
214
215    /// Builds the HTTP request for the given changes.
216    fn build_request(&self, changes: &[IpChange]) -> Result<HttpRequest, RetryableError> {
217        let mut request = HttpRequest::new(self.method.clone(), self.url.clone());
218
219        // Copy headers
220        for (name, value) in &self.headers {
221            request.headers.append(name, value.clone());
222        }
223
224        // Add body if template is configured
225        if let Some(body) = self.render_body(changes)? {
226            request.body = Some(body);
227        }
228
229        Ok(request)
230    }
231
232    /// Executes a single request attempt.
233    async fn execute_request(&self, request: &HttpRequest) -> Result<(), RetryableError> {
234        let response = self.client.request(request.clone()).await?;
235
236        if response.is_success() {
237            return Ok(());
238        }
239
240        Err(RetryableError::NonSuccessStatus {
241            status: response.status,
242            body: response.body_text().map(ToString::to_string),
243        })
244    }
245
246    /// Sends with retry logic.
247    async fn send_with_retry(&self, changes: &[IpChange]) -> Result<(), WebhookError> {
248        let request = self.build_request(changes)?;
249
250        let mut last_error: Option<RetryableError> = None;
251
252        for attempt in 1..=self.retry_policy.max_attempts {
253            match self.execute_request(&request).await {
254                Ok(()) => return Ok(()),
255                Err(e) => {
256                    // Non-retryable errors fail immediately
257                    if !e.is_retryable() {
258                        return Err(e.into());
259                    }
260
261                    last_error = Some(e);
262
263                    // Don't sleep after the last attempt
264                    if self.retry_policy.should_retry(attempt) {
265                        let delay = self.retry_policy.delay_for_retry(attempt - 1);
266                        self.sleeper.sleep(delay).await;
267                    }
268                }
269            }
270        }
271
272        Err(WebhookError::MaxRetriesExceeded {
273            attempts: self.retry_policy.max_attempts,
274            last_error: last_error.expect("max_attempts >= 1 ensures at least one attempt"),
275        })
276    }
277}
278
279impl<H: HttpClient, S: Sleeper> WebhookSender for HttpWebhook<H, S> {
280    async fn send(&self, changes: &[IpChange]) -> Result<(), WebhookError> {
281        self.send_with_retry(changes).await
282    }
283}
284
285/// Extension trait for checking if an error is retryable.
286///
287/// Determines whether an error represents a transient failure that
288/// warrants a retry attempt. Used by [`HttpWebhook`] to decide whether
289/// to continue retrying after a failure.
290pub trait IsRetryable {
291    /// Returns true if the error is potentially transient and should be retried.
292    fn is_retryable(&self) -> bool;
293}
294
295impl IsRetryable for HttpError {
296    fn is_retryable(&self) -> bool {
297        match self {
298            // Network errors are typically transient
299            Self::Connection(_) | Self::Timeout => true,
300            // URL errors are configuration issues, not transient
301            Self::InvalidUrl(_) => false,
302        }
303    }
304}
305
306impl IsRetryable for RetryableError {
307    fn is_retryable(&self) -> bool {
308        match self {
309            Self::Http(e) => e.is_retryable(),
310            // Server errors (5xx) are typically transient
311            // Rate limiting (429) is retryable
312            // Some 4xx (408 Request Timeout) are retryable
313            Self::NonSuccessStatus { status, .. } => {
314                status.is_server_error()
315                    || *status == http::StatusCode::TOO_MANY_REQUESTS
316                    || *status == http::StatusCode::REQUEST_TIMEOUT
317            }
318            // Template errors are not retryable (configuration issue)
319            Self::Template(_) => false,
320        }
321    }
322}