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