Skip to main content

clicksend_rs/
client.rs

1//! Async API client.
2//!
3//! Construct with [`Client::new`] for defaults, or [`Client::builder`] for
4//! tunables (timeout, retry, custom HTTP client). Then dispatch through
5//! one of the namespace handles ([`Client::sms`], [`Client::account`], …).
6
7use std::{fmt, sync::Arc, time::Duration};
8
9use reqwest::{Client as HttpClient, Method, RequestBuilder};
10use serde::de::DeserializeOwned;
11use serde::Serialize;
12
13use crate::{
14    errors::ClickSendError,
15    types::{
16        AccountData, ApiEnvelope, Email, MmsMessageCollection, Paginated, SmsHistoryItem,
17        SmsInboundItem, SmsMessageCollection, SmsReceiptItem, SmsSendData, VoiceMessageCollection,
18    },
19};
20
21const DEFAULT_BASE_URL: &str = "https://rest.clicksend.com/v3";
22const DEFAULT_USER_AGENT: &str = concat!("clicksend-rs/", env!("CARGO_PKG_VERSION"));
23
24/// Retry policy for transient errors (429, 5xx, request timeouts).
25///
26/// Default is **no retry** (`max_attempts = 1`). Build with
27/// [`RetryConfig::enabled`] to opt in.
28///
29/// Honors `Retry-After` on 429s when present; otherwise uses exponential
30/// backoff capped at `max_backoff`.
31#[derive(Debug, Clone, Copy)]
32pub struct RetryConfig {
33    /// Total attempts (1 = no retry, 3 = original + 2 retries).
34    pub max_attempts: u32,
35    /// First retry delay.
36    pub initial_backoff: Duration,
37    /// Multiplier between retries.
38    pub backoff_multiplier: f64,
39    /// Cap on backoff.
40    pub max_backoff: Duration,
41}
42
43impl Default for RetryConfig {
44    fn default() -> Self {
45        Self {
46            max_attempts: 1,
47            initial_backoff: Duration::from_millis(500),
48            backoff_multiplier: 2.0,
49            max_backoff: Duration::from_secs(30),
50        }
51    }
52}
53
54impl RetryConfig {
55    /// Quick-config with N attempts and otherwise default backoff.
56    pub fn enabled(max_attempts: u32) -> Self {
57        Self {
58            max_attempts,
59            ..Default::default()
60        }
61    }
62}
63
64pub(crate) struct Inner {
65    pub username: String,
66    pub api_key: String,
67    pub base_url: String,
68    pub http: HttpClient,
69    pub retry: RetryConfig,
70}
71
72/// Async ClickSend client. Cheap to clone (`Arc` inside).
73///
74/// `Debug` redacts the api key.
75#[derive(Clone)]
76pub struct Client {
77    pub(crate) inner: Arc<Inner>,
78}
79
80/// Custom Debug — never leak the api_key.
81impl fmt::Debug for Client {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        f.debug_struct("Client")
84            .field("username", &self.inner.username)
85            .field("api_key", &"<redacted>")
86            .field("base_url", &self.inner.base_url)
87            .field("retry", &self.inner.retry)
88            .finish()
89    }
90}
91
92impl Client {
93    /// New client with default settings (30s timeout, 10s connect, no retry,
94    /// `clicksend-rs/<version>` UA). Use [`Client::builder`] for more control.
95    ///
96    /// # Panics
97    /// If `username` or `api_key` is empty. For non-panicking construction,
98    /// use [`ClientBuilder::build`].
99    pub fn new(username: impl Into<String>, api_key: impl Into<String>) -> Self {
100        ClientBuilder::new(username, api_key).build().expect("default client builds")
101    }
102
103    /// Configurable builder — set timeout, retry, custom HTTP client, base URL.
104    pub fn builder(
105        username: impl Into<String>,
106        api_key: impl Into<String>,
107    ) -> ClientBuilder {
108        ClientBuilder::new(username, api_key)
109    }
110
111    // ───────── namespace handles ─────────
112
113    /// `/account` endpoints.
114    pub fn account(&self) -> AccountApi<'_> {
115        AccountApi { c: self }
116    }
117    /// `/sms/*` endpoints.
118    pub fn sms(&self) -> SmsApi<'_> {
119        SmsApi { c: self }
120    }
121    /// `/mms/*` endpoints.
122    pub fn mms(&self) -> MmsApi<'_> {
123        MmsApi { c: self }
124    }
125    /// `/voice/*` endpoints.
126    pub fn voice(&self) -> VoiceApi<'_> {
127        VoiceApi { c: self }
128    }
129    /// `/email/*` endpoints.
130    pub fn email(&self) -> EmailApi<'_> {
131        EmailApi { c: self }
132    }
133
134    // ───────── escape hatch ─────────
135
136    /// Pre-authenticated [`RequestBuilder`] for any path, for endpoints not
137    /// yet wrapped by this crate. `path` is appended to the base URL.
138    ///
139    /// ```no_run
140    /// # async fn run(client: clicksend_rs::Client) -> Result<(), Box<dyn std::error::Error>> {
141    /// let resp: serde_json::Value = client
142    ///     .raw_request(reqwest::Method::GET, "/contacts/lists")
143    ///     .send().await?
144    ///     .json().await?;
145    /// # Ok(()) }
146    /// ```
147    pub fn raw_request(&self, method: Method, path: &str) -> RequestBuilder {
148        self.inner
149            .http
150            .request(method, format!("{}{}", self.inner.base_url, path))
151            .basic_auth(&self.inner.username, Some(&self.inner.api_key))
152    }
153
154    // ───────── private plumbing ─────────
155
156    fn req(&self, method: Method, path: &str) -> RequestBuilder {
157        self.raw_request(method, path)
158    }
159
160    pub(crate) async fn execute<T: DeserializeOwned>(
161        &self,
162        method: Method,
163        path: &str,
164        query: Option<&[(&str, &str)]>,
165        body: Option<&dyn ErasedSerialize>,
166    ) -> Result<ApiEnvelope<T>, ClickSendError> {
167        let span = tracing::debug_span!("clicksend", %method, path);
168        let _g = span.enter();
169
170        let mut attempt = 0u32;
171        let mut backoff = self.inner.retry.initial_backoff;
172
173        loop {
174            attempt += 1;
175
176            let mut rb = self.req(method.clone(), path);
177            if let Some(q) = query {
178                rb = rb.query(q);
179            }
180            if let Some(b) = body {
181                rb = rb.json(&b.as_value()?);
182            }
183
184            let resp = rb.send().await;
185            let resp = match resp {
186                Ok(r) => r,
187                Err(e) => {
188                    if attempt < self.inner.retry.max_attempts && e.is_timeout() {
189                        tracing::warn!(?e, attempt, "transient send error, retrying");
190                        sleep(backoff).await;
191                        backoff = next_backoff(backoff, &self.inner.retry);
192                        continue;
193                    }
194                    return Err(ClickSendError::Http(e));
195                }
196            };
197
198            let status = resp.status();
199
200            if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
201                if attempt < self.inner.retry.max_attempts {
202                    let retry_after = resp
203                        .headers()
204                        .get("retry-after")
205                        .and_then(|v| v.to_str().ok())
206                        .and_then(|v| v.parse::<u64>().ok());
207                    let wait = retry_after
208                        .map(Duration::from_secs)
209                        .unwrap_or(backoff);
210                    tracing::warn!(attempt, ?wait, "429, retrying");
211                    sleep(wait).await;
212                    backoff = next_backoff(backoff, &self.inner.retry);
213                    continue;
214                }
215                let retry_after = resp
216                    .headers()
217                    .get("retry-after")
218                    .and_then(|v| v.to_str().ok())
219                    .and_then(|v| v.parse::<u64>().ok());
220                return Err(ClickSendError::RateLimited {
221                    retry_after_secs: retry_after,
222                });
223            }
224
225            if status.is_server_error() && attempt < self.inner.retry.max_attempts {
226                tracing::warn!(?status, attempt, "5xx, retrying");
227                sleep(backoff).await;
228                backoff = next_backoff(backoff, &self.inner.retry);
229                continue;
230            }
231
232            let text = resp.text().await.map_err(ClickSendError::Http)?;
233            return decode_envelope(status, &text);
234        }
235    }
236}
237
238pub(crate) fn decode_envelope<T: DeserializeOwned>(
239    status: reqwest::StatusCode,
240    text: &str,
241) -> Result<ApiEnvelope<T>, ClickSendError> {
242    if status == reqwest::StatusCode::UNAUTHORIZED {
243        return Err(ClickSendError::Unauthorized);
244    }
245    if status == reqwest::StatusCode::NOT_FOUND {
246        return Err(ClickSendError::NotFound(text.to_string()));
247    }
248    if status.is_client_error() || status.is_server_error() {
249        return Err(ClickSendError::Http4xx5xx {
250            code: status.as_u16(),
251            message: text.to_string(),
252        });
253    }
254
255    let env: ApiEnvelope<T> = serde_json::from_str(text).map_err(|e| ClickSendError::Decode {
256        message: e.to_string(),
257        body: text.to_string(),
258    })?;
259
260    if env.response_code != "SUCCESS" {
261        return Err(ClickSendError::Api {
262            code: env.response_code.clone(),
263            message: env.response_msg.clone().unwrap_or_default(),
264            body: text.to_string(),
265        });
266    }
267
268    Ok(env)
269}
270
271fn next_backoff(current: Duration, cfg: &RetryConfig) -> Duration {
272    let next = current.mul_f64(cfg.backoff_multiplier);
273    if next > cfg.max_backoff {
274        cfg.max_backoff
275    } else {
276        next
277    }
278}
279
280async fn sleep(d: Duration) {
281    tokio::time::sleep(d).await;
282}
283
284// ───────── erased serializer (so execute() isn't generic over body type) ─────────
285
286pub(crate) trait ErasedSerialize: Send + Sync {
287    fn as_value(&self) -> Result<serde_json::Value, ClickSendError>;
288}
289
290impl<T: Serialize + Send + Sync> ErasedSerialize for T {
291    fn as_value(&self) -> Result<serde_json::Value, ClickSendError> {
292        serde_json::to_value(self).map_err(|e| ClickSendError::Decode {
293            message: e.to_string(),
294            body: String::new(),
295        })
296    }
297}
298
299// ───────── builder ─────────
300
301/// Builder for [`Client`]. Tunes timeouts, retry, UA, base URL, custom HTTP.
302pub struct ClientBuilder {
303    username: String,
304    api_key: String,
305    base_url: String,
306    timeout: Duration,
307    connect_timeout: Duration,
308    user_agent: String,
309    retry: RetryConfig,
310    http: Option<HttpClient>,
311}
312
313impl ClientBuilder {
314    /// Start a builder with credentials. Call [`Self::build`] to finalize.
315    pub fn new(username: impl Into<String>, api_key: impl Into<String>) -> Self {
316        Self {
317            username: username.into(),
318            api_key: api_key.into(),
319            base_url: DEFAULT_BASE_URL.to_string(),
320            timeout: Duration::from_secs(30),
321            connect_timeout: Duration::from_secs(10),
322            user_agent: DEFAULT_USER_AGENT.to_string(),
323            retry: RetryConfig::default(),
324            http: None,
325        }
326    }
327
328    /// Override the API base URL (useful for mock servers).
329    pub fn base_url(mut self, v: impl Into<String>) -> Self {
330        self.base_url = v.into();
331        self
332    }
333    /// Total request timeout (default 30s).
334    pub fn timeout(mut self, v: Duration) -> Self {
335        self.timeout = v;
336        self
337    }
338    /// TCP connect timeout (default 10s).
339    pub fn connect_timeout(mut self, v: Duration) -> Self {
340        self.connect_timeout = v;
341        self
342    }
343    /// Override the User-Agent header (default `clicksend-rs/<version>`).
344    pub fn user_agent(mut self, v: impl Into<String>) -> Self {
345        self.user_agent = v.into();
346        self
347    }
348    /// Configure retry behavior. Default is no retry.
349    pub fn retry(mut self, v: RetryConfig) -> Self {
350        self.retry = v;
351        self
352    }
353    /// Plug your own `reqwest::Client` — timeout/connect/UA on the builder are
354    /// then ignored.
355    pub fn http_client(mut self, http: HttpClient) -> Self {
356        self.http = Some(http);
357        self
358    }
359
360    /// Finalize. Errors with [`ClickSendError::InvalidConfig`] on empty creds.
361    pub fn build(self) -> Result<Client, ClickSendError> {
362        if self.username.is_empty() {
363            return Err(ClickSendError::InvalidConfig("username is empty".into()));
364        }
365        if self.api_key.is_empty() {
366            return Err(ClickSendError::InvalidConfig("api_key is empty".into()));
367        }
368
369        let http = match self.http {
370            Some(h) => h,
371            None => HttpClient::builder()
372                .timeout(self.timeout)
373                .connect_timeout(self.connect_timeout)
374                .user_agent(self.user_agent)
375                .build()
376                .map_err(ClickSendError::Http)?,
377        };
378
379        Ok(Client {
380            inner: Arc::new(Inner {
381                username: self.username,
382                api_key: self.api_key,
383                base_url: self.base_url,
384                http,
385                retry: self.retry,
386            }),
387        })
388    }
389}
390
391// ═════════════════════════════════════════════════════════════════
392//                          NAMESPACES
393// ═════════════════════════════════════════════════════════════════
394
395/// `/account` namespace. Get from [`Client::account`].
396#[derive(Debug)]
397pub struct AccountApi<'a> {
398    c: &'a Client,
399}
400
401impl<'a> AccountApi<'a> {
402    /// `GET /account` — fetch account info, balance, currency.
403    pub async fn get(&self) -> Result<ApiEnvelope<AccountData>, ClickSendError> {
404        self.c
405            .execute::<AccountData>(Method::GET, "/account", None, None)
406            .await
407    }
408}
409
410/// `/sms/*` namespace. Get from [`Client::sms`].
411#[derive(Debug)]
412pub struct SmsApi<'a> {
413    c: &'a Client,
414}
415
416impl<'a> SmsApi<'a> {
417    /// `POST /sms/send` — actually send SMS. Each message in the collection
418    /// is billed.
419    pub async fn send(
420        &self,
421        messages: &SmsMessageCollection,
422    ) -> Result<ApiEnvelope<SmsSendData>, ClickSendError> {
423        self.c
424            .execute::<SmsSendData>(Method::POST, "/sms/send", None, Some(messages))
425            .await
426    }
427
428    /// `POST /sms/price` — free price estimate; nothing is sent.
429    pub async fn price(
430        &self,
431        messages: &SmsMessageCollection,
432    ) -> Result<ApiEnvelope<SmsSendData>, ClickSendError> {
433        self.c
434            .execute::<SmsSendData>(Method::POST, "/sms/price", None, Some(messages))
435            .await
436    }
437
438    /// `GET /sms/history` — list previously sent SMS with delivery status.
439    /// Common query keys: `q`, `date_from`, `date_to`, `page`, `limit`.
440    pub async fn history(
441        &self,
442        query: &[(&str, &str)],
443    ) -> Result<ApiEnvelope<Paginated<SmsHistoryItem>>, ClickSendError> {
444        self.c
445            .execute::<Paginated<SmsHistoryItem>>(Method::GET, "/sms/history", Some(query), None)
446            .await
447    }
448
449    /// `GET /sms/receipts` — delivery receipts only.
450    pub async fn receipts(
451        &self,
452        query: &[(&str, &str)],
453    ) -> Result<ApiEnvelope<Paginated<SmsReceiptItem>>, ClickSendError> {
454        self.c
455            .execute::<Paginated<SmsReceiptItem>>(Method::GET, "/sms/receipts", Some(query), None)
456            .await
457    }
458
459    /// `GET /sms/inbound` — incoming SMS to your numbers.
460    pub async fn inbound(
461        &self,
462        query: &[(&str, &str)],
463    ) -> Result<ApiEnvelope<Paginated<SmsInboundItem>>, ClickSendError> {
464        self.c
465            .execute::<Paginated<SmsInboundItem>>(Method::GET, "/sms/inbound", Some(query), None)
466            .await
467    }
468
469    /// `PUT /sms/{id}/cancel` — cancel a scheduled message that hasn't sent yet.
470    pub async fn cancel(
471        &self,
472        message_id: &str,
473    ) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
474        let path = format!("/sms/{message_id}/cancel");
475        self.c
476            .execute::<serde_json::Value>(Method::PUT, &path, None, None)
477            .await
478    }
479
480    /// `PUT /sms/cancel-all` — cancel every scheduled message at once.
481    pub async fn cancel_all(&self) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
482        self.c
483            .execute::<serde_json::Value>(Method::PUT, "/sms/cancel-all", None, None)
484            .await
485    }
486}
487
488/// `/mms/*` namespace. Get from [`Client::mms`].
489#[derive(Debug)]
490pub struct MmsApi<'a> {
491    c: &'a Client,
492}
493
494impl<'a> MmsApi<'a> {
495    /// `POST /mms/send` — send MMS. The collection's `media_file` must be a
496    /// publicly reachable URL.
497    pub async fn send(
498        &self,
499        messages: &MmsMessageCollection,
500    ) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
501        self.c
502            .execute::<serde_json::Value>(Method::POST, "/mms/send", None, Some(messages))
503            .await
504    }
505}
506
507/// `/voice/*` namespace. Get from [`Client::voice`].
508#[derive(Debug)]
509pub struct VoiceApi<'a> {
510    c: &'a Client,
511}
512
513impl<'a> VoiceApi<'a> {
514    /// `POST /voice/send` — place TTS voice calls.
515    pub async fn send(
516        &self,
517        messages: &VoiceMessageCollection,
518    ) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
519        self.c
520            .execute::<serde_json::Value>(Method::POST, "/voice/send", None, Some(messages))
521            .await
522    }
523}
524
525/// `/email/*` namespace. Get from [`Client::email`].
526#[derive(Debug)]
527pub struct EmailApi<'a> {
528    c: &'a Client,
529}
530
531impl<'a> EmailApi<'a> {
532    /// `POST /email/send` — send a transactional email. Requires a
533    /// pre-verified sender (see [`crate::EmailFrom`]).
534    pub async fn send(
535        &self,
536        email: &Email,
537    ) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
538        self.c
539            .execute::<serde_json::Value>(Method::POST, "/email/send", None, Some(email))
540            .await
541    }
542}