Skip to main content

aws_lite_rs/api/
route53.rs

1//! Amazon Route 53 API client.
2//!
3//! Thin wrapper over generated ops. All URL construction and HTTP methods
4//! are in `ops::route53::Route53Ops`. This layer adds:
5//! - Ergonomic method signatures
6
7use serde::Serialize;
8
9use crate::{
10    AwsHttpClient, Result,
11    ops::route53::Route53Ops,
12    types::route53::{
13        AliasTarget, Change, ChangeAction, ChangeResourceRecordSetsRequest,
14        ChangeResourceRecordSetsResponse, CreateHealthCheckRequest, CreateHealthCheckResponse,
15        GetHealthCheckStatusResponse, ListHealthChecksResponse, ListHostedZonesResponse,
16        ListResourceRecordSetsResponse, RRType, ResourceRecord,
17    },
18};
19
20// ── Local XML serialization helpers for ChangeResourceRecordSets ──────────────
21//
22// quick_xml serializes `Vec<Change>` as repeated <Changes>item</Changes> tags:
23//   <Changes><Action>CREATE</Action>...</Changes>
24// But Route53 requires wrapped elements:
25//   <Changes><Change><Action>CREATE</Action>...</Change></Changes>
26//
27// Similarly ResourceRecords needs:
28//   <ResourceRecords><ResourceRecord><Value>x</Value></ResourceRecord></ResourceRecords>
29//
30// These local structs produce the correct nesting.  They mirror the public
31// `types::route53` structs but have an extra level of wrapping for the lists.
32
33#[derive(Serialize)]
34#[serde(rename_all = "PascalCase")]
35struct XmlResourceRecord<'a> {
36    value: &'a str,
37}
38
39#[derive(Serialize)]
40#[serde(rename_all = "PascalCase")]
41struct XmlResourceRecordsWrapper<'a> {
42    resource_record: Vec<XmlResourceRecord<'a>>,
43}
44
45#[derive(Serialize)]
46#[serde(rename_all = "PascalCase")]
47struct XmlResourceRecordSet<'a> {
48    name: &'a str,
49    #[serde(rename = "Type", skip_serializing_if = "Option::is_none")]
50    r#type: Option<RRType>,
51    #[serde(rename = "TTL", skip_serializing_if = "Option::is_none")]
52    ttl: Option<i64>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    resource_records: Option<XmlResourceRecordsWrapper<'a>>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    alias_target: Option<&'a AliasTarget>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    health_check_id: Option<&'a str>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    set_identifier: Option<&'a str>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    weight: Option<i64>,
63}
64
65#[derive(Serialize)]
66#[serde(rename_all = "PascalCase")]
67struct XmlChange<'a> {
68    #[serde(skip_serializing_if = "Option::is_none")]
69    action: Option<ChangeAction>,
70    resource_record_set: XmlResourceRecordSet<'a>,
71}
72
73#[derive(Serialize)]
74#[serde(rename_all = "PascalCase")]
75struct XmlChangesWrapper<'a> {
76    change: Vec<XmlChange<'a>>,
77}
78
79#[derive(Serialize)]
80#[serde(rename_all = "PascalCase")]
81struct XmlChangeBatch<'a> {
82    #[serde(skip_serializing_if = "Option::is_none")]
83    comment: Option<&'a str>,
84    changes: XmlChangesWrapper<'a>,
85}
86
87#[derive(Serialize)]
88#[serde(rename = "ChangeResourceRecordSetsRequest", rename_all = "PascalCase")]
89struct XmlChangeResourceRecordSetsRequest<'a> {
90    change_batch: XmlChangeBatch<'a>,
91}
92
93fn build_change_rrset_xml(body: &ChangeResourceRecordSetsRequest) -> Result<String> {
94    let xml_changes: Vec<XmlChange<'_>> = body
95        .change_batch
96        .changes
97        .iter()
98        .map(|c: &Change| {
99            let rrs = &c.resource_record_set;
100            let resource_records = if rrs.resource_records.is_empty() {
101                None
102            } else {
103                Some(XmlResourceRecordsWrapper {
104                    resource_record: rrs
105                        .resource_records
106                        .iter()
107                        .map(|r: &ResourceRecord| XmlResourceRecord { value: &r.value })
108                        .collect(),
109                })
110            };
111            XmlChange {
112                action: c.action,
113                resource_record_set: XmlResourceRecordSet {
114                    name: &rrs.name,
115                    r#type: rrs.r#type,
116                    ttl: rrs.ttl,
117                    resource_records,
118                    alias_target: rrs.alias_target.as_ref(),
119                    health_check_id: rrs.health_check_id.as_deref(),
120                    set_identifier: rrs.set_identifier.as_deref(),
121                    weight: rrs.weight,
122                },
123            }
124        })
125        .collect();
126
127    let xml_body = XmlChangeResourceRecordSetsRequest {
128        change_batch: XmlChangeBatch {
129            comment: body.change_batch.comment.as_deref(),
130            changes: XmlChangesWrapper {
131                change: xml_changes,
132            },
133        },
134    };
135
136    quick_xml::se::to_string(&xml_body).map_err(|e| crate::AwsError::InvalidResponse {
137        message: format!("Failed to serialize ChangeResourceRecordSets request to XML: {e}"),
138        body: None,
139    })
140}
141
142/// Client for the Amazon Route 53 API
143pub struct Route53Client<'a> {
144    ops: Route53Ops<'a>,
145}
146
147impl<'a> Route53Client<'a> {
148    /// Create a new Amazon Route 53 API client
149    pub(crate) fn new(client: &'a AwsHttpClient) -> Self {
150        Self {
151            ops: Route53Ops::new(client),
152        }
153    }
154
155    /// Retrieves a list of the public and private hosted zones associated with the current AWS account.
156    pub async fn list_hosted_zones(&self) -> Result<ListHostedZonesResponse> {
157        self.ops.list_hosted_zones().await
158    }
159
160    /// Lists the resource record sets in a specified hosted zone.
161    pub async fn list_resource_record_sets(
162        &self,
163        id: &str,
164    ) -> Result<ListResourceRecordSetsResponse> {
165        self.ops.list_resource_record_sets(id).await
166    }
167
168    /// Retrieve a list of the health checks associated with the current AWS account.
169    pub async fn list_health_checks(&self) -> Result<ListHealthChecksResponse> {
170        self.ops.list_health_checks().await
171    }
172
173    /// Gets status of a health check based on the most recent checker observations.
174    pub async fn get_health_check_status(
175        &self,
176        health_check_id: &str,
177    ) -> Result<GetHealthCheckStatusResponse> {
178        self.ops.get_health_check_status(health_check_id).await
179    }
180
181    /// Creates a new health check.
182    pub async fn create_health_check(
183        &self,
184        body: &CreateHealthCheckRequest,
185    ) -> Result<CreateHealthCheckResponse> {
186        self.ops.create_health_check(body).await
187    }
188
189    /// Deletes a health check.
190    pub async fn delete_health_check(&self, health_check_id: &str) -> Result<()> {
191        self.ops.delete_health_check(health_check_id).await
192    }
193
194    /// Creates, changes, or deletes a resource record set.
195    ///
196    /// Route53 requires the `Changes` list to be wrapped with individual `<Change>` elements:
197    /// `<Changes><Change>...</Change></Changes>`. This method constructs that XML manually
198    /// because quick_xml's default Vec serialization produces flat repeated tags.
199    pub async fn change_resource_record_sets(
200        &self,
201        id: &str,
202        body: &ChangeResourceRecordSetsRequest,
203    ) -> Result<ChangeResourceRecordSetsResponse> {
204        use urlencoding::encode;
205        #[cfg(any(test, feature = "test-support"))]
206        let base_url = self
207            .ops
208            .client
209            .base_url
210            .as_deref()
211            .map(|u| u.trim_end_matches('/'))
212            .unwrap_or("https://route53.amazonaws.com");
213        #[cfg(not(any(test, feature = "test-support")))]
214        let base_url = "https://route53.amazonaws.com";
215        let url = format!("{base_url}/2013-04-01/hostedzone/{}/rrset/", encode(id));
216        let body_xml = build_change_rrset_xml(body)?;
217        let body_xml = crate::xml::inject_xml_namespace(
218            &body_xml,
219            "https://route53.amazonaws.com/doc/2013-04-01/",
220        );
221        let response = self
222            .ops
223            .client
224            .post(&url, "route53", body_xml.as_bytes(), "application/xml")
225            .await?;
226        let response = response.error_for_status("xml").await?;
227        let response_bytes =
228            response
229                .bytes()
230                .await
231                .map_err(|e| crate::AwsError::InvalidResponse {
232                    message: format!("Failed to read change_resource_record_sets response: {e}"),
233                    body: None,
234                })?;
235        let body_text =
236            std::str::from_utf8(&response_bytes).map_err(|e| crate::AwsError::InvalidResponse {
237                message: format!("Invalid UTF-8 in change_resource_record_sets response: {e}"),
238                body: None,
239            })?;
240        crate::xml::parse_rest_xml_response::<ChangeResourceRecordSetsResponse>(body_text).map_err(
241            |e| crate::AwsError::InvalidResponse {
242                message: format!("Failed to parse change_resource_record_sets XML response: {e}"),
243                body: Some(body_text.to_string()),
244            },
245        )
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    // Route53 REST-XML responses use PascalCase field names and namespace-stripped XML.
254    // Proven behavior from integration testing (2026-02-18):
255    // - Marker field is absent when there are no more pages (Option<String>)
256    // - IsTruncated is always present
257    // - MaxItems is always present
258    // - HostedZone.Id returns the full "/hostedzone/XXXXX" format
259    // - HealthCheck.Id is a UUID without prefix
260
261    #[tokio::test]
262    async fn test_list_hosted_zones() {
263        let mut mock = crate::MockClient::new();
264        let xml = r#"<?xml version="1.0"?>
265<ListHostedZonesResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
266  <HostedZones>
267    <HostedZone>
268      <Id>/hostedzone/Z08460922J0JNOMET4IK5</Id>
269      <Name>cloud-lite-test-ralph.internal.</Name>
270      <CallerReference>cloud-lite-test-ralph-1771394386</CallerReference>
271      <Config><PrivateZone>true</PrivateZone></Config>
272      <ResourceRecordSetCount>2</ResourceRecordSetCount>
273    </HostedZone>
274  </HostedZones>
275  <IsTruncated>false</IsTruncated>
276  <MaxItems>100</MaxItems>
277</ListHostedZonesResponse>"#;
278        mock.expect_get("/2013-04-01/hostedzone")
279            .returning_bytes(xml.as_bytes().to_vec());
280
281        let client = AwsHttpClient::from_mock(mock);
282        let r53 = client.route53();
283        let result = r53.list_hosted_zones().await.unwrap();
284
285        assert_eq!(result.hosted_zones.len(), 1);
286        assert_eq!(
287            result.hosted_zones[0].id,
288            "/hostedzone/Z08460922J0JNOMET4IK5"
289        );
290        assert_eq!(
291            result.hosted_zones[0].name,
292            "cloud-lite-test-ralph.internal."
293        );
294        assert_eq!(
295            result.hosted_zones[0].caller_reference,
296            "cloud-lite-test-ralph-1771394386"
297        );
298        assert_eq!(result.hosted_zones[0].resource_record_set_count, Some(2));
299        assert!(!result.is_truncated);
300        assert_eq!(result.max_items, "100");
301        assert_eq!(result.marker, None); // absent when no more pages
302    }
303
304    #[tokio::test]
305    async fn test_list_resource_record_sets() {
306        let mut mock = crate::MockClient::new();
307        let xml = r#"<?xml version="1.0"?>
308<ListResourceRecordSetsResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
309  <ResourceRecordSets>
310    <ResourceRecordSet>
311      <Name>cloud-lite-test-ralph.internal.</Name>
312      <Type>NS</Type>
313      <TTL>172800</TTL>
314      <ResourceRecords>
315        <ResourceRecord><Value>ns-1.awsdns-01.com.</Value></ResourceRecord>
316      </ResourceRecords>
317    </ResourceRecordSet>
318    <ResourceRecordSet>
319      <Name>cloud-lite-test-ralph.internal.</Name>
320      <Type>SOA</Type>
321      <TTL>900</TTL>
322      <ResourceRecords>
323        <ResourceRecord><Value>ns-1.awsdns-01.com.</Value></ResourceRecord>
324      </ResourceRecords>
325    </ResourceRecordSet>
326  </ResourceRecordSets>
327  <IsTruncated>false</IsTruncated>
328  <MaxItems>300</MaxItems>
329</ListResourceRecordSetsResponse>"#;
330        mock.expect_get("/2013-04-01/hostedzone/Z08460922J0JNOMET4IK5/rrset")
331            .returning_bytes(xml.as_bytes().to_vec());
332
333        let client = AwsHttpClient::from_mock(mock);
334        let r53 = client.route53();
335        let result = r53
336            .list_resource_record_sets("Z08460922J0JNOMET4IK5")
337            .await
338            .unwrap();
339
340        assert_eq!(result.resource_record_sets.len(), 2);
341        assert_eq!(
342            result.resource_record_sets[0].name,
343            "cloud-lite-test-ralph.internal."
344        );
345        assert!(!result.is_truncated);
346        assert_eq!(result.max_items, "300");
347    }
348
349    #[tokio::test]
350    async fn test_list_health_checks() {
351        let mut mock = crate::MockClient::new();
352        let xml = r#"<?xml version="1.0"?>
353<ListHealthChecksResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
354  <HealthChecks>
355    <HealthCheck>
356      <Id>0e548232-8f42-4e8c-bbda-81b6bf5e71ca</Id>
357      <CallerReference>cloud-lite-test-ralph-1771394175</CallerReference>
358      <HealthCheckConfig>
359        <Type>HTTP</Type>
360        <FullyQualifiedDomainName>cloud-lite-test-ralph-healthcheck.example.com</FullyQualifiedDomainName>
361        <Port>80</Port>
362        <ResourcePath>/health</ResourcePath>
363        <RequestInterval>30</RequestInterval>
364        <FailureThreshold>3</FailureThreshold>
365      </HealthCheckConfig>
366      <HealthCheckVersion>1</HealthCheckVersion>
367    </HealthCheck>
368  </HealthChecks>
369  <IsTruncated>false</IsTruncated>
370  <MaxItems>100</MaxItems>
371</ListHealthChecksResponse>"#;
372        mock.expect_get("/2013-04-01/healthcheck")
373            .returning_bytes(xml.as_bytes().to_vec());
374
375        let client = AwsHttpClient::from_mock(mock);
376        let r53 = client.route53();
377        let result = r53.list_health_checks().await.unwrap();
378
379        assert_eq!(result.health_checks.len(), 1);
380        let hc = &result.health_checks[0];
381        assert_eq!(hc.id, "0e548232-8f42-4e8c-bbda-81b6bf5e71ca");
382        assert_eq!(hc.caller_reference, "cloud-lite-test-ralph-1771394175");
383        assert_eq!(hc.health_check_version, 1);
384        assert_eq!(
385            hc.health_check_config
386                .fully_qualified_domain_name
387                .as_deref(),
388            Some("cloud-lite-test-ralph-healthcheck.example.com")
389        );
390        assert_eq!(hc.health_check_config.port, Some(80));
391        assert!(!result.is_truncated);
392        assert_eq!(result.max_items, "100");
393        assert_eq!(result.marker, None); // absent when no more pages
394    }
395
396    #[tokio::test]
397    async fn test_get_health_check_status() {
398        let mut mock = crate::MockClient::new();
399        let xml = r#"<?xml version="1.0"?>
400<GetHealthCheckStatusResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
401  <HealthCheckObservations>
402    <HealthCheckObservation>
403      <Region>us-east-1</Region>
404      <IPAddress>15.177.4.1</IPAddress>
405      <StatusReport>
406        <Status>Success: HTTP Status Code 200</Status>
407        <CheckedTime>2026-02-18T08:00:00.000Z</CheckedTime>
408      </StatusReport>
409    </HealthCheckObservation>
410  </HealthCheckObservations>
411</GetHealthCheckStatusResponse>"#;
412        mock.expect_get("/2013-04-01/healthcheck/0e548232-8f42-4e8c-bbda-81b6bf5e71ca/status")
413            .returning_bytes(xml.as_bytes().to_vec());
414
415        let client = AwsHttpClient::from_mock(mock);
416        let r53 = client.route53();
417        let result = r53
418            .get_health_check_status("0e548232-8f42-4e8c-bbda-81b6bf5e71ca")
419            .await
420            .unwrap();
421
422        assert_eq!(result.health_check_observations.len(), 1);
423        let obs = &result.health_check_observations[0];
424        assert_eq!(obs.region.as_deref(), Some("us-east-1"));
425        assert_eq!(obs.ip_address.as_deref(), Some("15.177.4.1"));
426        let status = obs.status_report.as_ref().unwrap();
427        assert!(status.status.as_deref().unwrap().contains("200"));
428    }
429
430    // DeleteHealthCheck: proven behavior from integration testing (2026-02-18):
431    // - DELETE /2013-04-01/healthcheck/{id} returns empty body on success
432    // - Route53 returns HTTP 200 with no body when delete succeeds
433    #[tokio::test]
434    async fn test_delete_health_check() {
435        let mut mock = crate::MockClient::new();
436        mock.expect_delete("/2013-04-01/healthcheck/8271fa7a-bffa-4cc5-9d33-70d9cf71a032")
437            .returning_bytes(vec![]);
438
439        let client = AwsHttpClient::from_mock(mock);
440        let r53 = client.route53();
441        let result = r53
442            .delete_health_check("8271fa7a-bffa-4cc5-9d33-70d9cf71a032")
443            .await;
444        assert!(
445            result.is_ok(),
446            "delete_health_check should return Ok(()) on success: {result:?}"
447        );
448    }
449
450    // ChangeResourceRecordSets: proven behavior from integration testing (2026-02-18):
451    // - POST /2013-04-01/hostedzone/{id}/rrset/ with XML body
452    // - Body must be <ChangeResourceRecordSetsRequest><ChangeBatch><Changes><Change>...</Change></Changes></ChangeBatch></ChangeResourceRecordSetsRequest>
453    // - ResourceRecords must be wrapped: <ResourceRecords><ResourceRecord><Value>x</Value></ResourceRecord></ResourceRecords>
454    // - Response is <ChangeResourceRecordSetsResponse><ChangeInfo><Id>...</Id><Status>PENDING</Status><SubmittedAt>...</SubmittedAt></ChangeInfo></ChangeResourceRecordSetsResponse>
455    // - Status is PENDING immediately after submission
456    // - ChangeInfo.Id has "/change/" prefix (e.g. "/change/C03401643O9NFZYR50ZKY")
457    // - SubmittedAt is an ISO 8601 timestamp
458    #[tokio::test]
459    async fn test_change_resource_record_sets() {
460        use crate::types::route53::{
461            Change, ChangeAction, ChangeBatch, ChangeResourceRecordSetsRequest, RRType,
462            ResourceRecord, ResourceRecordSet,
463        };
464
465        let mut mock = crate::MockClient::new();
466        let xml = r#"<?xml version="1.0"?>
467<ChangeResourceRecordSetsResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
468  <ChangeInfo>
469    <Id>/change/C03401643O9NFZYR50ZKY</Id>
470    <Status>PENDING</Status>
471    <SubmittedAt>2026-02-18T06:24:24.579Z</SubmittedAt>
472  </ChangeInfo>
473</ChangeResourceRecordSetsResponse>"#;
474        mock.expect_post("/2013-04-01/hostedzone/Z08643462LIHG4ESDGE73/rrset/")
475            .returning_bytes(xml.as_bytes().to_vec());
476
477        let client = AwsHttpClient::from_mock(mock);
478        let r53 = client.route53();
479        let body = ChangeResourceRecordSetsRequest {
480            change_batch: ChangeBatch {
481                comment: Some("test change".to_string()),
482                changes: vec![Change {
483                    action: Some(ChangeAction::Create),
484                    resource_record_set: ResourceRecordSet {
485                        name: "test-txt.cloud-lite-test-ralph.internal.".to_string(),
486                        r#type: Some(RRType::Txt),
487                        ttl: Some(300),
488                        resource_records: vec![ResourceRecord {
489                            value: "\"cloud-lite-test-value\"".to_string(),
490                        }],
491                        ..Default::default()
492                    },
493                }],
494            },
495        };
496        let result = r53
497            .change_resource_record_sets("Z08643462LIHG4ESDGE73", &body)
498            .await
499            .unwrap();
500
501        // ChangeInfo.Id has "/change/" prefix
502        assert_eq!(result.change_info.id, "/change/C03401643O9NFZYR50ZKY");
503        // Status is PENDING immediately after submission
504        assert_eq!(
505            result.change_info.status,
506            Some(crate::types::route53::ChangeStatus::Pending)
507        );
508        // SubmittedAt is an ISO 8601 timestamp
509        assert_eq!(result.change_info.submitted_at, "2026-02-18T06:24:24.579Z");
510    }
511
512    // ChangeResourceRecordSets body serialization: verify XML structure
513    // Proven: quick_xml flat Vec serialization produces wrong structure;
514    // build_change_rrset_xml wraps correctly for Route53.
515    #[test]
516    fn test_change_resource_record_sets_xml_body_structure() {
517        use crate::types::route53::{
518            Change, ChangeAction, ChangeBatch, ChangeResourceRecordSetsRequest, RRType,
519            ResourceRecord, ResourceRecordSet,
520        };
521
522        let body = ChangeResourceRecordSetsRequest {
523            change_batch: ChangeBatch {
524                comment: None,
525                changes: vec![Change {
526                    action: Some(ChangeAction::Create),
527                    resource_record_set: ResourceRecordSet {
528                        name: "test.example.com.".to_string(),
529                        r#type: Some(RRType::Txt),
530                        ttl: Some(300),
531                        resource_records: vec![ResourceRecord {
532                            value: "\"hello\"".to_string(),
533                        }],
534                        ..Default::default()
535                    },
536                }],
537            },
538        };
539
540        let xml = build_change_rrset_xml(&body).unwrap();
541        // Root element must be ChangeResourceRecordSetsRequest (not XmlChangeResourceRecordSetsRequest)
542        assert!(
543            xml.contains("<ChangeResourceRecordSetsRequest"),
544            "root element must be ChangeResourceRecordSetsRequest, got: {xml}"
545        );
546        // Changes wraps Change element (not repeated Changes)
547        assert!(
548            xml.contains("<Changes><Change>")
549                || xml.contains("<Changes>\n<Change>")
550                || xml.contains("<Changes> <Change>"),
551            "Changes must contain <Change> element, got: {xml}"
552        );
553        // ResourceRecord is wrapped
554        assert!(
555            xml.contains("<ResourceRecords><ResourceRecord>")
556                || xml.contains("<ResourceRecords>\n<ResourceRecord>"),
557            "ResourceRecords must contain <ResourceRecord> element, got: {xml}"
558        );
559        // TTL uses correct casing
560        assert!(
561            xml.contains("<TTL>300</TTL>"),
562            "TTL field name must be <TTL>, got: {xml}"
563        );
564        // Type uses correct casing
565        assert!(
566            xml.contains("<Type>"),
567            "Type field must be present, got: {xml}"
568        );
569    }
570}