Skip to main content

alien_aws_clients/aws/
resourcegroupstagging.rs

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}