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), ¶ms)
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}