1use 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#[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
142pub struct Route53Client<'a> {
144 ops: Route53Ops<'a>,
145}
146
147impl<'a> Route53Client<'a> {
148 pub(crate) fn new(client: &'a AwsHttpClient) -> Self {
150 Self {
151 ops: Route53Ops::new(client),
152 }
153 }
154
155 pub async fn list_hosted_zones(&self) -> Result<ListHostedZonesResponse> {
157 self.ops.list_hosted_zones().await
158 }
159
160 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 pub async fn list_health_checks(&self) -> Result<ListHealthChecksResponse> {
170 self.ops.list_health_checks().await
171 }
172
173 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 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 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 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 #[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); }
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); }
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 #[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 #[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 assert_eq!(result.change_info.id, "/change/C03401643O9NFZYR50ZKY");
503 assert_eq!(
505 result.change_info.status,
506 Some(crate::types::route53::ChangeStatus::Pending)
507 );
508 assert_eq!(result.change_info.submitted_at, "2026-02-18T06:24:24.579Z");
510 }
511
512 #[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 assert!(
543 xml.contains("<ChangeResourceRecordSetsRequest"),
544 "root element must be ChangeResourceRecordSetsRequest, got: {xml}"
545 );
546 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 assert!(
555 xml.contains("<ResourceRecords><ResourceRecord>")
556 || xml.contains("<ResourceRecords>\n<ResourceRecord>"),
557 "ResourceRecords must contain <ResourceRecord> element, got: {xml}"
558 );
559 assert!(
561 xml.contains("<TTL>300</TTL>"),
562 "TTL field name must be <TTL>, got: {xml}"
563 );
564 assert!(
566 xml.contains("<Type>"),
567 "Type field must be present, got: {xml}"
568 );
569 }
570}