1use crate::aws::aws_request_utils::{AwsRequestBuilderExt, AwsSignConfig};
8use crate::aws::credential_provider::AwsCredentialProvider;
9use alien_client_core::{ErrorData, Result};
10use alien_error::ContextError;
11use reqwest::{Client, Method, StatusCode};
12use serde::de::DeserializeOwned;
13use serde::Deserialize;
14use std::collections::HashMap;
15
16#[cfg(feature = "test-utils")]
17use mockall::automock;
18
19const RDS_API_VERSION: &str = "2014-10-31";
20pub const AURORA_POSTGRESQL_ENGINE: &str = "aurora-postgresql";
21pub const SERVERLESS_INSTANCE_CLASS: &str = "db.serverless";
22
23#[cfg_attr(feature = "test-utils", automock)]
24#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
25#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
26pub trait RdsApi: Send + Sync + std::fmt::Debug {
27 async fn create_db_subnet_group(&self, request: CreateDbSubnetGroupRequest) -> Result<()>;
28 async fn delete_db_subnet_group(&self, name: &str) -> Result<()>;
29 async fn create_db_cluster(&self, request: CreateDbClusterRequest) -> Result<DbCluster>;
30 async fn modify_db_cluster(&self, request: ModifyDbClusterRequest) -> Result<()>;
31 async fn delete_db_cluster(&self, request: DeleteDbClusterRequest) -> Result<()>;
32 async fn describe_db_clusters(&self, identifier: &str) -> Result<Vec<DbCluster>>;
33 async fn create_db_instance(&self, request: CreateDbInstanceRequest) -> Result<()>;
34 async fn delete_db_instance(&self, request: DeleteDbInstanceRequest) -> Result<()>;
35 async fn describe_db_instances(&self, cluster_identifier: &str) -> Result<Vec<DbInstance>>;
36}
37
38#[derive(Debug, Clone)]
41pub struct CreateDbSubnetGroupRequest {
42 pub name: String,
43 pub description: String,
44 pub subnet_ids: Vec<String>,
45 pub tags: HashMap<String, String>,
46}
47
48#[derive(Debug, Clone)]
49pub struct CreateDbClusterRequest {
50 pub identifier: String,
51 pub engine_version: String,
52 pub master_username: String,
53 pub master_user_password: String,
54 pub database_name: String,
55 pub db_subnet_group_name: String,
56 pub vpc_security_group_ids: Vec<String>,
57 pub max_capacity: f64,
59 pub backup_retention_days: u16,
60 pub tags: HashMap<String, String>,
61}
62
63#[derive(Debug, Clone)]
64pub struct ModifyDbClusterRequest {
65 pub identifier: String,
66 pub engine_version: Option<String>,
68 pub max_capacity: Option<f64>,
70 pub master_user_password: Option<String>,
73}
74
75#[derive(Debug, Clone)]
76pub struct DeleteDbClusterRequest {
77 pub identifier: String,
78}
79
80#[derive(Debug, Clone)]
81pub struct CreateDbInstanceRequest {
82 pub identifier: String,
83 pub cluster_identifier: String,
84 pub engine_version: String,
85}
86
87#[derive(Debug, Clone)]
88pub struct DeleteDbInstanceRequest {
89 pub identifier: String,
90}
91
92#[derive(Debug, Clone, Deserialize, PartialEq)]
95#[serde(rename_all = "PascalCase")]
96pub struct DbCluster {
97 #[serde(rename = "DBClusterIdentifier")]
98 pub identifier: String,
99 pub status: String,
100 #[serde(default)]
101 pub endpoint: Option<String>,
102 #[serde(default)]
103 pub reader_endpoint: Option<String>,
104 #[serde(default)]
105 pub port: Option<u16>,
106 #[serde(default)]
107 pub engine_version: Option<String>,
108 #[serde(default, rename = "ServerlessV2ScalingConfiguration")]
110 pub serverless_v2_scaling_configuration: Option<ServerlessV2ScalingConfiguration>,
111}
112
113#[derive(Debug, Clone, Deserialize, PartialEq)]
115#[serde(rename_all = "PascalCase")]
116pub struct ServerlessV2ScalingConfiguration {
117 pub max_capacity: f64,
119}
120
121#[derive(Debug, Clone, Deserialize, PartialEq)]
122#[serde(rename_all = "PascalCase")]
123pub struct DbInstance {
124 #[serde(rename = "DBInstanceIdentifier")]
125 pub identifier: String,
126 #[serde(rename = "DBInstanceStatus")]
127 pub status: String,
128}
129
130#[derive(Debug, Deserialize)]
131#[serde(rename_all = "PascalCase")]
132struct CreateDbClusterEnvelope {
133 #[serde(rename = "CreateDBClusterResult")]
134 result: CreateDbClusterResult,
135}
136#[derive(Debug, Deserialize)]
137#[serde(rename_all = "PascalCase")]
138struct CreateDbClusterResult {
139 #[serde(rename = "DBCluster")]
140 db_cluster: DbCluster,
141}
142
143#[derive(Debug, Deserialize)]
144#[serde(rename_all = "PascalCase")]
145struct DescribeDbClustersEnvelope {
146 #[serde(rename = "DescribeDBClustersResult")]
147 result: DescribeDbClustersResult,
148}
149#[derive(Debug, Deserialize)]
150#[serde(rename_all = "PascalCase")]
151struct DescribeDbClustersResult {
152 #[serde(rename = "DBClusters", default)]
153 db_clusters: DbClusterList,
154}
155#[derive(Debug, Default, Deserialize)]
156struct DbClusterList {
157 #[serde(rename = "DBCluster", default)]
158 members: Vec<DbCluster>,
159}
160
161#[derive(Debug, Deserialize)]
162#[serde(rename_all = "PascalCase")]
163struct DescribeDbInstancesEnvelope {
164 #[serde(rename = "DescribeDBInstancesResult")]
165 result: DescribeDbInstancesResult,
166}
167#[derive(Debug, Deserialize)]
168#[serde(rename_all = "PascalCase")]
169struct DescribeDbInstancesResult {
170 #[serde(rename = "DBInstances", default)]
171 db_instances: DbInstanceList,
172}
173#[derive(Debug, Default, Deserialize)]
174struct DbInstanceList {
175 #[serde(rename = "DBInstance", default)]
176 members: Vec<DbInstance>,
177}
178
179#[derive(Debug, Deserialize)]
180struct RdsErrorEnvelope {
181 #[serde(rename = "Error")]
182 error: RdsError,
183}
184#[derive(Debug, Deserialize)]
185#[serde(rename_all = "PascalCase")]
186struct RdsError {
187 code: String,
188 message: String,
189}
190
191fn indexed_members(
194 form: &mut HashMap<String, String>,
195 prefix: &str,
196 member: &str,
197 values: &[String],
198) {
199 for (i, value) in values.iter().enumerate() {
200 form.insert(format!("{prefix}.{member}.{}", i + 1), value.clone());
201 }
202}
203
204fn tag_members(form: &mut HashMap<String, String>, tags: &HashMap<String, String>) {
205 for (i, (key, value)) in tags.iter().enumerate() {
206 form.insert(format!("Tags.Tag.{}.Key", i + 1), key.clone());
207 form.insert(format!("Tags.Tag.{}.Value", i + 1), value.clone());
208 }
209}
210
211fn base_form(action: &str) -> HashMap<String, String> {
212 HashMap::from([
213 ("Action".to_string(), action.to_string()),
214 ("Version".to_string(), RDS_API_VERSION.to_string()),
215 ])
216}
217
218fn create_db_subnet_group_form(r: &CreateDbSubnetGroupRequest) -> HashMap<String, String> {
219 let mut form = base_form("CreateDBSubnetGroup");
220 form.insert("DBSubnetGroupName".into(), r.name.clone());
221 form.insert("DBSubnetGroupDescription".into(), r.description.clone());
222 indexed_members(&mut form, "SubnetIds", "SubnetIdentifier", &r.subnet_ids);
223 tag_members(&mut form, &r.tags);
224 form
225}
226
227fn create_db_cluster_form(r: &CreateDbClusterRequest) -> HashMap<String, String> {
228 let mut form = base_form("CreateDBCluster");
229 form.insert("DBClusterIdentifier".into(), r.identifier.clone());
230 form.insert("Engine".into(), AURORA_POSTGRESQL_ENGINE.to_string());
231 form.insert("EngineVersion".into(), r.engine_version.clone());
232 form.insert("MasterUsername".into(), r.master_username.clone());
233 form.insert("MasterUserPassword".into(), r.master_user_password.clone());
234 form.insert("DatabaseName".into(), r.database_name.clone());
235 form.insert("DBSubnetGroupName".into(), r.db_subnet_group_name.clone());
236 indexed_members(
237 &mut form,
238 "VpcSecurityGroupIds",
239 "VpcSecurityGroupId",
240 &r.vpc_security_group_ids,
241 );
242 form.insert(
244 "ServerlessV2ScalingConfiguration.MinCapacity".into(),
245 "0".into(),
246 );
247 form.insert(
248 "ServerlessV2ScalingConfiguration.MaxCapacity".into(),
249 format!("{}", r.max_capacity),
250 );
251 form.insert(
252 "BackupRetentionPeriod".into(),
253 r.backup_retention_days.to_string(),
254 );
255 form.insert("StorageEncrypted".into(), "true".into());
256 tag_members(&mut form, &r.tags);
257 form
258}
259
260fn modify_db_cluster_form(r: &ModifyDbClusterRequest) -> HashMap<String, String> {
261 let mut form = base_form("ModifyDBCluster");
262 form.insert("DBClusterIdentifier".into(), r.identifier.clone());
263 form.insert("ApplyImmediately".into(), "true".into());
264 if let Some(version) = &r.engine_version {
265 form.insert("EngineVersion".into(), version.clone());
266 form.insert("AllowMajorVersionUpgrade".into(), "true".into());
267 }
268 if let Some(max_capacity) = r.max_capacity {
269 form.insert(
271 "ServerlessV2ScalingConfiguration.MinCapacity".into(),
272 "0".into(),
273 );
274 form.insert(
275 "ServerlessV2ScalingConfiguration.MaxCapacity".into(),
276 format!("{max_capacity}"),
277 );
278 }
279 if let Some(password) = &r.master_user_password {
280 form.insert("MasterUserPassword".into(), password.clone());
281 }
282 form
283}
284
285fn create_db_instance_form(r: &CreateDbInstanceRequest) -> HashMap<String, String> {
286 let mut form = base_form("CreateDBInstance");
287 form.insert("DBInstanceIdentifier".into(), r.identifier.clone());
288 form.insert("DBClusterIdentifier".into(), r.cluster_identifier.clone());
289 form.insert("Engine".into(), AURORA_POSTGRESQL_ENGINE.to_string());
290 form.insert("EngineVersion".into(), r.engine_version.clone());
291 form.insert(
292 "DBInstanceClass".into(),
293 SERVERLESS_INSTANCE_CLASS.to_string(),
294 );
295 form.insert("PubliclyAccessible".into(), "false".into());
299 form
300}
301
302fn delete_db_cluster_form(r: &DeleteDbClusterRequest) -> HashMap<String, String> {
303 let mut form = base_form("DeleteDBCluster");
304 form.insert("DBClusterIdentifier".into(), r.identifier.clone());
305 form.insert("SkipFinalSnapshot".into(), "true".into());
308 form
309}
310
311fn delete_db_instance_form(r: &DeleteDbInstanceRequest) -> HashMap<String, String> {
312 let mut form = base_form("DeleteDBInstance");
313 form.insert("DBInstanceIdentifier".into(), r.identifier.clone());
314 form.insert("SkipFinalSnapshot".into(), "true".into());
315 form
316}
317
318#[derive(Debug, Clone)]
321pub struct RdsClient {
322 client: Client,
323 credentials: AwsCredentialProvider,
324}
325
326impl RdsClient {
327 pub fn new(client: Client, credentials: AwsCredentialProvider) -> Self {
328 Self {
329 client,
330 credentials,
331 }
332 }
333
334 fn sign_config(&self) -> AwsSignConfig {
335 AwsSignConfig {
336 service_name: "rds".into(),
337 region: self.credentials.region().to_string(),
338 credentials: self.credentials.get_credentials(),
339 signing_region: None,
340 }
341 }
342
343 fn get_base_url(&self) -> String {
344 if let Some(url) = self.credentials.get_service_endpoint_option("rds") {
345 url.to_string()
346 } else {
347 format!("https://rds.{}.amazonaws.com", self.credentials.region())
348 }
349 }
350
351 fn get_host(&self) -> String {
352 format!("rds.{}.amazonaws.com", self.credentials.region())
353 }
354
355 async fn send_form<T: DeserializeOwned + Send + 'static>(
356 &self,
357 form_data: HashMap<String, String>,
358 operation: &str,
359 resource: &str,
360 ) -> Result<T> {
361 self.credentials.ensure_fresh().await?;
362 let body = form_urlencoded::Serializer::new(String::new())
363 .extend_pairs(form_data.iter())
364 .finish();
365 let builder = self
366 .client
367 .request(Method::POST, &self.get_base_url())
368 .host(&self.get_host())
369 .content_type_form()
370 .content_sha256(&body)
371 .body(body);
372 let result =
373 crate::aws::aws_request_utils::sign_send_xml(builder, &self.sign_config()).await;
374 Self::map_result(result, operation, resource)
375 }
376
377 async fn send_form_no_body(
378 &self,
379 form_data: HashMap<String, String>,
380 operation: &str,
381 resource: &str,
382 ) -> Result<()> {
383 self.credentials.ensure_fresh().await?;
384 let body = form_urlencoded::Serializer::new(String::new())
385 .extend_pairs(form_data.iter())
386 .finish();
387 let builder = self
388 .client
389 .request(Method::POST, &self.get_base_url())
390 .host(&self.get_host())
391 .content_type_form()
392 .content_sha256(&body)
393 .body(body);
394 let result =
395 crate::aws::aws_request_utils::sign_send_no_response(builder, &self.sign_config())
396 .await;
397 Self::map_result(result, operation, resource)
398 }
399
400 fn map_result<T>(result: Result<T>, operation: &str, resource: &str) -> Result<T> {
401 let result = alien_client_core::redact_request_body(result);
405 let Err(e) = result else {
406 return result;
407 };
408 let Some(ErrorData::HttpResponseError {
409 http_status,
410 http_response_text: Some(text),
411 ..
412 }) = &e.error
413 else {
414 return Err(e);
415 };
416 let status =
417 StatusCode::from_u16(*http_status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
418 match Self::map_rds_error(status, text, resource) {
419 Some(mapped) => Err(e.context(mapped)),
420 None => Err(e.context(ErrorData::GenericError {
425 message: format!("RDS {operation} failed"),
426 })),
427 }
428 }
429
430 fn map_rds_error(status: StatusCode, body: &str, resource: &str) -> Option<ErrorData> {
431 if let Ok(parsed) = quick_xml::de::from_str::<RdsErrorEnvelope>(body) {
434 let code = parsed.error.code.as_str();
435 if code.ends_with("NotFoundFault") {
436 return Some(ErrorData::RemoteResourceNotFound {
437 resource_type: "RDS Resource".into(),
438 resource_name: resource.into(),
439 });
440 }
441 if code.ends_with("StateFault")
446 || code.ends_with("InUseFault")
447 || code.ends_with("AlreadyExistsFault")
448 {
449 return Some(ErrorData::RemoteResourceConflict {
450 resource_type: "RDS Resource".into(),
451 resource_name: resource.into(),
452 message: format!("{}: {}", code, parsed.error.message),
453 });
454 }
455 if matches!(
459 code,
460 "Throttling"
461 | "ThrottlingException"
462 | "RequestLimitExceeded"
463 | "ProvisionedThroughputExceededException"
464 ) || code.contains("Throttl")
465 || code.ends_with("LimitExceeded")
466 {
467 return Some(ErrorData::RateLimitExceeded {
468 message: format!("{}: {}", code, parsed.error.message),
469 });
470 }
471 if code.starts_with("Invalid")
477 || code.contains("Validation")
478 || code.contains("MalformedQuery")
479 {
480 return Some(ErrorData::InvalidInput {
481 message: format!("{}: {}", code, parsed.error.message),
482 field_name: None,
483 });
484 }
485 if code.ends_with("QuotaExceededFault") {
490 return Some(ErrorData::QuotaExceeded {
491 message: format!("{}: {}", code, parsed.error.message),
492 });
493 }
494 if code.starts_with("Insufficient") || code.ends_with("CapacityFault") {
495 return Some(ErrorData::RemoteServiceUnavailable {
496 message: format!("{}: {}", code, parsed.error.message),
497 });
498 }
499 return Some(ErrorData::GenericError {
500 message: format!("{}: {}", code, parsed.error.message),
501 });
502 }
503 match status {
504 StatusCode::NOT_FOUND => Some(ErrorData::RemoteResourceNotFound {
505 resource_type: "RDS Resource".into(),
506 resource_name: resource.into(),
507 }),
508 StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED => {
509 Some(ErrorData::RemoteAccessDenied {
510 resource_type: "RDS Resource".into(),
511 resource_name: resource.into(),
512 })
513 }
514 _ => None,
515 }
516 }
517}
518
519#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
520#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
521impl RdsApi for RdsClient {
522 async fn create_db_subnet_group(&self, request: CreateDbSubnetGroupRequest) -> Result<()> {
523 let name = request.name.clone();
524 self.send_form_no_body(
525 create_db_subnet_group_form(&request),
526 "CreateDBSubnetGroup",
527 &name,
528 )
529 .await
530 }
531
532 async fn delete_db_subnet_group(&self, name: &str) -> Result<()> {
533 let mut form = base_form("DeleteDBSubnetGroup");
534 form.insert("DBSubnetGroupName".into(), name.to_string());
535 self.send_form_no_body(form, "DeleteDBSubnetGroup", name)
536 .await
537 }
538
539 async fn create_db_cluster(&self, request: CreateDbClusterRequest) -> Result<DbCluster> {
540 let id = request.identifier.clone();
541 let envelope: CreateDbClusterEnvelope = self
542 .send_form(create_db_cluster_form(&request), "CreateDBCluster", &id)
543 .await?;
544 Ok(envelope.result.db_cluster)
545 }
546
547 async fn modify_db_cluster(&self, request: ModifyDbClusterRequest) -> Result<()> {
548 self.send_form_no_body(
549 modify_db_cluster_form(&request),
550 "ModifyDBCluster",
551 &request.identifier,
552 )
553 .await
554 }
555
556 async fn delete_db_cluster(&self, request: DeleteDbClusterRequest) -> Result<()> {
557 let id = request.identifier.clone();
558 self.send_form_no_body(delete_db_cluster_form(&request), "DeleteDBCluster", &id)
559 .await
560 }
561
562 async fn describe_db_clusters(&self, identifier: &str) -> Result<Vec<DbCluster>> {
563 let mut form = base_form("DescribeDBClusters");
564 form.insert("DBClusterIdentifier".into(), identifier.to_string());
565 let envelope: DescribeDbClustersEnvelope = self
566 .send_form(form, "DescribeDBClusters", identifier)
567 .await?;
568 Ok(envelope.result.db_clusters.members)
569 }
570
571 async fn create_db_instance(&self, request: CreateDbInstanceRequest) -> Result<()> {
572 let id = request.identifier.clone();
573 self.send_form_no_body(create_db_instance_form(&request), "CreateDBInstance", &id)
574 .await
575 }
576
577 async fn delete_db_instance(&self, request: DeleteDbInstanceRequest) -> Result<()> {
578 let id = request.identifier.clone();
579 self.send_form_no_body(delete_db_instance_form(&request), "DeleteDBInstance", &id)
580 .await
581 }
582
583 async fn describe_db_instances(&self, cluster_identifier: &str) -> Result<Vec<DbInstance>> {
584 let mut form = base_form("DescribeDBInstances");
585 form.insert("Filters.Filter.1.Name".into(), "db-cluster-id".into());
586 form.insert(
587 "Filters.Filter.1.Values.Value.1".into(),
588 cluster_identifier.to_string(),
589 );
590 let envelope: DescribeDbInstancesEnvelope = self
591 .send_form(form, "DescribeDBInstances", cluster_identifier)
592 .await?;
593 Ok(envelope.result.db_instances.members)
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 fn cluster_request() -> CreateDbClusterRequest {
602 CreateDbClusterRequest {
603 identifier: "stack-db".into(),
604 engine_version: "17.4".into(),
605 master_username: "alien".into(),
606 master_user_password: "secret".into(),
607 database_name: "db".into(),
608 db_subnet_group_name: "stack-subnets".into(),
609 vpc_security_group_ids: vec!["sg-1".into(), "sg-2".into()],
610 max_capacity: 4.0,
611 backup_retention_days: 7,
612 tags: HashMap::new(),
613 }
614 }
615
616 #[test]
617 fn create_cluster_form_pins_min_capacity_zero_and_engine() {
618 let form = create_db_cluster_form(&cluster_request());
619 assert_eq!(form["Action"], "CreateDBCluster");
620 assert_eq!(form["Engine"], "aurora-postgresql");
621 assert_eq!(form["ServerlessV2ScalingConfiguration.MinCapacity"], "0");
622 assert_eq!(form["ServerlessV2ScalingConfiguration.MaxCapacity"], "4");
623 assert_eq!(form["BackupRetentionPeriod"], "7");
624 assert_eq!(form["VpcSecurityGroupIds.VpcSecurityGroupId.1"], "sg-1");
625 assert_eq!(form["VpcSecurityGroupIds.VpcSecurityGroupId.2"], "sg-2");
626 }
627
628 #[test]
629 fn modify_cluster_form_moves_acu_ceiling_and_pins_min_zero() {
630 let form = modify_db_cluster_form(&ModifyDbClusterRequest {
631 identifier: "stack-db".into(),
632 engine_version: None,
633 max_capacity: Some(8.0),
634 master_user_password: None,
635 });
636 assert_eq!(form["Action"], "ModifyDBCluster");
637 assert_eq!(form["ApplyImmediately"], "true");
638 assert_eq!(form["ServerlessV2ScalingConfiguration.MinCapacity"], "0");
639 assert_eq!(form["ServerlessV2ScalingConfiguration.MaxCapacity"], "8");
640 assert!(!form.contains_key("EngineVersion"));
642 assert!(!form.contains_key("MasterUserPassword"));
643 }
644
645 #[test]
646 fn modify_cluster_form_carries_master_password_applied_immediately() {
647 let form = modify_db_cluster_form(&ModifyDbClusterRequest {
648 identifier: "stack-db".into(),
649 engine_version: None,
650 max_capacity: None,
651 master_user_password: Some("s3cret-pw".into()),
652 });
653 assert_eq!(form["ApplyImmediately"], "true");
656 assert_eq!(form["MasterUserPassword"], "s3cret-pw");
657 }
658
659 #[test]
660 fn modify_cluster_form_omits_scaling_when_no_memory_change() {
661 let form = modify_db_cluster_form(&ModifyDbClusterRequest {
662 identifier: "stack-db".into(),
663 engine_version: None,
664 max_capacity: None,
665 master_user_password: None,
666 });
667 assert!(!form.contains_key("ServerlessV2ScalingConfiguration.MaxCapacity"));
668 }
669
670 #[test]
671 fn delete_cluster_skips_final_snapshot() {
672 let form = delete_db_cluster_form(&DeleteDbClusterRequest {
673 identifier: "stack-db".into(),
674 });
675 assert_eq!(form["Action"], "DeleteDBCluster");
676 assert_eq!(form["SkipFinalSnapshot"], "true");
677 }
678
679 #[test]
680 fn create_instance_uses_serverless_class_and_is_never_public() {
681 let form = create_db_instance_form(&CreateDbInstanceRequest {
682 identifier: "stack-db-1".into(),
683 cluster_identifier: "stack-db".into(),
684 engine_version: "17.4".into(),
685 });
686 assert_eq!(form["DBInstanceClass"], "db.serverless");
687 assert_eq!(form["Engine"], "aurora-postgresql");
688 assert_eq!(form["PubliclyAccessible"], "false");
690 }
691
692 #[test]
693 fn map_result_strips_master_password_from_error_chain() {
694 use alien_error::AlienError;
699 const PW: &str = "Sup3rSecret-MasterPassword!";
700 let raw = AlienError::new(ErrorData::HttpResponseError {
701 message: "Request failed with HTTP 400".into(),
702 url: "https://rds.us-east-1.amazonaws.com/".into(),
703 http_status: 400,
704 http_request_text: Some(format!("Action=CreateDBCluster&MasterUserPassword={PW}")),
705 http_response_text: Some("<Error><Code>InvalidParameterValue</Code></Error>".into()),
706 });
707 let mapped = RdsClient::map_result::<()>(Err(raw), "CreateDBCluster", "stack-db")
708 .expect_err("an error result stays an error");
709 let json = serde_json::to_string(&mapped).expect("serialize");
710 assert!(
711 !json.contains(PW),
712 "master password leaked through map_result: {json}"
713 );
714 }
715
716 #[test]
717 fn describe_clusters_parses_xml() {
718 let xml = r#"<DescribeDBClustersResponse><DescribeDBClustersResult><DBClusters>
719 <DBCluster><DBClusterIdentifier>stack-db</DBClusterIdentifier><Status>available</Status>
720 <Endpoint>stack-db.cluster-x.us-east-1.rds.amazonaws.com</Endpoint><Port>5432</Port>
721 <EngineVersion>17.4</EngineVersion>
722 <ServerlessV2ScalingConfiguration><MinCapacity>0</MinCapacity><MaxCapacity>8</MaxCapacity></ServerlessV2ScalingConfiguration></DBCluster>
723 </DBClusters></DescribeDBClustersResult></DescribeDBClustersResponse>"#;
724 let env: DescribeDbClustersEnvelope = quick_xml::de::from_str(xml).expect("parses");
725 let clusters = env.result.db_clusters.members;
726 assert_eq!(clusters.len(), 1);
727 assert_eq!(clusters[0].identifier, "stack-db");
728 assert_eq!(clusters[0].status, "available");
729 assert_eq!(clusters[0].port, Some(5432));
730 assert_eq!(
733 clusters[0]
734 .serverless_v2_scaling_configuration
735 .as_ref()
736 .map(|s| s.max_capacity),
737 Some(8.0)
738 );
739 }
740}