Skip to main content

hooksmith_core/
client.rs

1use crate::retry::RetryPolicy;
2use reqwest::Client;
3use serde::Serialize;
4use std::time::Duration;
5
6/// A thin wrapper around [`reqwest::Client`] shared by all hooksmith service crates.
7///
8/// Service crates (e.g. `discord_hook`, `slack_hook`) hold one of these,
9/// configure it at construction time, and call [`HttpClient::post_json`] to
10/// fire requests.
11///
12/// **TLS configuration** is the responsibility of the service crate — build a
13/// [`reqwest::Client`] with your chosen TLS backend and pass it in via
14/// [`HttpClient::with_reqwest`].
15#[derive(Clone)]
16pub struct HttpClient {
17    inner: Client,
18}
19
20impl HttpClient {
21    /// Create a client backed by a freshly-constructed [`reqwest::Client`].
22    ///
23    /// A 30-second request timeout is applied by default so that a slow or
24    /// unresponsive endpoint can never hang your application indefinitely.
25    /// Override this by building your own [`reqwest::Client`] and passing it
26    /// to [`HttpClient::with_reqwest`].
27    pub fn new() -> Self {
28        let inner = Client::builder()
29            .timeout(Duration::from_secs(30))
30            .build()
31            .expect("failed to initialise reqwest client — TLS backend unavailable");
32        Self { inner }
33    }
34
35    /// Wrap an existing [`reqwest::Client`].
36    ///
37    /// Use this to share a connection pool or inject custom configuration
38    /// (timeouts, proxies, etc.) across your application.
39    pub fn with_reqwest(client: Client) -> Self {
40        Self { inner: client }
41    }
42
43    /// POST `body` serialized as JSON to `url` and return the raw response.
44    ///
45    /// When the **`tracing`** feature is enabled this method emits an
46    /// `info_span` named `hooksmith.post_json` capturing the request URL,
47    /// HTTP status, and wall-clock latency.
48    pub async fn post_json(
49        &self,
50        url: &str,
51        body: &impl Serialize,
52    ) -> Result<reqwest::Response, reqwest::Error> {
53        #[cfg(not(feature = "tracing"))]
54        {
55            return self.inner.post(url).json(body).send().await;
56        }
57
58        #[cfg(feature = "tracing")]
59        {
60            use tracing::Instrument;
61            let span = tracing::info_span!("hooksmith.post_json", url = %url);
62            let start = std::time::Instant::now();
63            let result = self
64                .inner
65                .post(url)
66                .json(body)
67                .send()
68                .instrument(span.clone())
69                .await;
70            let latency_ms = start.elapsed().as_millis();
71            let _enter = span.enter();
72            match &result {
73                Ok(resp) => tracing::info!(status = resp.status().as_u16(), latency_ms),
74                Err(err) => tracing::error!(error = %err, latency_ms),
75            }
76            result
77        }
78    }
79
80    /// POST `body` serialized as JSON to `url`, retrying on failure according
81    /// to the supplied [`RetryPolicy`].
82    ///
83    /// Each retry is separated by an exponentially increasing delay
84    /// (`base_delay × 2ⁿ`).  When `policy.jitter` is `true` a random
85    /// fraction of the current step is added to the delay.
86    ///
87    /// Returns the first successful [`reqwest::Response`], or the error from
88    /// the final attempt if all attempts are exhausted.
89    ///
90    /// # Example
91    ///
92    /// ```rust,ignore
93    /// use hooksmith_core::RetryPolicy;
94    ///
95    /// let policy = RetryPolicy { max_attempts: 4, ..Default::default() };
96    /// let resp = client.post_json_with_retry(url, &payload, &policy).await?;
97    /// ```
98    pub async fn post_json_with_retry(
99        &self,
100        url: &str,
101        body: &impl Serialize,
102        policy: &RetryPolicy,
103    ) -> Result<reqwest::Response, reqwest::Error> {
104        let max = policy.max_attempts.max(1);
105        let mut last_err: Option<reqwest::Error> = None;
106
107        for attempt in 0..max {
108            match self.post_json(url, body).await {
109                Ok(resp) => return Ok(resp),
110                Err(err) => {
111                    let is_last = attempt + 1 >= max;
112                    if !is_last {
113                        let factor = 1u32 << attempt; // 2^attempt
114                        let base = policy.base_delay * factor;
115                        let delay = if policy.jitter {
116                            // Use subsecond nanos from the system clock as a
117                            // cheap jitter source — no external crate needed.
118                            let nanos = std::time::SystemTime::now()
119                                .duration_since(std::time::UNIX_EPOCH)
120                                .unwrap_or_default()
121                                .subsec_nanos();
122                            let jitter = (nanos % 1_000) as f64 / 1_000.0;
123                            base + Duration::from_secs_f64(base.as_secs_f64() * jitter)
124                        } else {
125                            base
126                        };
127                        tokio::time::sleep(delay).await;
128                    }
129                    last_err = Some(err);
130                }
131            }
132        }
133
134        Err(last_err.expect("max_attempts is at least 1"))
135    }
136
137    /// Access the underlying [`reqwest::Client`] for advanced use-cases.
138    pub fn inner(&self) -> &Client {
139        &self.inner
140    }
141}
142
143impl Default for HttpClient {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149// ---------------------------------------------------------------------------
150// Domain validation utility
151// ---------------------------------------------------------------------------
152
153/// Return `true` if `url` uses HTTPS and its host exactly matches one of the
154/// `allowed` domains.
155///
156/// This is a convenience helper for service-crate constructors that want to
157/// enforce a fixed set of known-good endpoints. Matching is against the bare
158/// hostname only — port and path are excluded from the comparison.
159///
160/// # Example
161///
162/// ```rust
163/// use hooksmith_core::is_allowed_domain;
164///
165/// assert!(is_allowed_domain("https://hooks.slack.com/services/T/B/X", &["hooks.slack.com"]));
166/// assert!(!is_allowed_domain("https://evil.com/hooks.slack.com", &["hooks.slack.com"]));
167/// assert!(!is_allowed_domain("http://hooks.slack.com/services/T/B/X", &["hooks.slack.com"]));
168/// ```
169pub fn is_allowed_domain(url: &str, allowed: &[&str]) -> bool {
170    let Some(rest) = url.strip_prefix("https://") else {
171        return false;
172    };
173    // host[:port]/path — take only the host[:port] segment, then strip port.
174    let host_port = rest.split('/').next().unwrap_or("");
175    let host = host_port.split(':').next().unwrap_or("");
176    allowed.contains(&host)
177}
178
179// ---------------------------------------------------------------------------
180// HttpClientBuilder
181// ---------------------------------------------------------------------------
182
183/// Builder for [`HttpClient`] with configurable timeout settings.
184///
185/// # Example
186///
187/// ```rust
188/// use hooksmith_core::HttpClientBuilder;
189/// use std::time::Duration;
190///
191/// let client = HttpClientBuilder::new()
192///     .connect_timeout(Duration::from_secs(5))
193///     .request_timeout(Duration::from_secs(15))
194///     .build()
195///     .expect("failed to build client");
196/// ```
197#[derive(Default)]
198pub struct HttpClientBuilder {
199    connect_timeout: Option<Duration>,
200    request_timeout: Option<Duration>,
201}
202
203impl HttpClientBuilder {
204    pub fn new() -> Self {
205        Self::default()
206    }
207
208    /// Set the maximum time allowed to establish a TCP connection.
209    pub fn connect_timeout(mut self, timeout: Duration) -> Self {
210        self.connect_timeout = Some(timeout);
211        self
212    }
213
214    /// Set the maximum time allowed for a complete request/response cycle.
215    ///
216    /// Defaults to 30 seconds when not set.
217    pub fn request_timeout(mut self, timeout: Duration) -> Self {
218        self.request_timeout = Some(timeout);
219        self
220    }
221
222    /// Build the [`HttpClient`].
223    ///
224    /// # Errors
225    ///
226    /// Returns an error if the underlying [`reqwest::Client`] cannot be
227    /// constructed (e.g. TLS backend unavailable).
228    pub fn build(self) -> Result<HttpClient, reqwest::Error> {
229        let mut builder =
230            Client::builder().timeout(self.request_timeout.unwrap_or(Duration::from_secs(30)));
231        if let Some(t) = self.connect_timeout {
232            builder = builder.connect_timeout(t);
233        }
234        Ok(HttpClient {
235            inner: builder.build()?,
236        })
237    }
238}