use std::sync::Arc;
use std::time::Duration;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, USER_AGENT};
use reqwest::{Client, Method, RequestBuilder, StatusCode};
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
use crate::error::Error;
use crate::resources::{
audiences::Audiences, campaigns::Campaigns, contacts::Contacts, domains::Domains,
emails::Emails, templates::Templates, webhooks::Webhooks,
};
use crate::VERSION;
const DEFAULT_BASE_URL: &str = "https://api.sendry.online";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const DEFAULT_RETRIES: u8 = 2;
#[derive(Debug, Clone)]
pub struct Sendry {
pub(crate) inner: Arc<Inner>,
}
#[derive(Debug)]
pub(crate) struct Inner {
pub(crate) http: Client,
pub(crate) base_url: String,
pub(crate) retries: u8,
}
#[derive(Debug, Default)]
pub struct SendryBuilder {
api_key: Option<String>,
base_url: Option<String>,
timeout: Option<Duration>,
retries: Option<u8>,
}
impl SendryBuilder {
#[must_use]
pub fn api_key(mut self, key: impl Into<String>) -> Self {
self.api_key = Some(key.into());
self
}
#[must_use]
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
#[must_use]
pub fn timeout(mut self, d: Duration) -> Self {
self.timeout = Some(d);
self
}
#[must_use]
pub fn max_retries(mut self, n: u8) -> Self {
self.retries = Some(n);
self
}
pub fn build(self) -> Result<Sendry, Error> {
let api_key = self
.api_key
.ok_or_else(|| Error::Authentication("api_key is required".into()))?;
let mut headers = HeaderMap::new();
let auth = HeaderValue::from_str(&format!("Bearer {api_key}"))
.map_err(|_| Error::Authentication("invalid api_key bytes".into()))?;
headers.insert(AUTHORIZATION, auth);
headers.insert(
USER_AGENT,
HeaderValue::from_str(&format!("sendry-rust/{VERSION}")).unwrap(),
);
let http = Client::builder()
.default_headers(headers)
.timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))
.build()
.map_err(Error::Network)?;
Ok(Sendry {
inner: Arc::new(Inner {
http,
base_url: self
.base_url
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
.trim_end_matches('/')
.to_string(),
retries: self.retries.unwrap_or(DEFAULT_RETRIES),
}),
})
}
}
impl Sendry {
pub fn new(api_key: impl Into<String>) -> Self {
Self::builder()
.api_key(api_key)
.build()
.expect("api key required")
}
#[must_use]
pub fn builder() -> SendryBuilder {
SendryBuilder::default()
}
#[must_use]
pub fn emails(&self) -> Emails {
Emails::new(self.clone())
}
#[must_use]
pub fn domains(&self) -> Domains {
Domains::new(self.clone())
}
#[must_use]
pub fn templates(&self) -> Templates {
Templates::new(self.clone())
}
#[must_use]
pub fn contacts(&self) -> Contacts {
Contacts::new(self.clone())
}
#[must_use]
pub fn audiences(&self) -> Audiences {
Audiences::new(self.clone())
}
#[must_use]
pub fn campaigns(&self) -> Campaigns {
Campaigns::new(self.clone())
}
#[must_use]
pub fn webhooks(&self) -> Webhooks {
Webhooks::new(self.clone())
}
pub(crate) async fn request<R>(&self, req: RequestBuilder) -> Result<R, Error>
where
R: DeserializeOwned,
{
let resp = self.send_with_retries(req).await?;
let status = resp.status();
if status == StatusCode::NO_CONTENT {
return serde_json::from_value(Value::Null).map_err(Error::Decode);
}
let bytes = resp.bytes().await.map_err(Error::Network)?;
serde_json::from_slice::<R>(&bytes).map_err(Error::Decode)
}
pub(crate) async fn request_unit(&self, req: RequestBuilder) -> Result<(), Error> {
let resp = self.send_with_retries(req).await?;
if resp.status().is_success() {
let _ = resp.bytes().await;
return Ok(());
}
Err(Self::error_from_status(resp.status(), Value::Null))
}
pub(crate) fn build<B>(
&self,
method: Method,
path: &str,
query: &[(&str, String)],
body: Option<&B>,
) -> RequestBuilder
where
B: Serialize + ?Sized,
{
let url = format!("{}{}", self.inner.base_url, path);
let mut req = self.inner.http.request(method, url);
if !query.is_empty() {
req = req.query(query);
}
if let Some(b) = body {
req = req.json(b);
}
req
}
async fn send_with_retries(&self, req: RequestBuilder) -> Result<reqwest::Response, Error> {
let max_attempts = u32::from(self.inner.retries) + 1;
let mut last_error: Option<Error> = None;
for attempt in 0..max_attempts {
if attempt > 0 {
let delay = backoff(attempt - 1);
tokio::time::sleep(delay).await;
}
let cloned = req
.try_clone()
.expect("request body is JSON / static so try_clone always succeeds");
match cloned.send().await {
Ok(resp) if resp.status().is_success() => return Ok(resp),
Ok(resp) => {
let status = resp.status();
let bytes = resp.bytes().await.map_err(Error::Network)?;
let body: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
let err = Self::error_from_status(status, body);
if !err.is_retryable() {
return Err(err);
}
last_error = Some(err);
}
Err(e) => {
last_error = Some(Error::Network(e));
}
}
}
Err(last_error.expect("loop runs at least once"))
}
fn error_from_status(status: StatusCode, body: Value) -> Error {
let err = body.get("error");
let code = err
.and_then(|e| e.get("code"))
.and_then(Value::as_str)
.unwrap_or("unknown_error")
.to_string();
let message = err
.and_then(|e| e.get("message"))
.and_then(Value::as_str)
.unwrap_or_else(|| status.canonical_reason().unwrap_or("error"))
.to_string();
let details = err.and_then(|e| e.get("details")).cloned();
match status.as_u16() {
401 => Error::Authentication(message),
404 => Error::NotFound(message),
422 => Error::Validation {
code,
message,
details,
},
429 => Error::RateLimit {
code,
message,
retry_after: None,
},
s => Error::Api {
status: s,
code,
message,
details,
},
}
}
}
fn backoff(attempt: u32) -> Duration {
let ms = 200u64.saturating_mul(1u64 << attempt.min(5));
Duration::from_millis(ms.min(5_000))
}