1use crate::aws::aws_request_utils::{AwsRequestBuilderExt, AwsSignConfig};
2use crate::aws::credential_provider::AwsCredentialProvider;
3use alien_client_core::{ErrorData, Result};
4use alien_error::ContextError;
5use bon::Builder;
6use reqwest::{Client, StatusCode};
7use serde::de::DeserializeOwned;
8use serde::{Deserialize, Serialize};
9
10#[cfg(feature = "test-utils")]
11use mockall::automock;
12
13pub const GET_RESOURCES_TARGET: &str = "ResourceGroupsTaggingAPI_20170126.GetResources";
14
15#[cfg_attr(feature = "test-utils", automock)]
16#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
17#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
18pub trait ResourceGroupsTaggingApi: Send + Sync + std::fmt::Debug {
19 async fn get_resources(&self, request: GetResourcesRequest) -> Result<GetResourcesResponse>;
20}
21
22#[derive(Debug, Clone)]
23pub struct ResourceGroupsTaggingClient {
24 client: Client,
25 credentials: AwsCredentialProvider,
26}
27
28impl ResourceGroupsTaggingClient {
29 pub fn new(client: Client, credentials: AwsCredentialProvider) -> Self {
30 Self {
31 client,
32 credentials,
33 }
34 }
35
36 fn sign_config(&self) -> AwsSignConfig {
37 AwsSignConfig {
38 service_name: "tagging".into(),
39 region: self.credentials.region().to_string(),
40 credentials: self.credentials.get_credentials(),
41 signing_region: None,
42 }
43 }
44
45 fn host(&self) -> String {
46 format!("tagging.{}.amazonaws.com", self.credentials.region())
47 }
48
49 fn get_base_url(&self) -> String {
50 if let Some(override_url) = self
51 .credentials
52 .get_service_endpoint_option("resourcegroupstagging")
53 .or_else(|| self.credentials.get_service_endpoint_option("tagging"))
54 {
55 override_url.to_string()
56 } else {
57 format!("https://{}", self.host())
58 }
59 }
60
61 async fn send_json<T: DeserializeOwned + Send + 'static>(
62 &self,
63 target: &str,
64 body: String,
65 operation: &str,
66 resource: &str,
67 ) -> Result<T> {
68 self.credentials.ensure_fresh().await?;
69 let url = format!("{}/", self.get_base_url().trim_end_matches('/'));
70
71 let builder = self
72 .client
73 .post(&url)
74 .host(&self.host())
75 .header("X-Amz-Target", target)
76 .content_type_amz_json()
77 .content_sha256(&body)
78 .body(body.clone());
79
80 let result =
81 crate::aws::aws_request_utils::sign_send_json(builder, &self.sign_config()).await;
82
83 Self::map_result(result, operation, resource, Some(&body))
84 }
85
86 fn map_result<T>(
87 result: Result<T>,
88 operation: &str,
89 resource: &str,
90 request_body: Option<&str>,
91 ) -> Result<T> {
92 match result {
93 Ok(value) => Ok(value),
94 Err(error) => {
95 if let Some(ErrorData::HttpResponseError {
96 http_status,
97 http_response_text: Some(ref text),
98 ..
99 }) = &error.error
100 {
101 let status = StatusCode::from_u16(*http_status)
102 .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
103 if let Some(mapped) =
104 Self::map_error(status, text, operation, resource, request_body)
105 {
106 return Err(error.context(mapped));
107 }
108 }
109 Err(error)
110 }
111 }
112 }
113
114 fn map_error(
115 status: StatusCode,
116 body: &str,
117 operation: &str,
118 resource: &str,
119 request_body: Option<&str>,
120 ) -> Option<ErrorData> {
121 let parsed: Option<ResourceGroupsTaggingErrorResponse> = serde_json::from_str(body).ok();
122 let code = parsed
123 .as_ref()
124 .map(|error| error.code.trim_start_matches('#'))
125 .unwrap_or_default();
126 let message = parsed
127 .as_ref()
128 .and_then(|error| error.message.clone())
129 .unwrap_or_else(|| body.to_string());
130
131 match status {
132 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
133 Some(ErrorData::AuthenticationError { message })
134 }
135 StatusCode::TOO_MANY_REQUESTS => Some(ErrorData::RateLimitExceeded { message }),
136 StatusCode::BAD_REQUEST if code == "InvalidParameterException" => {
137 Some(ErrorData::InvalidClientConfig {
138 message,
139 errors: None,
140 })
141 }
142 _ if !body.trim().is_empty() => Some(ErrorData::HttpResponseError {
143 message: format!("{} failed for '{}': {}", operation, resource, message),
144 url: String::new(),
145 http_status: status.as_u16(),
146 http_request_text: request_body.map(ToOwned::to_owned),
147 http_response_text: Some(body.to_string()),
148 }),
149 _ => None,
150 }
151 }
152}
153
154#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
155#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
156impl ResourceGroupsTaggingApi for ResourceGroupsTaggingClient {
157 async fn get_resources(&self, request: GetResourcesRequest) -> Result<GetResourcesResponse> {
158 let body = serde_json::to_string(&request).map_err(|error| {
159 alien_error::AlienError::new(ErrorData::InvalidClientConfig {
160 message: format!("Failed to serialize GetResources request: {}", error),
161 errors: None,
162 })
163 })?;
164
165 self.send_json(
166 GET_RESOURCES_TARGET,
167 body,
168 "GetResources",
169 "resource inventory",
170 )
171 .await
172 }
173}
174
175#[derive(Debug, Clone, Default, Builder, Serialize)]
176#[serde(rename_all = "PascalCase")]
177pub struct GetResourcesRequest {
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub pagination_token: Option<String>,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 pub resources_per_page: Option<i32>,
182 #[serde(skip_serializing_if = "Vec::is_empty")]
183 #[builder(default)]
184 pub tag_filters: Vec<TagFilter>,
185 #[serde(skip_serializing_if = "Vec::is_empty")]
186 #[builder(default)]
187 pub resource_type_filters: Vec<String>,
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub include_compliance_details: Option<bool>,
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub exclude_compliant_resources: Option<bool>,
192}
193
194#[derive(Debug, Clone, Builder, Serialize)]
195#[serde(rename_all = "PascalCase")]
196pub struct TagFilter {
197 pub key: String,
198 #[serde(skip_serializing_if = "Vec::is_empty")]
199 #[builder(default)]
200 pub values: Vec<String>,
201}
202
203#[derive(Debug, Clone, Deserialize)]
204#[serde(rename_all = "PascalCase")]
205pub struct GetResourcesResponse {
206 #[serde(default)]
207 pub pagination_token: Option<String>,
208 #[serde(default)]
209 pub resource_tag_mapping_list: Vec<ResourceTagMapping>,
210}
211
212#[derive(Debug, Clone, Deserialize)]
213#[serde(rename_all = "PascalCase")]
214pub struct ResourceTagMapping {
215 #[serde(rename = "ResourceARN")]
216 pub resource_arn: String,
217 #[serde(default)]
218 pub tags: Vec<Tag>,
219 #[serde(default)]
220 pub compliance_details: Option<ComplianceDetails>,
221}
222
223#[derive(Debug, Clone, Deserialize)]
224#[serde(rename_all = "PascalCase")]
225pub struct Tag {
226 pub key: String,
227 pub value: String,
228}
229
230#[derive(Debug, Clone, Deserialize)]
231#[serde(rename_all = "PascalCase")]
232pub struct ComplianceDetails {
233 #[serde(default)]
234 pub noncompliant_keys: Vec<String>,
235 #[serde(default)]
236 pub keys_with_noncompliant_values: Vec<String>,
237 #[serde(default)]
238 pub compliance_status: Option<bool>,
239}
240
241#[derive(Debug, Deserialize)]
242#[serde(rename_all = "PascalCase")]
243struct ResourceGroupsTaggingErrorResponse {
244 #[serde(rename = "__type", alias = "Code")]
245 code: String,
246 #[serde(rename = "Message", alias = "message")]
247 message: Option<String>,
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use serde_json::json;
254
255 #[test]
256 fn get_resources_request_matches_aws_json_shape() {
257 let request = GetResourcesRequest::builder()
258 .resources_per_page(1)
259 .tag_filters(vec![TagFilter::builder()
260 .key("Environment".to_string())
261 .values(vec!["prod".to_string()])
262 .build()])
263 .resource_type_filters(vec!["ec2:instance".to_string()])
264 .build();
265
266 let encoded = serde_json::to_value(request).unwrap();
267
268 assert_eq!(
269 encoded,
270 json!({
271 "ResourcesPerPage": 1,
272 "TagFilters": [{ "Key": "Environment", "Values": ["prod"] }],
273 "ResourceTypeFilters": ["ec2:instance"]
274 })
275 );
276 }
277
278 #[test]
279 fn get_resources_response_parses_resource_arn_and_tags() {
280 let response: GetResourcesResponse = serde_json::from_value(json!({
281 "PaginationToken": "next",
282 "ResourceTagMappingList": [{
283 "ResourceARN": "arn:aws:s3:::example",
284 "Tags": [{ "Key": "Name", "Value": "example" }]
285 }]
286 }))
287 .unwrap();
288
289 assert_eq!(response.pagination_token.as_deref(), Some("next"));
290 assert_eq!(
291 response.resource_tag_mapping_list[0].resource_arn,
292 "arn:aws:s3:::example"
293 );
294 assert_eq!(response.resource_tag_mapping_list[0].tags[0].key, "Name");
295 assert_eq!(
296 response.resource_tag_mapping_list[0].tags[0].value,
297 "example"
298 );
299 }
300}