use std::time::Duration;
use reqwest::{multipart, Client, Method, Response, StatusCode};
use serde::de::DeserializeOwned;
use serde::Serialize;
use serde_json::Value;
use crate::error::{AxeneError, Result};
const DEFAULT_BASE: &str = "https://mail.axene.io";
const USER_AGENT: &str = concat!("axene-mailer-rust/", env!("CARGO_PKG_VERSION"));
#[derive(Debug, Clone)]
pub struct HttpTransport {
client: Client,
api_key: String,
base_url: String,
max_retries: u32,
}
impl HttpTransport {
pub(crate) fn new(
api_key: String,
base_url: Option<String>,
max_retries: u32,
timeout: Duration,
) -> Result<Self> {
if api_key.is_empty() {
return Err(AxeneError::transport("Axene: `api_key` is required."));
}
let base = base_url.unwrap_or_else(|| DEFAULT_BASE.to_string());
let base = base.trim_end_matches('/').to_string();
let client = Client::builder()
.timeout(timeout)
.build()
.map_err(|e| AxeneError::transport(format!("failed to build HTTP client: {e}")))?;
Ok(Self {
client,
api_key,
base_url: base,
max_retries: max_retries.max(1),
})
}
pub(crate) async fn request<T: DeserializeOwned>(
&self,
method: Method,
path: &str,
) -> Result<T> {
self.send::<(), T>(method, path, None).await
}
pub(crate) async fn request_json<B: Serialize, T: DeserializeOwned>(
&self,
method: Method,
path: &str,
body: &B,
) -> Result<T> {
self.send(method, path, Some(body)).await
}
pub(crate) async fn request_empty(&self, method: Method, path: &str) -> Result<()> {
let _: IgnoredBody = self.send::<(), IgnoredBody>(method, path, None).await?;
Ok(())
}
async fn send<B: Serialize, T: DeserializeOwned>(
&self,
method: Method,
path: &str,
body: Option<&B>,
) -> Result<T> {
let url = format!("{}{}", self.base_url, path);
let mut last_error: Option<AxeneError> = None;
for attempt in 1..=self.max_retries {
let mut builder = self
.client
.request(method.clone(), &url)
.bearer_auth(&self.api_key)
.header(reqwest::header::USER_AGENT, USER_AGENT);
if let Some(b) = body {
builder = builder.json(b);
}
match builder.send().await {
Ok(res) => {
let status = res.status();
if is_retryable(status) && attempt < self.max_retries {
let wait = backoff(Some(&res), attempt);
last_error = Some(error_placeholder(status));
tokio::time::sleep(wait).await;
continue;
}
return decode(res).await;
}
Err(err) => {
last_error = Some(AxeneError::transport(format!(
"Axene request failed: {err}"
)));
if attempt < self.max_retries {
tokio::time::sleep(backoff(None, attempt)).await;
continue;
}
}
}
}
Err(last_error
.unwrap_or_else(|| AxeneError::transport("Axene request failed: no attempts made")))
}
pub(crate) async fn upload<T: DeserializeOwned>(
&self,
path: &str,
file: Vec<u8>,
filename: &str,
) -> Result<T> {
let url = format!("{}{}", self.base_url, path);
let part = multipart::Part::bytes(file).file_name(filename.to_string());
let form = multipart::Form::new().part("file", part);
let res = self
.client
.post(&url)
.bearer_auth(&self.api_key)
.header(reqwest::header::USER_AGENT, USER_AGENT)
.multipart(form)
.send()
.await
.map_err(|e| AxeneError::transport(format!("Axene upload failed: {e}")))?;
decode(res).await
}
}
#[derive(serde::Deserialize)]
struct IgnoredBody {
#[serde(flatten)]
#[allow(dead_code)]
rest: std::collections::HashMap<String, Value>,
}
fn is_retryable(status: StatusCode) -> bool {
status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()
}
fn backoff(res: Option<&Response>, attempt: u32) -> Duration {
if let Some(res) = res {
if let Some(secs) = res
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.trim().parse::<f64>().ok())
{
if secs > 0.0 {
return Duration::from_secs_f64(secs);
}
}
}
Duration::from_millis(250 * 2u64.pow(attempt.saturating_sub(1)))
}
fn error_placeholder(status: StatusCode) -> AxeneError {
AxeneError::new(
status.as_u16(),
format!("Axene request failed ({})", status.as_u16()),
None,
)
}
async fn decode<T: DeserializeOwned>(res: Response) -> Result<T> {
let status = res.status();
let bytes = res
.bytes()
.await
.map_err(|e| AxeneError::transport(format!("failed to read response body: {e}")))?;
if status.is_success() {
if bytes.is_empty() {
return serde_json::from_slice(b"{}").map_err(|e| {
AxeneError::transport(format!("failed to decode empty body: {e}"))
});
}
return serde_json::from_slice(&bytes).map_err(|e| {
AxeneError::transport(format!("failed to decode response: {e}"))
});
}
Err(to_error(status.as_u16(), &bytes))
}
fn to_error(status: u16, bytes: &[u8]) -> AxeneError {
let payload: Option<Value> = serde_json::from_slice(bytes).ok();
let detail = payload.as_ref().and_then(|p| p.get("detail"));
let mut code = None;
let mut message = None;
if let Some(detail) = detail {
if let Some(obj) = detail.as_object() {
code = obj.get("code").and_then(|c| c.as_str()).map(String::from);
message = obj.get("message").and_then(|m| m.as_str()).map(String::from);
} else if let Some(s) = detail.as_str() {
message = Some(s.to_string());
}
}
AxeneError::new(
status,
message.unwrap_or_else(|| format!("Axene request failed ({status})")),
code,
)
}