Skip to main content

cloudflare_dns/api/
client.rs

1/// Cloudflare API client implementation.
2///
3/// This module provides an async client for interacting with the Cloudflare API v4.
4/// It uses blocking reqwest wrapped in `smol::unblock` to avoid runtime conflicts
5/// with iocraft's internal smol executor.
6use reqwest::blocking::Client;
7use std::sync::Arc;
8
9use super::error::{CloudflareError, CloudflareResult};
10use super::models::{ApiResponse, DnsRecord, DnsRecordResponse, ZoneResponse};
11
12/// Internal client state wrapped in Arc for thread-safe sharing.
13struct Inner {
14    client: Client,
15    api_token: String,
16    zone_id: String,
17    base_url: String,
18}
19
20/// Cloudflare API client for DNS record management.
21///
22/// This client provides methods to list, create, update, and delete DNS records
23/// in a Cloudflare zone. It's designed to be cheaply cloneable (via Arc) for use
24/// across async tasks.
25///
26/// # Example
27/// ```ignore
28/// let client = CloudflareClient::new(api_token, zone_id);
29/// let records = client.list_dns_records().await?;
30/// ```
31pub struct CloudflareClient {
32    inner: Arc<Inner>,
33}
34
35impl CloudflareClient {
36    /// Create a new Cloudflare API client.
37    ///
38    /// # Arguments
39    /// * `api_token` - Cloudflare API token with DNS edit permissions
40    /// * `zone_id` - The Cloudflare zone ID for the domain to manage
41    pub fn new(api_token: String, zone_id: String) -> Self {
42        Self::with_base_url(
43            api_token,
44            zone_id,
45            "https://api.cloudflare.com/client/v4".to_string(),
46        )
47    }
48
49    /// Create a client with a custom base URL (useful for testing with mock servers).
50    pub fn with_base_url(api_token: String, zone_id: String, base_url: String) -> Self {
51        Self {
52            inner: Arc::new(Inner {
53                client: Client::builder()
54                    .timeout(std::time::Duration::from_secs(30))
55                    .build()
56                    .expect("Failed to create HTTP client"),
57                api_token,
58                zone_id,
59                base_url,
60            }),
61        }
62    }
63
64    /// Fetch the zone name for the configured zone ID.
65    ///
66    /// Returns the zone name if successful, or falls back to the zone ID
67    /// if the API request fails.
68    pub async fn get_zone_name(&self) -> Result<String, CloudflareError> {
69        let inner = self.inner.clone();
70        smol::unblock(move || {
71            let url = format!("{}/zones/{}", inner.base_url, inner.zone_id);
72            let response = inner
73                .client
74                .get(&url)
75                .header("Authorization", format!("Bearer {}", inner.api_token))
76                .header("Content-Type", "application/json")
77                .send()
78                .map_err(CloudflareError::RequestError)?;
79
80            if !response.status().is_success() {
81                return Ok(inner.zone_id.clone());
82            }
83
84            let api_response: ApiResponse<ZoneResponse> =
85                response.json().map_err(CloudflareError::RequestError)?;
86
87            if let Some(zone) = api_response.result {
88                Ok(zone.name)
89            } else {
90                Ok(inner.zone_id.clone())
91            }
92        })
93        .await
94    }
95
96    /// List all DNS records in the configured zone.
97    ///
98    /// Automatically handles pagination by fetching all pages of records.
99    pub async fn list_dns_records(&self) -> CloudflareResult<Vec<DnsRecord>> {
100        let inner = self.inner.clone();
101        smol::unblock(move || {
102            let mut all_records = Vec::new();
103            let mut page = 1;
104            let per_page = 100;
105
106            loop {
107                let url = format!(
108                    "{}/zones/{}/dns_records?page={}&per_page={}",
109                    inner.base_url, inner.zone_id, page, per_page
110                );
111
112                let response = inner
113                    .client
114                    .get(&url)
115                    .header("Authorization", format!("Bearer {}", inner.api_token))
116                    .header("Content-Type", "application/json")
117                    .send()
118                    .map_err(CloudflareError::RequestError)?;
119
120                if !response.status().is_success() {
121                    let status = response.status().as_u16();
122                    let body = response.text().unwrap_or_default();
123                    return Err(CloudflareError::HttpError { status, body });
124                }
125
126                let api_response: ApiResponse<Vec<DnsRecordResponse>> =
127                    response.json().map_err(CloudflareError::RequestError)?;
128
129                if !api_response.success {
130                    let errors: Vec<String> = api_response
131                        .errors
132                        .iter()
133                        .map(|e| format!("{} (code: {})", e.message, e.code))
134                        .collect();
135                    return Err(CloudflareError::ApiErrors(errors));
136                }
137
138                let records = api_response.result.unwrap_or_default();
139                let fetched_count = records.len();
140                if fetched_count == 0 {
141                    break;
142                }
143
144                all_records.extend(records.into_iter().map(|r| DnsRecord {
145                    id: Some(r.id),
146                    record_type: r.record_type,
147                    name: r.name,
148                    content: r.content,
149                    ttl: Some(r.ttl),
150                    proxied: Some(r.proxied),
151                    comment: r.comment,
152                }));
153
154                if fetched_count < per_page {
155                    break;
156                }
157                page += 1;
158            }
159
160            Ok(all_records)
161        })
162        .await
163    }
164
165    /// Create a new DNS record in the configured zone.
166    pub async fn create_dns_record(&self, record: &DnsRecord) -> CloudflareResult<DnsRecord> {
167        let inner = self.inner.clone();
168        let record = record.clone();
169
170        smol::unblock(move || {
171            let response = inner
172                .client
173                .post(format!(
174                    "{}/zones/{}/dns_records",
175                    inner.base_url, inner.zone_id
176                ))
177                .header("Authorization", format!("Bearer {}", inner.api_token))
178                .header("Content-Type", "application/json")
179                .json(&record)
180                .send()
181                .map_err(CloudflareError::RequestError)?;
182
183            if !response.status().is_success() {
184                let status = response.status().as_u16();
185                let body = response.text().unwrap_or_default();
186                return Err(CloudflareError::HttpError { status, body });
187            }
188
189            let api_response: ApiResponse<DnsRecordResponse> =
190                response.json().map_err(CloudflareError::RequestError)?;
191
192            if !api_response.success {
193                let errors: Vec<String> = api_response
194                    .errors
195                    .iter()
196                    .map(|e| format!("{} (code: {})", e.message, e.code))
197                    .collect();
198                return Err(CloudflareError::ApiErrors(errors));
199            }
200
201            let result = api_response.result.ok_or(CloudflareError::NoResult)?;
202
203            Ok(DnsRecord {
204                id: Some(result.id),
205                record_type: result.record_type,
206                name: result.name,
207                content: result.content,
208                ttl: Some(result.ttl),
209                proxied: Some(result.proxied),
210                comment: result.comment,
211            })
212        })
213        .await
214    }
215
216    /// Update an existing DNS record.
217    ///
218    /// The record must have an `id` field set, otherwise returns `MissingRecordId`.
219    pub async fn update_dns_record(&self, record: &DnsRecord) -> CloudflareResult<DnsRecord> {
220        let inner = self.inner.clone();
221        let record = record.clone();
222
223        smol::unblock(move || {
224            let record_id = record.id.as_ref().ok_or(CloudflareError::MissingRecordId)?;
225
226            let response = inner
227                .client
228                .put(format!(
229                    "{}/zones/{}/dns_records/{}",
230                    inner.base_url, inner.zone_id, record_id
231                ))
232                .header("Authorization", format!("Bearer {}", inner.api_token))
233                .header("Content-Type", "application/json")
234                .json(&record)
235                .send()
236                .map_err(CloudflareError::RequestError)?;
237
238            if !response.status().is_success() {
239                let status = response.status().as_u16();
240                let body = response.text().unwrap_or_default();
241                return Err(CloudflareError::HttpError { status, body });
242            }
243
244            let api_response: ApiResponse<DnsRecordResponse> =
245                response.json().map_err(CloudflareError::RequestError)?;
246
247            if !api_response.success {
248                let errors: Vec<String> = api_response
249                    .errors
250                    .iter()
251                    .map(|e| format!("{} (code: {})", e.message, e.code))
252                    .collect();
253                return Err(CloudflareError::ApiErrors(errors));
254            }
255
256            let result = api_response.result.ok_or(CloudflareError::NoResult)?;
257
258            Ok(DnsRecord {
259                id: Some(result.id),
260                record_type: result.record_type,
261                name: result.name,
262                content: result.content,
263                ttl: Some(result.ttl),
264                proxied: Some(result.proxied),
265                comment: result.comment,
266            })
267        })
268        .await
269    }
270
271    /// Delete a DNS record by its ID.
272    pub async fn delete_dns_record(&self, record_id: &str) -> CloudflareResult<()> {
273        let inner = self.inner.clone();
274        let record_id = record_id.to_string();
275
276        smol::unblock(move || {
277            let response = inner
278                .client
279                .delete(format!(
280                    "{}/zones/{}/dns_records/{}",
281                    inner.base_url, inner.zone_id, record_id
282                ))
283                .header("Authorization", format!("Bearer {}", inner.api_token))
284                .header("Content-Type", "application/json")
285                .send()
286                .map_err(CloudflareError::RequestError)?;
287
288            if !response.status().is_success() {
289                let status = response.status().as_u16();
290                let body = response.text().unwrap_or_default();
291                return Err(CloudflareError::HttpError { status, body });
292            }
293
294            let api_response: ApiResponse<serde_json::Value> =
295                response.json().map_err(CloudflareError::RequestError)?;
296
297            if !api_response.success {
298                let errors: Vec<String> = api_response
299                    .errors
300                    .iter()
301                    .map(|e| format!("{} (code: {})", e.message, e.code))
302                    .collect();
303                return Err(CloudflareError::ApiErrors(errors));
304            }
305
306            Ok(())
307        })
308        .await
309    }
310}