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}