use std::sync::{Arc, Mutex};
use std::time::Duration;
use reqwest::header::{HeaderMap, HeaderValue};
use crate::errors::{ApiError, Error};
use crate::resources::leads::LeadsResource;
use crate::resources::notes::NotesResource;
use crate::resources::scoring::ScoringResource;
use crate::resources::webhooks::WebhooksResource;
use crate::types::RateLimitState;
const DEFAULT_BASE_URL: &str = "https://api.klozeo.com/api/v1";
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub base_url: String,
pub timeout: Duration,
pub max_retries: u32,
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
base_url: DEFAULT_BASE_URL.to_owned(),
timeout: Duration::from_secs(30),
max_retries: 3,
}
}
}
impl ClientConfig {
pub fn builder() -> ClientConfigBuilder {
ClientConfigBuilder::default()
}
}
#[derive(Default)]
pub struct ClientConfigBuilder {
base_url: Option<String>,
timeout: Option<Duration>,
max_retries: Option<u32>,
}
impl ClientConfigBuilder {
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn timeout(mut self, d: Duration) -> Self {
self.timeout = Some(d);
self
}
pub fn max_retries(mut self, n: u32) -> Self {
self.max_retries = Some(n);
self
}
pub fn build(self) -> ClientConfig {
let defaults = ClientConfig::default();
ClientConfig {
base_url: self.base_url.unwrap_or(defaults.base_url),
timeout: self.timeout.unwrap_or(defaults.timeout),
max_retries: self.max_retries.unwrap_or(defaults.max_retries),
}
}
}
pub(crate) struct ClientInner {
pub(crate) http: reqwest::Client,
pub(crate) config: ClientConfig,
pub(crate) rate_limit: Mutex<Option<RateLimitState>>,
}
#[derive(Clone)]
pub struct Client {
pub(crate) inner: Arc<ClientInner>,
}
impl Client {
pub fn new(api_key: &str) -> Self {
Self::with_config(api_key, ClientConfig::default())
}
pub fn with_config(api_key: &str, config: ClientConfig) -> Self {
let mut headers = HeaderMap::new();
let key_value = HeaderValue::from_str(api_key)
.expect("API key must be a valid header value");
headers.insert("X-API-Key", key_value);
let http = reqwest::Client::builder()
.default_headers(headers)
.timeout(config.timeout)
.build()
.expect("failed to build reqwest client");
Self {
inner: Arc::new(ClientInner {
http,
config,
rate_limit: Mutex::new(None),
}),
}
}
pub fn leads(&self) -> LeadsResource {
LeadsResource::new(Arc::clone(&self.inner))
}
pub fn notes(&self) -> NotesResource {
NotesResource::new(Arc::clone(&self.inner))
}
pub fn scoring(&self) -> ScoringResource {
ScoringResource::new(Arc::clone(&self.inner))
}
pub fn webhooks(&self) -> WebhooksResource {
WebhooksResource::new(Arc::clone(&self.inner))
}
pub fn rate_limit_state(&self) -> Option<RateLimitState> {
self.inner.rate_limit.lock().unwrap().clone()
}
}
async fn parse_api_error(status: u16, resp: reqwest::Response) -> Error {
#[derive(serde::Deserialize)]
struct ApiErrorBody {
message: Option<String>,
code: Option<String>,
error: Option<String>,
}
let body: ApiErrorBody = resp.json().await.unwrap_or(ApiErrorBody {
message: None,
code: None,
error: None,
});
let message = body.message
.or(body.error)
.unwrap_or_else(|| format!("HTTP {status}"));
let code = body.code.unwrap_or_else(|| "unknown".to_owned());
match status {
404 => Error::NotFound,
401 => Error::Unauthorized,
403 => Error::Forbidden,
400 => Error::BadRequest(message),
429 => Error::RateLimited { retry_after: 0 }, _ => Error::Api(ApiError { status_code: status, message, code }),
}
}
pub(crate) fn update_rate_limit(inner: &ClientInner, headers: &reqwest::header::HeaderMap) {
let limit = headers
.get("X-RateLimit-Limit")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
let remaining = headers
.get("X-RateLimit-Remaining")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok());
if let (Some(limit), Some(remaining)) = (limit, remaining) {
*inner.rate_limit.lock().unwrap() = Some(RateLimitState { limit, remaining });
}
}
pub(crate) fn retry_after_secs(headers: &reqwest::header::HeaderMap) -> u64 {
headers
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(1)
}
pub(crate) async fn execute_with_retry(
inner: &ClientInner,
build_request: impl Fn() -> reqwest::RequestBuilder,
) -> Result<reqwest::Response, Error> {
let max = inner.config.max_retries;
let mut attempt = 0u32;
loop {
let resp = build_request().send().await?;
let status = resp.status().as_u16();
update_rate_limit(inner, resp.headers());
if resp.status().is_success() {
return Ok(resp);
}
if status == 429 {
let retry_after = retry_after_secs(resp.headers());
if attempt < max {
attempt += 1;
tokio::time::sleep(std::time::Duration::from_secs(retry_after)).await;
continue;
}
return Err(Error::RateLimited { retry_after });
}
if status >= 500 && attempt < max {
attempt += 1;
let backoff = std::time::Duration::from_millis(200 * (1u64 << attempt));
tokio::time::sleep(backoff).await;
continue;
}
return Err(parse_api_error(status, resp).await);
}
}