use crate::cache::CacheService;
use crate::dns::DnsService;
use crate::error::{Error, Result};
use crate::zones::ZoneService;
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
#[derive(Clone)]
pub struct CloudflareClient {
pub(crate) http_client: reqwest::Client,
#[allow(dead_code)]
pub(crate) api_token: String,
pub base_url: String,
pub retry_config: RetryConfig,
}
impl std::fmt::Debug for CloudflareClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CloudflareClient")
.field("base_url", &self.base_url)
.field("retry_config", &self.retry_config)
.finish()
}
}
#[derive(Clone, Debug)]
pub struct RetryConfig {
pub max_retries: u32,
pub initial_delay: std::time::Duration,
pub max_delay: std::time::Duration,
pub backoff_multiplier: f64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_delay: std::time::Duration::from_millis(500),
max_delay: std::time::Duration::from_secs(30),
backoff_multiplier: 2.0,
}
}
}
impl RetryConfig {
pub fn disabled() -> Self {
Self {
max_retries: 0,
initial_delay: std::time::Duration::from_millis(0),
max_delay: std::time::Duration::from_millis(0),
backoff_multiplier: 1.0,
}
}
pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {
if attempt == 0 {
return self.initial_delay;
}
let delay_ms =
(self.initial_delay.as_millis() as f64) * self.backoff_multiplier.powi(attempt as i32);
let delay = std::time::Duration::from_millis(delay_ms as u64);
delay.min(self.max_delay)
}
}
impl CloudflareClient {
pub fn builder() -> CloudflareClientBuilder {
CloudflareClientBuilder::new()
}
pub fn new(api_token: impl Into<String>) -> Result<Self> {
Self::builder().api_token(api_token).build()
}
pub fn dns(&self) -> DnsService {
DnsService::new(self.clone())
}
pub fn zones(&self) -> ZoneService {
ZoneService::new(self.clone())
}
pub fn cache(&self) -> CacheService {
CacheService::new(self.clone())
}
async fn execute_with_retry<F, Fut>(&self, f: F) -> Result<reqwest::Response>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<reqwest::Response>>,
{
let mut last_error = None;
for attempt in 0..=self.retry_config.max_retries {
match f().await {
Ok(response) => {
let status = response.status();
if status.as_u16() == 429 {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.map(std::time::Duration::from_secs);
if attempt < self.retry_config.max_retries {
let delay = retry_after
.unwrap_or_else(|| self.retry_config.delay_for_attempt(attempt));
tokio::time::sleep(delay).await;
continue;
} else {
return Err(Error::RateLimited {
retry_after: retry_after.map(|d| d.as_secs()),
});
}
}
if status.is_server_error() && attempt < self.retry_config.max_retries {
let delay = self.retry_config.delay_for_attempt(attempt);
tokio::time::sleep(delay).await;
continue;
}
return Ok(response);
}
Err(e) => {
if attempt < self.retry_config.max_retries {
let delay = self.retry_config.delay_for_attempt(attempt);
tokio::time::sleep(delay).await;
last_error = Some(e);
continue;
} else {
return Err(e);
}
}
}
}
Err(last_error.unwrap_or_else(|| Error::InvalidInput("No attempts made".to_string())))
}
pub(crate) async fn get(&self, path: &str) -> Result<reqwest::Response> {
let url = format!("{}{}", self.base_url, path);
self.execute_with_retry(|| async { Ok(self.http_client.get(&url).send().await?) })
.await
}
pub(crate) async fn get_with_params(
&self,
path: &str,
params: &[(&str, String)],
) -> Result<reqwest::Response> {
let url = format!("{}{}", self.base_url, path);
let params = params.to_vec();
self.execute_with_retry(|| async {
Ok(self.http_client.get(&url).query(¶ms).send().await?)
})
.await
}
pub(crate) async fn post(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<reqwest::Response> {
let url = format!("{}{}", self.base_url, path);
let body = body.clone();
self.execute_with_retry(|| async {
Ok(self.http_client.post(&url).json(&body).send().await?)
})
.await
}
pub(crate) async fn put(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<reqwest::Response> {
let url = format!("{}{}", self.base_url, path);
let body = body.clone();
self.execute_with_retry(|| async {
Ok(self.http_client.put(&url).json(&body).send().await?)
})
.await
}
#[allow(dead_code)]
pub(crate) async fn patch(
&self,
path: &str,
body: &serde_json::Value,
) -> Result<reqwest::Response> {
let url = format!("{}{}", self.base_url, path);
let body = body.clone();
self.execute_with_retry(|| async {
Ok(self.http_client.patch(&url).json(&body).send().await?)
})
.await
}
pub(crate) async fn delete(&self, path: &str) -> Result<reqwest::Response> {
let url = format!("{}{}", self.base_url, path);
self.execute_with_retry(|| async { Ok(self.http_client.delete(&url).send().await?) })
.await
}
pub(crate) async fn handle_response<T>(response: reqwest::Response) -> Result<T>
where
T: serde::de::DeserializeOwned,
{
let status = response.status();
if status.as_u16() == 429 {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
return Err(Error::RateLimited { retry_after });
}
if status.as_u16() == 401 || status.as_u16() == 403 {
let body = response.text().await?;
return Err(Error::Unauthorized(format!(
"Authentication failed: {}",
body
)));
}
let body = response.text().await?;
if !status.is_success() {
return Err(Error::Api(crate::error::ApiError::from_response(
status.as_u16(),
&body,
)));
}
let api_response: crate::types::ApiResponse<T> = serde_json::from_str(&body)?;
if !api_response.success {
let error_msg = api_response
.errors
.first()
.map(|e| e.message.clone())
.unwrap_or_else(|| "Unknown error".to_string());
return Err(Error::Api(crate::error::ApiError::new(
status.as_u16(),
error_msg,
body,
)));
}
api_response
.result
.ok_or_else(|| Error::InvalidInput("No result in API response".to_string()))
}
}
pub struct CloudflareClientBuilder {
api_token: Option<String>,
base_url: Option<String>,
timeout: Option<std::time::Duration>,
retry_config: Option<RetryConfig>,
}
impl CloudflareClientBuilder {
pub fn new() -> Self {
Self {
api_token: None,
base_url: None,
timeout: None,
retry_config: None,
}
}
pub fn api_token(mut self, token: impl Into<String>) -> Self {
self.api_token = Some(token.into());
self
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn retry_config(mut self, config: RetryConfig) -> Self {
self.retry_config = Some(config);
self
}
pub fn build(self) -> Result<CloudflareClient> {
let api_token = self
.api_token
.ok_or_else(|| Error::InvalidInput("API token is required".to_string()))?;
let base_url = self
.base_url
.unwrap_or_else(|| "https://api.cloudflare.com/client/v4".to_string());
let timeout = self
.timeout
.unwrap_or_else(|| std::time::Duration::from_secs(30));
let retry_config = self.retry_config.unwrap_or_default();
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {}", api_token))
.map_err(|_| Error::InvalidInput("Invalid API token format".to_string()))?,
);
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let http_client = reqwest::Client::builder()
.default_headers(headers)
.timeout(timeout)
.build()?;
Ok(CloudflareClient {
http_client,
api_token,
base_url,
retry_config,
})
}
}
impl Default for CloudflareClientBuilder {
fn default() -> Self {
Self::new()
}
}