lmrc_cloudflare/
client.rs1use crate::cache::CacheService;
4use crate::dns::DnsService;
5use crate::error::{Error, Result};
6use crate::zones::ZoneService;
7use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
8
9#[derive(Clone)]
33pub struct CloudflareClient {
34 pub(crate) http_client: reqwest::Client,
36
37 #[allow(dead_code)]
39 pub(crate) api_token: String,
40
41 pub base_url: String,
43
44 pub retry_config: RetryConfig,
46}
47
48impl std::fmt::Debug for CloudflareClient {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("CloudflareClient")
51 .field("base_url", &self.base_url)
52 .field("retry_config", &self.retry_config)
53 .finish()
54 }
55}
56
57#[derive(Clone, Debug)]
59pub struct RetryConfig {
60 pub max_retries: u32,
62
63 pub initial_delay: std::time::Duration,
65
66 pub max_delay: std::time::Duration,
68
69 pub backoff_multiplier: f64,
71}
72
73impl Default for RetryConfig {
74 fn default() -> Self {
75 Self {
76 max_retries: 3,
77 initial_delay: std::time::Duration::from_millis(500),
78 max_delay: std::time::Duration::from_secs(30),
79 backoff_multiplier: 2.0,
80 }
81 }
82}
83
84impl RetryConfig {
85 pub fn disabled() -> Self {
87 Self {
88 max_retries: 0,
89 initial_delay: std::time::Duration::from_millis(0),
90 max_delay: std::time::Duration::from_millis(0),
91 backoff_multiplier: 1.0,
92 }
93 }
94
95 pub fn delay_for_attempt(&self, attempt: u32) -> std::time::Duration {
97 if attempt == 0 {
98 return self.initial_delay;
99 }
100
101 let delay_ms =
102 (self.initial_delay.as_millis() as f64) * self.backoff_multiplier.powi(attempt as i32);
103 let delay = std::time::Duration::from_millis(delay_ms as u64);
104
105 delay.min(self.max_delay)
106 }
107}
108
109impl CloudflareClient {
110 pub fn builder() -> CloudflareClientBuilder {
123 CloudflareClientBuilder::new()
124 }
125
126 pub fn new(api_token: impl Into<String>) -> Result<Self> {
137 Self::builder().api_token(api_token).build()
138 }
139
140 pub fn dns(&self) -> DnsService {
155 DnsService::new(self.clone())
156 }
157
158 pub fn zones(&self) -> ZoneService {
173 ZoneService::new(self.clone())
174 }
175
176 pub fn cache(&self) -> CacheService {
190 CacheService::new(self.clone())
191 }
192
193 async fn execute_with_retry<F, Fut>(&self, f: F) -> Result<reqwest::Response>
195 where
196 F: Fn() -> Fut,
197 Fut: std::future::Future<Output = Result<reqwest::Response>>,
198 {
199 let mut last_error = None;
200
201 for attempt in 0..=self.retry_config.max_retries {
202 match f().await {
203 Ok(response) => {
204 let status = response.status();
205
206 if status.as_u16() == 429 {
208 let retry_after = response
209 .headers()
210 .get("retry-after")
211 .and_then(|v| v.to_str().ok())
212 .and_then(|v| v.parse::<u64>().ok())
213 .map(std::time::Duration::from_secs);
214
215 if attempt < self.retry_config.max_retries {
216 let delay = retry_after
217 .unwrap_or_else(|| self.retry_config.delay_for_attempt(attempt));
218
219 tokio::time::sleep(delay).await;
220 continue;
221 } else {
222 return Err(Error::RateLimited {
223 retry_after: retry_after.map(|d| d.as_secs()),
224 });
225 }
226 }
227
228 if status.is_server_error() && attempt < self.retry_config.max_retries {
230 let delay = self.retry_config.delay_for_attempt(attempt);
231 tokio::time::sleep(delay).await;
232 continue;
233 }
234
235 return Ok(response);
236 }
237 Err(e) => {
238 if attempt < self.retry_config.max_retries {
240 let delay = self.retry_config.delay_for_attempt(attempt);
241 tokio::time::sleep(delay).await;
242 last_error = Some(e);
243 continue;
244 } else {
245 return Err(e);
246 }
247 }
248 }
249 }
250
251 Err(last_error.unwrap_or_else(|| Error::InvalidInput("No attempts made".to_string())))
252 }
253
254 pub(crate) async fn get(&self, path: &str) -> Result<reqwest::Response> {
256 let url = format!("{}{}", self.base_url, path);
257 self.execute_with_retry(|| async { Ok(self.http_client.get(&url).send().await?) })
258 .await
259 }
260
261 pub(crate) async fn get_with_params(
263 &self,
264 path: &str,
265 params: &[(&str, String)],
266 ) -> Result<reqwest::Response> {
267 let url = format!("{}{}", self.base_url, path);
268 let params = params.to_vec();
269 self.execute_with_retry(|| async {
270 Ok(self.http_client.get(&url).query(¶ms).send().await?)
271 })
272 .await
273 }
274
275 pub(crate) async fn post(
277 &self,
278 path: &str,
279 body: &serde_json::Value,
280 ) -> Result<reqwest::Response> {
281 let url = format!("{}{}", self.base_url, path);
282 let body = body.clone();
283 self.execute_with_retry(|| async {
284 Ok(self.http_client.post(&url).json(&body).send().await?)
285 })
286 .await
287 }
288
289 pub(crate) async fn put(
291 &self,
292 path: &str,
293 body: &serde_json::Value,
294 ) -> Result<reqwest::Response> {
295 let url = format!("{}{}", self.base_url, path);
296 let body = body.clone();
297 self.execute_with_retry(|| async {
298 Ok(self.http_client.put(&url).json(&body).send().await?)
299 })
300 .await
301 }
302
303 #[allow(dead_code)]
305 pub(crate) async fn patch(
306 &self,
307 path: &str,
308 body: &serde_json::Value,
309 ) -> Result<reqwest::Response> {
310 let url = format!("{}{}", self.base_url, path);
311 let body = body.clone();
312 self.execute_with_retry(|| async {
313 Ok(self.http_client.patch(&url).json(&body).send().await?)
314 })
315 .await
316 }
317
318 pub(crate) async fn delete(&self, path: &str) -> Result<reqwest::Response> {
320 let url = format!("{}{}", self.base_url, path);
321 self.execute_with_retry(|| async { Ok(self.http_client.delete(&url).send().await?) })
322 .await
323 }
324
325 pub(crate) async fn handle_response<T>(response: reqwest::Response) -> Result<T>
327 where
328 T: serde::de::DeserializeOwned,
329 {
330 let status = response.status();
331
332 if status.as_u16() == 429 {
334 let retry_after = response
335 .headers()
336 .get("retry-after")
337 .and_then(|v| v.to_str().ok())
338 .and_then(|v| v.parse().ok());
339
340 return Err(Error::RateLimited { retry_after });
341 }
342
343 if status.as_u16() == 401 || status.as_u16() == 403 {
345 let body = response.text().await?;
346 return Err(Error::Unauthorized(format!(
347 "Authentication failed: {}",
348 body
349 )));
350 }
351
352 let body = response.text().await?;
353
354 if !status.is_success() {
356 return Err(Error::Api(crate::error::ApiError::from_response(
357 status.as_u16(),
358 &body,
359 )));
360 }
361
362 let api_response: crate::types::ApiResponse<T> = serde_json::from_str(&body)?;
364
365 if !api_response.success {
367 let error_msg = api_response
368 .errors
369 .first()
370 .map(|e| e.message.clone())
371 .unwrap_or_else(|| "Unknown error".to_string());
372
373 return Err(Error::Api(crate::error::ApiError::new(
374 status.as_u16(),
375 error_msg,
376 body,
377 )));
378 }
379
380 api_response
382 .result
383 .ok_or_else(|| Error::InvalidInput("No result in API response".to_string()))
384 }
385}
386
387pub struct CloudflareClientBuilder {
389 api_token: Option<String>,
390 base_url: Option<String>,
391 timeout: Option<std::time::Duration>,
392 retry_config: Option<RetryConfig>,
393}
394
395impl CloudflareClientBuilder {
396 pub fn new() -> Self {
398 Self {
399 api_token: None,
400 base_url: None,
401 timeout: None,
402 retry_config: None,
403 }
404 }
405
406 pub fn api_token(mut self, token: impl Into<String>) -> Self {
419 self.api_token = Some(token.into());
420 self
421 }
422
423 pub fn base_url(mut self, url: impl Into<String>) -> Self {
427 self.base_url = Some(url.into());
428 self
429 }
430
431 pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
435 self.timeout = Some(timeout);
436 self
437 }
438
439 pub fn retry_config(mut self, config: RetryConfig) -> Self {
468 self.retry_config = Some(config);
469 self
470 }
471
472 pub fn build(self) -> Result<CloudflareClient> {
479 let api_token = self
480 .api_token
481 .ok_or_else(|| Error::InvalidInput("API token is required".to_string()))?;
482
483 let base_url = self
484 .base_url
485 .unwrap_or_else(|| "https://api.cloudflare.com/client/v4".to_string());
486
487 let timeout = self
488 .timeout
489 .unwrap_or_else(|| std::time::Duration::from_secs(30));
490
491 let retry_config = self.retry_config.unwrap_or_default();
492
493 let mut headers = HeaderMap::new();
495 headers.insert(
496 AUTHORIZATION,
497 HeaderValue::from_str(&format!("Bearer {}", api_token))
498 .map_err(|_| Error::InvalidInput("Invalid API token format".to_string()))?,
499 );
500 headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
501
502 let http_client = reqwest::Client::builder()
504 .default_headers(headers)
505 .timeout(timeout)
506 .build()?;
507
508 Ok(CloudflareClient {
509 http_client,
510 api_token,
511 base_url,
512 retry_config,
513 })
514 }
515}
516
517impl Default for CloudflareClientBuilder {
518 fn default() -> Self {
519 Self::new()
520 }
521}