1use 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#[derive(Debug, Clone, Copy)]
32pub struct RetryConfig {
33 pub max_attempts: u32,
35 pub initial_backoff: Duration,
37 pub backoff_multiplier: f64,
39 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 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#[derive(Clone)]
76pub struct Client {
77 pub(crate) inner: Arc<Inner>,
78}
79
80impl 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 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 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 pub fn account(&self) -> AccountApi<'_> {
115 AccountApi { c: self }
116 }
117 pub fn sms(&self) -> SmsApi<'_> {
119 SmsApi { c: self }
120 }
121 pub fn mms(&self) -> MmsApi<'_> {
123 MmsApi { c: self }
124 }
125 pub fn voice(&self) -> VoiceApi<'_> {
127 VoiceApi { c: self }
128 }
129 pub fn email(&self) -> EmailApi<'_> {
131 EmailApi { c: self }
132 }
133
134 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 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
284pub(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
299pub 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 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 pub fn base_url(mut self, v: impl Into<String>) -> Self {
330 self.base_url = v.into();
331 self
332 }
333 pub fn timeout(mut self, v: Duration) -> Self {
335 self.timeout = v;
336 self
337 }
338 pub fn connect_timeout(mut self, v: Duration) -> Self {
340 self.connect_timeout = v;
341 self
342 }
343 pub fn user_agent(mut self, v: impl Into<String>) -> Self {
345 self.user_agent = v.into();
346 self
347 }
348 pub fn retry(mut self, v: RetryConfig) -> Self {
350 self.retry = v;
351 self
352 }
353 pub fn http_client(mut self, http: HttpClient) -> Self {
356 self.http = Some(http);
357 self
358 }
359
360 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#[derive(Debug)]
397pub struct AccountApi<'a> {
398 c: &'a Client,
399}
400
401impl<'a> AccountApi<'a> {
402 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#[derive(Debug)]
412pub struct SmsApi<'a> {
413 c: &'a Client,
414}
415
416impl<'a> SmsApi<'a> {
417 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 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 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 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 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 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 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#[derive(Debug)]
490pub struct MmsApi<'a> {
491 c: &'a Client,
492}
493
494impl<'a> MmsApi<'a> {
495 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#[derive(Debug)]
509pub struct VoiceApi<'a> {
510 c: &'a Client,
511}
512
513impl<'a> VoiceApi<'a> {
514 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#[derive(Debug)]
527pub struct EmailApi<'a> {
528 c: &'a Client,
529}
530
531impl<'a> EmailApi<'a> {
532 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}