lmrc_cloudflare/
client.rs

1//! Core Cloudflare API client.
2
3use 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/// The main Cloudflare API client.
10///
11/// This client provides access to various Cloudflare services through
12/// a unified interface. Use the builder pattern to create a client instance.
13///
14/// # Examples
15///
16/// ```no_run
17/// use lmrc_cloudflare::CloudflareClient;
18///
19/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
20/// let client = CloudflareClient::builder()
21///     .api_token("your-api-token")
22///     .build()?;
23///
24/// // Access DNS service
25/// let records = client.dns().list_records("zone_id").send().await?;
26///
27/// // Access zones service
28/// let zones = client.zones().list().send().await?;
29/// # Ok(())
30/// # }
31/// ```
32#[derive(Clone)]
33pub struct CloudflareClient {
34    /// Internal HTTP client
35    pub(crate) http_client: reqwest::Client,
36
37    /// API token for authentication
38    #[allow(dead_code)]
39    pub(crate) api_token: String,
40
41    /// Base URL for the API (usually not changed)
42    pub base_url: String,
43
44    /// Retry configuration
45    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/// Configuration for retry behavior.
58#[derive(Clone, Debug)]
59pub struct RetryConfig {
60    /// Maximum number of retry attempts (0 = no retries)
61    pub max_retries: u32,
62
63    /// Initial delay between retries
64    pub initial_delay: std::time::Duration,
65
66    /// Maximum delay between retries
67    pub max_delay: std::time::Duration,
68
69    /// Exponential backoff multiplier
70    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    /// Create a new retry configuration with no retries.
86    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    /// Calculate the delay for a given retry attempt.
96    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    /// Create a new client builder.
111    ///
112    /// # Examples
113    ///
114    /// ```no_run
115    /// use lmrc_cloudflare::CloudflareClient;
116    ///
117    /// let client = CloudflareClient::builder()
118    ///     .api_token("your-api-token")
119    ///     .build()?;
120    /// # Ok::<(), lmrc_cloudflare::Error>(())
121    /// ```
122    pub fn builder() -> CloudflareClientBuilder {
123        CloudflareClientBuilder::new()
124    }
125
126    /// Create a client with just an API token (using default settings).
127    ///
128    /// # Examples
129    ///
130    /// ```no_run
131    /// use lmrc_cloudflare::CloudflareClient;
132    ///
133    /// let client = CloudflareClient::new("your-api-token")?;
134    /// # Ok::<(), lmrc_cloudflare::Error>(())
135    /// ```
136    pub fn new(api_token: impl Into<String>) -> Result<Self> {
137        Self::builder().api_token(api_token).build()
138    }
139
140    /// Get the DNS service for managing DNS records.
141    ///
142    /// # Examples
143    ///
144    /// ```no_run
145    /// # use lmrc_cloudflare::CloudflareClient;
146    /// # async fn example(client: CloudflareClient) -> Result<(), lmrc_cloudflare::Error> {
147    /// let records = client.dns()
148    ///     .list_records("zone_id")
149    ///     .send()
150    ///     .await?;
151    /// # Ok(())
152    /// # }
153    /// ```
154    pub fn dns(&self) -> DnsService {
155        DnsService::new(self.clone())
156    }
157
158    /// Get the zones service for managing zones.
159    ///
160    /// # Examples
161    ///
162    /// ```no_run
163    /// # use lmrc_cloudflare::CloudflareClient;
164    /// # async fn example(client: CloudflareClient) -> Result<(), lmrc_cloudflare::Error> {
165    /// let zones = client.zones()
166    ///     .list()
167    ///     .send()
168    ///     .await?;
169    /// # Ok(())
170    /// # }
171    /// ```
172    pub fn zones(&self) -> ZoneService {
173        ZoneService::new(self.clone())
174    }
175
176    /// Get the cache service for purging cache.
177    ///
178    /// # Examples
179    ///
180    /// ```no_run
181    /// # use lmrc_cloudflare::CloudflareClient;
182    /// # async fn example(client: CloudflareClient) -> Result<(), lmrc_cloudflare::Error> {
183    /// client.cache()
184    ///     .purge_everything("zone_id")
185    ///     .await?;
186    /// # Ok(())
187    /// # }
188    /// ```
189    pub fn cache(&self) -> CacheService {
190        CacheService::new(self.clone())
191    }
192
193    /// Execute a request with retry logic.
194    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                    // Check for rate limiting
207                    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                    // For 5xx errors, retry
229                    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                    // For network errors, retry
239                    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    /// Make a GET request to the Cloudflare API.
255    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    /// Make a GET request with query parameters.
262    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(&params).send().await?)
271        })
272        .await
273    }
274
275    /// Make a POST request to the Cloudflare API.
276    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    /// Make a PUT request to the Cloudflare API.
290    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    /// Make a PATCH request to the Cloudflare API.
304    #[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    /// Make a DELETE request to the Cloudflare API.
319    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    /// Handle API response and check for errors.
326    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        // Check for rate limiting
333        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        // Check for authentication errors
344        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        // Check for non-success status
355        if !status.is_success() {
356            return Err(Error::Api(crate::error::ApiError::from_response(
357                status.as_u16(),
358                &body,
359            )));
360        }
361
362        // Parse the response
363        let api_response: crate::types::ApiResponse<T> = serde_json::from_str(&body)?;
364
365        // Check if API reported success
366        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        // Return the result
381        api_response
382            .result
383            .ok_or_else(|| Error::InvalidInput("No result in API response".to_string()))
384    }
385}
386
387/// Builder for creating a CloudflareClient.
388pub 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    /// Create a new builder.
397    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    /// Set the API token for authentication.
407    ///
408    /// # Examples
409    ///
410    /// ```no_run
411    /// use lmrc_cloudflare::CloudflareClient;
412    ///
413    /// let client = CloudflareClient::builder()
414    ///     .api_token("your-api-token")
415    ///     .build()?;
416    /// # Ok::<(), lmrc_cloudflare::Error>(())
417    /// ```
418    pub fn api_token(mut self, token: impl Into<String>) -> Self {
419        self.api_token = Some(token.into());
420        self
421    }
422
423    /// Set a custom base URL (advanced usage, usually not needed).
424    ///
425    /// Defaults to `https://api.cloudflare.com/client/v4`.
426    pub fn base_url(mut self, url: impl Into<String>) -> Self {
427        self.base_url = Some(url.into());
428        self
429    }
430
431    /// Set the request timeout.
432    ///
433    /// Defaults to 30 seconds.
434    pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
435        self.timeout = Some(timeout);
436        self
437    }
438
439    /// Set the retry configuration.
440    ///
441    /// By default, the client will retry failed requests up to 3 times with exponential backoff.
442    ///
443    /// # Examples
444    ///
445    /// ```no_run
446    /// use lmrc_cloudflare::{CloudflareClient, RetryConfig};
447    ///
448    /// // Disable retries
449    /// let client = CloudflareClient::builder()
450    ///     .api_token("your-api-token")
451    ///     .retry_config(RetryConfig::disabled())
452    ///     .build()?;
453    ///
454    /// // Custom retry configuration
455    /// let config = RetryConfig {
456    ///     max_retries: 5,
457    ///     initial_delay: std::time::Duration::from_millis(1000),
458    ///     max_delay: std::time::Duration::from_secs(60),
459    ///     backoff_multiplier: 2.0,
460    /// };
461    /// let client = CloudflareClient::builder()
462    ///     .api_token("your-api-token")
463    ///     .retry_config(config)
464    ///     .build()?;
465    /// # Ok::<(), lmrc_cloudflare::Error>(())
466    /// ```
467    pub fn retry_config(mut self, config: RetryConfig) -> Self {
468        self.retry_config = Some(config);
469        self
470    }
471
472    /// Build the CloudflareClient.
473    ///
474    /// # Errors
475    ///
476    /// Returns an error if the API token is not set or if the HTTP client
477    /// cannot be created.
478    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        // Build headers
494        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        // Build HTTP client
503        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}