Skip to main content

lmrc_cloudflare/dns/
mod.rs

1//! DNS record management for Cloudflare.
2//!
3//! This module provides comprehensive DNS record management capabilities including:
4//! - Creating, reading, updating, and deleting DNS records
5//! - Listing and filtering records
6//! - Batch operations with diff output
7//! - Idempotent sync operations for CI/CD
8
9use crate::client::CloudflareClient;
10use crate::error::Result;
11use crate::types::Change;
12
13pub mod types;
14pub use types::{DnsRecord, DnsRecordBuilder, ListRecordsQuery, RecordType};
15
16/// Service for managing DNS records.
17///
18/// Obtain an instance via [`CloudflareClient::dns()`].
19#[derive(Clone)]
20pub struct DnsService {
21    client: CloudflareClient,
22}
23
24impl DnsService {
25    /// Create a new DNS service (internal use).
26    pub(crate) fn new(client: CloudflareClient) -> Self {
27        Self { client }
28    }
29
30    /// List DNS records for a zone.
31    ///
32    /// # Examples
33    ///
34    /// ```no_run
35    /// # use lmrc_cloudflare::CloudflareClient;
36    /// # use lmrc_cloudflare::dns::RecordType;
37    /// # async fn example(client: CloudflareClient) -> Result<(), lmrc_cloudflare::Error> {
38    /// // List all records
39    /// let all_records = client.dns()
40    ///     .list_records("zone_id")
41    ///     .send()
42    ///     .await?;
43    ///
44    /// // Filter by type
45    /// let a_records = client.dns()
46    ///     .list_records("zone_id")
47    ///     .record_type(RecordType::A)
48    ///     .send()
49    ///     .await?;
50    ///
51    /// // Filter by name and type
52    /// let records = client.dns()
53    ///     .list_records("zone_id")
54    ///     .name("api.example.com")
55    ///     .record_type(RecordType::A)
56    ///     .send()
57    ///     .await?;
58    /// # Ok(())
59    /// # }
60    /// ```
61    pub fn list_records(&self, zone_id: impl Into<String>) -> ListRecordsRequest {
62        ListRecordsRequest {
63            service: self.clone(),
64            zone_id: zone_id.into(),
65            query: ListRecordsQuery::new(),
66        }
67    }
68
69    /// Get a specific DNS record by ID.
70    ///
71    /// # Examples
72    ///
73    /// ```no_run
74    /// # use lmrc_cloudflare::CloudflareClient;
75    /// # async fn example(client: CloudflareClient) -> Result<(), lmrc_cloudflare::Error> {
76    /// let record = client.dns()
77    ///     .get_record("zone_id", "record_id")
78    ///     .await?;
79    /// # Ok(())
80    /// # }
81    /// ```
82    pub async fn get_record(
83        &self,
84        zone_id: impl Into<String>,
85        record_id: impl Into<String>,
86    ) -> Result<DnsRecord> {
87        let zone_id = zone_id.into();
88        let record_id = record_id.into();
89
90        let response = self
91            .client
92            .get(&format!("/zones/{}/dns_records/{}", zone_id, record_id))
93            .await?;
94
95        CloudflareClient::handle_response(response).await
96    }
97
98    /// Create a new DNS record.
99    ///
100    /// # Examples
101    ///
102    /// ```no_run
103    /// # use lmrc_cloudflare::CloudflareClient;
104    /// # use lmrc_cloudflare::dns::RecordType;
105    /// # async fn example(client: CloudflareClient) -> Result<(), lmrc_cloudflare::Error> {
106    /// let record = client.dns()
107    ///     .create_record("zone_id")
108    ///     .name("api")
109    ///     .record_type(RecordType::A)
110    ///     .content("192.0.2.1")
111    ///     .proxied(true)
112    ///     .ttl(1)
113    ///     .send()
114    ///     .await?;
115    /// # Ok(())
116    /// # }
117    /// ```
118    pub fn create_record(&self, zone_id: impl Into<String>) -> CreateRecordRequest {
119        CreateRecordRequest {
120            service: self.clone(),
121            zone_id: zone_id.into(),
122            builder: DnsRecordBuilder::new(),
123        }
124    }
125
126    /// Update an existing DNS record.
127    ///
128    /// # Examples
129    ///
130    /// ```no_run
131    /// # use lmrc_cloudflare::CloudflareClient;
132    /// # async fn example(client: CloudflareClient) -> Result<(), lmrc_cloudflare::Error> {
133    /// let record = client.dns()
134    ///     .update_record("zone_id", "record_id")
135    ///     .content("192.0.2.2")
136    ///     .proxied(false)
137    ///     .send()
138    ///     .await?;
139    /// # Ok(())
140    /// # }
141    /// ```
142    pub fn update_record(
143        &self,
144        zone_id: impl Into<String>,
145        record_id: impl Into<String>,
146    ) -> UpdateRecordRequest {
147        UpdateRecordRequest {
148            service: self.clone(),
149            zone_id: zone_id.into(),
150            record_id: record_id.into(),
151            builder: DnsRecordBuilder::new(),
152        }
153    }
154
155    /// Delete a DNS record.
156    ///
157    /// # Examples
158    ///
159    /// ```no_run
160    /// # use lmrc_cloudflare::CloudflareClient;
161    /// # async fn example(client: CloudflareClient) -> Result<(), lmrc_cloudflare::Error> {
162    /// client.dns()
163    ///     .delete_record("zone_id", "record_id")
164    ///     .await?;
165    /// # Ok(())
166    /// # }
167    /// ```
168    pub async fn delete_record(
169        &self,
170        zone_id: impl Into<String>,
171        record_id: impl Into<String>,
172    ) -> Result<()> {
173        let zone_id = zone_id.into();
174        let record_id = record_id.into();
175
176        let response = self
177            .client
178            .delete(&format!("/zones/{}/dns_records/{}", zone_id, record_id))
179            .await?;
180
181        let _: serde_json::Value = CloudflareClient::handle_response(response).await?;
182        Ok(())
183    }
184
185    /// Find a DNS record by name and type.
186    ///
187    /// Returns `None` if no matching record is found.
188    ///
189    /// # Examples
190    ///
191    /// ```no_run
192    /// # use lmrc_cloudflare::CloudflareClient;
193    /// # use lmrc_cloudflare::dns::RecordType;
194    /// # async fn example(client: CloudflareClient) -> Result<(), lmrc_cloudflare::Error> {
195    /// let record = client.dns()
196    ///     .find_record("zone_id", "api.example.com", RecordType::A)
197    ///     .await?;
198    /// # Ok(())
199    /// # }
200    /// ```
201    pub async fn find_record(
202        &self,
203        zone_id: impl Into<String>,
204        name: impl Into<String>,
205        record_type: RecordType,
206    ) -> Result<Option<DnsRecord>> {
207        let name = name.into();
208        let records = self
209            .list_records(zone_id)
210            .name(&name)
211            .record_type(record_type)
212            .send()
213            .await?;
214
215        Ok(records.into_iter().find(|r| r.matches(&name, record_type)))
216    }
217
218    /// Sync DNS records with desired state, showing diff of changes.
219    ///
220    /// This is an idempotent operation that will:
221    /// - Create records that don't exist
222    /// - Update records that have changed
223    /// - Leave unchanged records alone
224    ///
225    /// Returns a list of changes that were made or would be made.
226    ///
227    /// # Examples
228    ///
229    /// ```no_run
230    /// # use lmrc_cloudflare::CloudflareClient;
231    /// # use lmrc_cloudflare::dns::{RecordType, DnsRecordBuilder};
232    /// # async fn example(client: CloudflareClient) -> Result<(), lmrc_cloudflare::Error> {
233    /// let desired_records = vec![
234    ///     DnsRecordBuilder::new()
235    ///         .name("api.example.com")
236    ///         .record_type(RecordType::A)
237    ///         .content("192.0.2.1")
238    ///         .proxied(true),
239    ///     DnsRecordBuilder::new()
240    ///         .name("www.example.com")
241    ///         .record_type(RecordType::CNAME)
242    ///         .content("example.com")
243    ///         .proxied(true),
244    /// ];
245    ///
246    /// let changes = client.dns()
247    ///     .sync_records("zone_id")
248    ///     .records(desired_records)
249    ///     .dry_run(false)
250    ///     .send()
251    ///     .await?;
252    ///
253    /// for change in changes {
254    ///     println!("{:?}: {}", change.action, change.description);
255    /// }
256    /// # Ok(())
257    /// # }
258    /// ```
259    pub fn sync_records(&self, zone_id: impl Into<String>) -> SyncRecordsRequest {
260        SyncRecordsRequest {
261            service: self.clone(),
262            zone_id: zone_id.into(),
263            records: Vec::new(),
264            dry_run: false,
265        }
266    }
267}
268
269/// Request builder for listing DNS records.
270pub struct ListRecordsRequest {
271    service: DnsService,
272    zone_id: String,
273    query: ListRecordsQuery,
274}
275
276impl ListRecordsRequest {
277    /// Filter by record type.
278    pub fn record_type(mut self, record_type: RecordType) -> Self {
279        self.query = self.query.record_type(record_type);
280        self
281    }
282
283    /// Filter by record name.
284    pub fn name(mut self, name: impl Into<String>) -> Self {
285        self.query = self.query.name(name);
286        self
287    }
288
289    /// Filter by content.
290    pub fn content(mut self, content: impl Into<String>) -> Self {
291        self.query = self.query.content(content);
292        self
293    }
294
295    /// Set page number for pagination.
296    pub fn page(mut self, page: u32) -> Self {
297        self.query = self.query.page(page);
298        self
299    }
300
301    /// Set number of results per page.
302    pub fn per_page(mut self, per_page: u32) -> Self {
303        self.query = self.query.per_page(per_page);
304        self
305    }
306
307    /// Send the request.
308    pub async fn send(self) -> Result<Vec<DnsRecord>> {
309        let params = self.query.build_params();
310        let response = self
311            .service
312            .client
313            .get_with_params(&format!("/zones/{}/dns_records", self.zone_id), &params)
314            .await?;
315
316        CloudflareClient::handle_response(response).await
317    }
318}
319
320/// Request builder for creating DNS records.
321pub struct CreateRecordRequest {
322    service: DnsService,
323    zone_id: String,
324    builder: DnsRecordBuilder,
325}
326
327impl CreateRecordRequest {
328    /// Set the record name.
329    pub fn name(mut self, name: impl Into<String>) -> Self {
330        self.builder = self.builder.name(name);
331        self
332    }
333
334    /// Set the record type.
335    pub fn record_type(mut self, record_type: RecordType) -> Self {
336        self.builder = self.builder.record_type(record_type);
337        self
338    }
339
340    /// Set the record content/value.
341    pub fn content(mut self, content: impl Into<String>) -> Self {
342        self.builder = self.builder.content(content);
343        self
344    }
345
346    /// Set whether the record should be proxied.
347    pub fn proxied(mut self, proxied: bool) -> Self {
348        self.builder = self.builder.proxied(proxied);
349        self
350    }
351
352    /// Set the TTL.
353    pub fn ttl(mut self, ttl: u32) -> Self {
354        self.builder = self.builder.ttl(ttl);
355        self
356    }
357
358    /// Set a comment.
359    pub fn comment(mut self, comment: impl Into<String>) -> Self {
360        self.builder = self.builder.comment(comment);
361        self
362    }
363
364    /// Set the priority (for MX, SRV records).
365    pub fn priority(mut self, priority: u16) -> Self {
366        self.builder = self.builder.priority(priority);
367        self
368    }
369
370    /// Send the request.
371    pub async fn send(self) -> Result<DnsRecord> {
372        self.builder.validate_create()?;
373
374        let payload = self.builder.build_payload();
375        let response = self
376            .service
377            .client
378            .post(&format!("/zones/{}/dns_records", self.zone_id), &payload)
379            .await?;
380
381        CloudflareClient::handle_response(response).await
382    }
383}
384
385/// Request builder for updating DNS records.
386pub struct UpdateRecordRequest {
387    service: DnsService,
388    zone_id: String,
389    record_id: String,
390    builder: DnsRecordBuilder,
391}
392
393impl UpdateRecordRequest {
394    /// Set the record name.
395    pub fn name(mut self, name: impl Into<String>) -> Self {
396        self.builder = self.builder.name(name);
397        self
398    }
399
400    /// Set the record type.
401    pub fn record_type(mut self, record_type: RecordType) -> Self {
402        self.builder = self.builder.record_type(record_type);
403        self
404    }
405
406    /// Set the record content/value.
407    pub fn content(mut self, content: impl Into<String>) -> Self {
408        self.builder = self.builder.content(content);
409        self
410    }
411
412    /// Set whether the record should be proxied.
413    pub fn proxied(mut self, proxied: bool) -> Self {
414        self.builder = self.builder.proxied(proxied);
415        self
416    }
417
418    /// Set the TTL.
419    pub fn ttl(mut self, ttl: u32) -> Self {
420        self.builder = self.builder.ttl(ttl);
421        self
422    }
423
424    /// Set a comment.
425    pub fn comment(mut self, comment: impl Into<String>) -> Self {
426        self.builder = self.builder.comment(comment);
427        self
428    }
429
430    /// Set the priority (for MX, SRV records).
431    pub fn priority(mut self, priority: u16) -> Self {
432        self.builder = self.builder.priority(priority);
433        self
434    }
435
436    /// Send the request.
437    pub async fn send(self) -> Result<DnsRecord> {
438        let payload = self.builder.build_payload();
439        let response = self
440            .service
441            .client
442            .put(
443                &format!("/zones/{}/dns_records/{}", self.zone_id, self.record_id),
444                &payload,
445            )
446            .await?;
447
448        CloudflareClient::handle_response(response).await
449    }
450}
451
452/// Request builder for syncing DNS records.
453pub struct SyncRecordsRequest {
454    service: DnsService,
455    zone_id: String,
456    records: Vec<DnsRecordBuilder>,
457    dry_run: bool,
458}
459
460impl SyncRecordsRequest {
461    /// Set the desired records.
462    pub fn records(mut self, records: Vec<DnsRecordBuilder>) -> Self {
463        self.records = records;
464        self
465    }
466
467    /// Add a single desired record.
468    pub fn add_record(mut self, record: DnsRecordBuilder) -> Self {
469        self.records.push(record);
470        self
471    }
472
473    /// Set whether this is a dry run (don't actually make changes).
474    pub fn dry_run(mut self, dry_run: bool) -> Self {
475        self.dry_run = dry_run;
476        self
477    }
478
479    /// Send the request and return the list of changes.
480    pub async fn send(self) -> Result<Vec<Change<DnsRecord>>> {
481        let mut changes = Vec::new();
482
483        // Get all existing records for this zone
484        let existing_records = self.service.list_records(&self.zone_id).send().await?;
485
486        for desired in &self.records {
487            desired.validate_create()?;
488
489            let name = desired.name.as_ref().unwrap();
490            let record_type = desired.record_type.unwrap();
491            let content = desired.content.as_ref().unwrap();
492
493            // Find existing record
494            let existing = existing_records
495                .iter()
496                .find(|r| r.matches(name, record_type));
497
498            match existing {
499                Some(existing_record) => {
500                    // Check if update is needed
501                    if existing_record.needs_update(desired) {
502                        let change = Change::update(
503                            existing_record.clone(),
504                            existing_record.clone(), // Would be updated version
505                            format!(
506                                "Update {} record: {} -> {}",
507                                record_type.as_str(),
508                                name,
509                                content
510                            ),
511                        );
512                        changes.push(change);
513
514                        if !self.dry_run {
515                            self.service
516                                .update_record(&self.zone_id, &existing_record.id)
517                                .content(content)
518                                .proxied(desired.proxied.unwrap_or(existing_record.proxied))
519                                .ttl(desired.ttl.unwrap_or(existing_record.ttl))
520                                .send()
521                                .await?;
522                        }
523                    } else {
524                        let change = Change::no_change(
525                            existing_record.clone(),
526                            format!("{} record already correct: {}", record_type.as_str(), name),
527                        );
528                        changes.push(change);
529                    }
530                }
531                None => {
532                    // Create new record
533                    let change = Change::create(
534                        DnsRecord {
535                            id: String::new(),
536                            record_type: record_type.as_str().to_string(),
537                            name: name.clone(),
538                            content: content.clone(),
539                            proxied: desired.proxied.unwrap_or(true),
540                            ttl: desired.ttl.unwrap_or(1),
541                            zone_id: Some(self.zone_id.clone()),
542                            zone_name: None,
543                            created_on: None,
544                            modified_on: None,
545                            comment: desired.comment.clone(),
546                            priority: desired.priority,
547                        },
548                        format!(
549                            "Create {} record: {} -> {}",
550                            record_type.as_str(),
551                            name,
552                            content
553                        ),
554                    );
555                    changes.push(change);
556
557                    if !self.dry_run {
558                        self.service
559                            .create_record(&self.zone_id)
560                            .name(name)
561                            .record_type(record_type)
562                            .content(content)
563                            .proxied(desired.proxied.unwrap_or(true))
564                            .ttl(desired.ttl.unwrap_or(1))
565                            .send()
566                            .await?;
567                    }
568                }
569            }
570        }
571
572        Ok(changes)
573    }
574}