use std::{fmt, sync::Arc, thread, time::Duration};
use reqwest::blocking::{Client as HttpClient, RequestBuilder};
use reqwest::Method;
use serde::de::DeserializeOwned;
use crate::{
client::{decode_envelope, ErasedSerialize, RetryConfig},
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"));
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 BlockingClient {
pub(crate) inner: Arc<Inner>,
}
impl fmt::Debug for BlockingClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BlockingClient")
.field("username", &self.inner.username)
.field("api_key", &"<redacted>")
.field("base_url", &self.inner.base_url)
.field("retry", &self.inner.retry)
.finish()
}
}
impl BlockingClient {
pub fn new(username: impl Into<String>, api_key: impl Into<String>) -> Self {
BlockingClientBuilder::new(username, api_key).build().expect("default builds")
}
pub fn builder(
username: impl Into<String>,
api_key: impl Into<String>,
) -> BlockingClientBuilder {
BlockingClientBuilder::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 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.blocking", %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.raw_request(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();
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");
thread::sleep(backoff);
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");
thread::sleep(wait);
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");
thread::sleep(backoff);
backoff = next_backoff(backoff, &self.inner.retry);
continue;
}
let text = resp.text().map_err(ClickSendError::Http)?;
return decode_envelope(status, &text);
}
}
}
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
}
}
pub struct BlockingClientBuilder {
username: String,
api_key: String,
base_url: String,
timeout: Duration,
connect_timeout: Duration,
user_agent: String,
retry: RetryConfig,
http: Option<HttpClient>,
}
impl BlockingClientBuilder {
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<BlockingClient, 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(BlockingClient {
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 BlockingClient,
}
impl<'a> AccountApi<'a> {
pub fn get(&self) -> Result<ApiEnvelope<AccountData>, ClickSendError> {
self.c.execute::<AccountData>(Method::GET, "/account", None, None)
}
}
#[derive(Debug)]
pub struct SmsApi<'a> {
c: &'a BlockingClient,
}
impl<'a> SmsApi<'a> {
pub fn send(
&self,
messages: &SmsMessageCollection,
) -> Result<ApiEnvelope<SmsSendData>, ClickSendError> {
self.c
.execute::<SmsSendData>(Method::POST, "/sms/send", None, Some(messages))
}
pub fn price(
&self,
messages: &SmsMessageCollection,
) -> Result<ApiEnvelope<SmsSendData>, ClickSendError> {
self.c
.execute::<SmsSendData>(Method::POST, "/sms/price", None, Some(messages))
}
pub fn history(
&self,
query: &[(&str, &str)],
) -> Result<ApiEnvelope<Paginated<SmsHistoryItem>>, ClickSendError> {
self.c
.execute::<Paginated<SmsHistoryItem>>(Method::GET, "/sms/history", Some(query), None)
}
pub fn receipts(
&self,
query: &[(&str, &str)],
) -> Result<ApiEnvelope<Paginated<SmsReceiptItem>>, ClickSendError> {
self.c
.execute::<Paginated<SmsReceiptItem>>(Method::GET, "/sms/receipts", Some(query), None)
}
pub fn inbound(
&self,
query: &[(&str, &str)],
) -> Result<ApiEnvelope<Paginated<SmsInboundItem>>, ClickSendError> {
self.c
.execute::<Paginated<SmsInboundItem>>(Method::GET, "/sms/inbound", Some(query), None)
}
pub 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)
}
pub fn cancel_all(&self) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
self.c
.execute::<serde_json::Value>(Method::PUT, "/sms/cancel-all", None, None)
}
}
#[derive(Debug)]
pub struct MmsApi<'a> {
c: &'a BlockingClient,
}
impl<'a> MmsApi<'a> {
pub fn send(
&self,
messages: &MmsMessageCollection,
) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
self.c
.execute::<serde_json::Value>(Method::POST, "/mms/send", None, Some(messages))
}
}
#[derive(Debug)]
pub struct VoiceApi<'a> {
c: &'a BlockingClient,
}
impl<'a> VoiceApi<'a> {
pub fn send(
&self,
messages: &VoiceMessageCollection,
) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
self.c
.execute::<serde_json::Value>(Method::POST, "/voice/send", None, Some(messages))
}
}
#[derive(Debug)]
pub struct EmailApi<'a> {
c: &'a BlockingClient,
}
impl<'a> EmailApi<'a> {
pub fn send(
&self,
email: &Email,
) -> Result<ApiEnvelope<serde_json::Value>, ClickSendError> {
self.c
.execute::<serde_json::Value>(Method::POST, "/email/send", None, Some(email))
}
}