use std::{fmt, sync::Arc, time::Duration};
use reqwest::{Client as HttpClient, Method, RequestBuilder};
use serde::de::DeserializeOwned;
use serde::Serialize;
use crate::{
errors::ClickSendError,
types::{
AccountData, ApiEnvelope, Email, MmsMessageCollection, Paginated, SmsHistoryItem,
SmsInboundItem, SmsMessageCollection, SmsReceiptItem, SmsSendData, VoiceMessageCollection,
},
};
const DEFAULT_BASE_URL: &str = "https://rest.clicksend.com/v3";
const DEFAULT_USER_AGENT: &str = concat!("clicksend-rs/", env!("CARGO_PKG_VERSION"));
#[derive(Debug, Clone, Copy)]
pub struct RetryConfig {
pub max_attempts: u32,
pub initial_backoff: Duration,
pub backoff_multiplier: f64,
pub max_backoff: Duration,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: 1,
initial_backoff: Duration::from_millis(500),
backoff_multiplier: 2.0,
max_backoff: Duration::from_secs(30),
}
}
}
impl RetryConfig {
pub fn enabled(max_attempts: u32) -> Self {
Self {
max_attempts,
..Default::default()
}
}
}
pub(crate) struct Inner {
pub username: String,
pub api_key: String,
pub base_url: String,
pub http: HttpClient,
pub retry: RetryConfig,
}
#[derive(Clone)]
pub struct Client {
pub(crate) inner: Arc<Inner>,
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Client")
.field("username", &self.inner.username)
.field("api_key", &"<redacted>")
.field("base_url", &self.inner.base_url)
.field("retry", &self.inner.retry)
.finish()
}
}
impl Client {
pub fn new(username: impl Into<String>, api_key: impl Into<String>) -> Self {
ClientBuilder::new(username, api_key).build().expect("default client builds")
}
pub fn builder(
username: impl Into<String>,
api_key: impl Into<String>,
) -> ClientBuilder {
ClientBuilder::new(username, api_key)
}
pub fn account(&self) -> AccountApi<'_> {
AccountApi { c: self }
}
pub fn sms(&self) -> SmsApi<'_> {
SmsApi { c: self }
}
pub fn mms(&self) -> MmsApi<'_> {
MmsApi { c: self }
}
pub fn voice(&self) -> VoiceApi<'_> {
VoiceApi { c: self }
}
pub fn email(&self) -> EmailApi<'_> {
EmailApi { c: self }
}
pub fn raw_request(&self, method: Method, path: &str) -> RequestBuilder {
self.inner
.http
.request(method, format!("{}{}", self.inner.base_url, path))
.basic_auth(&self.inner.username, Some(&self.inner.api_key))
}
fn req(&self, method: Method, path: &str) -> RequestBuilder {
self.raw_request(method, path)
}
pub(crate) async fn execute<T: DeserializeOwned>(
&self,
method: Method,
path: &str,
query: Option<&[(&str, &str)]>,
body: Option<&dyn ErasedSerialize>,
) -> Result<ApiEnvelope<T>, ClickSendError> {
let span = tracing::debug_span!("clicksend", %method, path);
let _g = span.enter();
let mut attempt = 0u32;
let mut backoff = self.inner.retry.initial_backoff;
loop {
attempt += 1;
let mut rb = self.req(method.clone(), path);
if let Some(q) = query {
rb = rb.query(q);
}
if let Some(b) = body {
rb = rb.json(&b.as_value()?);
}
let resp = rb.send().await;
let resp = match resp {
Ok(r) => r,
Err(e) => {
if attempt < self.inner.retry.max_attempts && e.is_timeout() {
tracing::warn!(?e, attempt, "transient send error, retrying");
sleep(backoff).await;
backoff = next_backoff(backoff, &self.inner.retry);
continue;
}
return Err(ClickSendError::Http(e));
}
};
let status = resp.status();
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
if attempt < self.inner.retry.max_attempts {
let retry_after = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
let wait = retry_after
.map(Duration::from_secs)
.unwrap_or(backoff);
tracing::warn!(attempt, ?wait, "429, retrying");
sleep(wait).await;
backoff = next_backoff(backoff, &self.inner.retry);
continue;
}
let retry_after = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
return Err(ClickSendError::RateLimited {
retry_after_secs: retry_after,
});
}
if status.is_server_error() && attempt < self.inner.retry.max_attempts {
tracing::warn!(?status, attempt, "5xx, retrying");
sleep(backoff).await;
backoff = next_backoff(backoff, &self.inner.retry);
continue;
}
let text = resp.text().await.map_err(ClickSendError::Http)?;
return decode_envelope(status, &text);
}
}
}
pub(crate) fn decode_envelope<T: DeserializeOwned>(
status: reqwest::StatusCode,
text: &str,
) -> Result<ApiEnvelope<T>, ClickSendError> {
if status == reqwest::StatusCode::UNAUTHORIZED {
return Err(ClickSendError::Unauthorized);
}
if status == reqwest::StatusCode::NOT_FOUND {
return Err(ClickSendError::NotFound(text.to_string()));
}
if status.is_client_error() || status.is_server_error() {
return Err(ClickSendError::Http4xx5xx {
code: status.as_u16(),
message: text.to_string(),
});
}
let env: ApiEnvelope<T> = serde_json::from_str(text).map_err(|e| ClickSendError::Decode {
message: e.to_string(),
body: text.to_string(),
})?;
if env.response_code != "SUCCESS" {
return Err(ClickSendError::Api {
code: env.response_code.clone(),
message: env.response_msg.clone().unwrap_or_default(),
body: text.to_string(),
});
}
Ok(env)
}
fn next_backoff(current: Duration, cfg: &RetryConfig) -> Duration {
let next = current.mul_f64(cfg.backoff_multiplier);
if next > cfg.max_backoff {
cfg.max_backoff
} else {
next
}
}
async fn sleep(d: Duration) {
tokio::time::sleep(d).await;
}
pub(crate) trait ErasedSerialize: Send + Sync {
fn as_value(&self) -> Result<serde_json::Value, ClickSendError>;
}
impl<T: Serialize + Send + Sync> ErasedSerialize for T {
fn as_value(&self) -> Result<serde_json::Value, ClickSendError> {
serde_json::to_value(self).map_err(|e| ClickSendError::Decode {
message: e.to_string(),
body: String::new(),
})
}
}
pub struct ClientBuilder {
username: String,
api_key: String,
base_url: String,
timeout: Duration,
connect_timeout: Duration,
user_agent: String,
retry: RetryConfig,
http: Option<HttpClient>,
}
impl ClientBuilder {
pub fn new(username: impl Into<String>, api_key: impl Into<String>) -> Self {
Self {
username: username.into(),
api_key: api_key.into(),
base_url: DEFAULT_BASE_URL.to_string(),
timeout: Duration::from_secs(30),
connect_timeout: Duration::from_secs(10),
user_agent: DEFAULT_USER_AGENT.to_string(),
retry: RetryConfig::default(),
http: None,
}
}
pub fn base_url(mut self, v: impl Into<String>) -> Self {
self.base_url = v.into();
self
}
pub fn timeout(mut self, v: Duration) -> Self {
self.timeout = v;
self
}
pub fn connect_timeout(mut self, v: Duration) -> Self {
self.connect_timeout = v;
self
}
pub fn user_agent(mut self, v: impl Into<String>) -> Self {
self.user_agent = v.into();
self
}
pub fn retry(mut self, v: RetryConfig) -> Self {
self.retry = v;
self
}
pub fn http_client(mut self, http: HttpClient) -> Self {
self.http = Some(http);
self
}
pub fn build(self) -> Result<Client, ClickSendError> {
if self.username.is_empty() {
return Err(ClickSendError::InvalidConfig("username is empty".into()));
}
if self.api_key.is_empty() {
return Err(ClickSendError::InvalidConfig("api_key is empty".into()));
}
let http = match self.http {
Some(h) => h,
None => HttpClient::builder()
.timeout(self.timeout)
.connect_timeout(self.connect_timeout)
.user_agent(self.user_agent)
.build()
.map_err(ClickSendError::Http)?,
};
Ok(Client {
inner: Arc::new(Inner {
username: self.username,
api_key: self.api_key,
base_url: self.base_url,
http,
retry: self.retry,
}),
})
}
}
#[derive(Debug)]
pub struct AccountApi<'a> {
c: &'a Client,
}
impl<'a> AccountApi<'a> {
pub async fn get(&self) -> Result<ApiEnvelope<AccountData>, ClickSendError> {
self.c
.execute::<AccountData>(Method::GET, "/account", None, None)
.await
}
}
#[derive(Debug)]
pub struct SmsApi<'a> {
c: &'a Client,
}
impl<'a> SmsApi<'a> {
pub async fn send(
&self,
messages: &SmsMessageCollection,
) -> Result<ApiEnvelope<SmsSendData>, ClickSendError> {
self.c
.execute::<SmsSendData>(Method::POST, "/sms/send", None, Some(messages))
.await
}
pub async fn price(
&self,
messages: &SmsMessageCollection,
) -> Result<ApiEnvelope<SmsSendData>, ClickSendError> {
self.c
.execute::<SmsSendData>(Method::POST, "/sms/price", None, Some(messages))
.await
}
pub async fn history(
&self,
query: &[(&str, &str)],
) -> Result<ApiEnvelope<Paginated<SmsHistoryItem>>, ClickSendError> {
self.c
.execute::<Paginated<SmsHistoryItem>>(Method::GET, "/sms/history", Some(query), None)
.await
}
pub async fn receipts(
&self,
query: &[(&str, &str)],
) -> Result<ApiEnvelope<Paginated<SmsReceiptItem>>, ClickSendError> {
self.c
.execute::<Paginated<SmsReceiptItem>>(Method::GET, "/sms/receipts", Some(query), None)
.await
}
pub async fn inbound(
&self,
query: &[(&str, &str)],
) -> Result<ApiEnvelope<Paginated<SmsInboundItem>>, ClickSendError> {
self.c
.execute::<Paginated<SmsInboundItem>>(Method::GET, "/sms/inbound", Some(query), None)
.await
}
pub async fn cancel(
&self,
message_id: &str,
) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
let path = format!("/sms/{message_id}/cancel");
self.c
.execute::<serde_json::Value>(Method::PUT, &path, None, None)
.await
}
pub async fn cancel_all(&self) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
self.c
.execute::<serde_json::Value>(Method::PUT, "/sms/cancel-all", None, None)
.await
}
}
#[derive(Debug)]
pub struct MmsApi<'a> {
c: &'a Client,
}
impl<'a> MmsApi<'a> {
pub async fn send(
&self,
messages: &MmsMessageCollection,
) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
self.c
.execute::<serde_json::Value>(Method::POST, "/mms/send", None, Some(messages))
.await
}
}
#[derive(Debug)]
pub struct VoiceApi<'a> {
c: &'a Client,
}
impl<'a> VoiceApi<'a> {
pub async fn send(
&self,
messages: &VoiceMessageCollection,
) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
self.c
.execute::<serde_json::Value>(Method::POST, "/voice/send", None, Some(messages))
.await
}
}
#[derive(Debug)]
pub struct EmailApi<'a> {
c: &'a Client,
}
impl<'a> EmailApi<'a> {
pub async fn send(
&self,
email: &Email,
) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
self.c
.execute::<serde_json::Value>(Method::POST, "/email/send", None, Some(email))
.await
}
}