Skip to main content

fakecloud_resource_groups/
service.rs

1//! Resource Groups restJson1 dispatch + operation handlers.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use chrono::Utc;
8use http::{Method, StatusCode};
9use percent_encoding::percent_decode_str;
10use serde_json::{json, Value};
11use tokio::sync::Mutex as AsyncMutex;
12
13use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
14use fakecloud_persistence::SnapshotStore;
15
16use crate::persistence::save_snapshot;
17use crate::state::{
18    AccountSettings, ResourceGroup, ResourceQuery, SharedResourceGroupsState, TagSyncTask,
19};
20
21/// Every operation name in the Resource Groups Smithy model.
22pub const RESOURCE_GROUPS_ACTIONS: &[&str] = &[
23    "CancelTagSyncTask",
24    "CreateGroup",
25    "DeleteGroup",
26    "GetAccountSettings",
27    "GetGroup",
28    "GetGroupConfiguration",
29    "GetGroupQuery",
30    "GetTagSyncTask",
31    "GetTags",
32    "GroupResources",
33    "ListGroupResources",
34    "ListGroupingStatuses",
35    "ListGroups",
36    "ListTagSyncTasks",
37    "PutGroupConfiguration",
38    "SearchResources",
39    "StartTagSyncTask",
40    "Tag",
41    "UngroupResources",
42    "Untag",
43    "UpdateAccountSettings",
44    "UpdateGroup",
45    "UpdateGroupQuery",
46];
47
48pub struct ResourceGroupsService {
49    state: SharedResourceGroupsState,
50    snapshot_store: Option<Arc<dyn SnapshotStore>>,
51    snapshot_lock: Arc<AsyncMutex<()>>,
52}
53
54/// A resolved route: the operation plus any path-derived argument.
55enum Route {
56    CreateGroup,
57    GetGroup,
58    GetGroupQuery,
59    UpdateGroup,
60    UpdateGroupQuery,
61    DeleteGroup,
62    ListGroups,
63    GroupResources,
64    UngroupResources,
65    ListGroupResources,
66    SearchResources,
67    GetGroupConfiguration,
68    PutGroupConfiguration,
69    GetTags(String),
70    Tag(String),
71    Untag(String),
72    GetAccountSettings,
73    UpdateAccountSettings,
74    ListGroupingStatuses,
75    StartTagSyncTask,
76    GetTagSyncTask,
77    ListTagSyncTasks,
78    CancelTagSyncTask,
79}
80
81impl Route {
82    fn mutates(&self) -> bool {
83        matches!(
84            self,
85            Route::CreateGroup
86                | Route::UpdateGroup
87                | Route::UpdateGroupQuery
88                | Route::DeleteGroup
89                | Route::GroupResources
90                | Route::UngroupResources
91                | Route::PutGroupConfiguration
92                | Route::Tag(_)
93                | Route::Untag(_)
94                | Route::UpdateAccountSettings
95                | Route::StartTagSyncTask
96                | Route::CancelTagSyncTask
97        )
98    }
99}
100
101impl ResourceGroupsService {
102    pub fn new(state: SharedResourceGroupsState) -> Self {
103        Self {
104            state,
105            snapshot_store: None,
106            snapshot_lock: Arc::new(AsyncMutex::new(())),
107        }
108    }
109
110    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
111        self.snapshot_store = Some(store);
112        self
113    }
114
115    async fn persist(&self) {
116        save_snapshot(
117            &self.state,
118            self.snapshot_store.clone(),
119            &self.snapshot_lock,
120        )
121        .await;
122    }
123
124    fn resolve_route(req: &AwsRequest) -> Option<Route> {
125        let segs: Vec<&str> = req
126            .raw_path
127            .trim_matches('/')
128            .split('/')
129            .filter(|s| !s.is_empty())
130            .collect();
131        match (&req.method, segs.as_slice()) {
132            (&Method::POST, ["groups"]) => Some(Route::CreateGroup),
133            (&Method::POST, ["groups-list"]) => Some(Route::ListGroups),
134            (&Method::POST, ["get-group"]) => Some(Route::GetGroup),
135            (&Method::POST, ["get-group-query"]) => Some(Route::GetGroupQuery),
136            (&Method::POST, ["update-group"]) => Some(Route::UpdateGroup),
137            (&Method::POST, ["update-group-query"]) => Some(Route::UpdateGroupQuery),
138            (&Method::POST, ["delete-group"]) => Some(Route::DeleteGroup),
139            (&Method::POST, ["group-resources"]) => Some(Route::GroupResources),
140            (&Method::POST, ["ungroup-resources"]) => Some(Route::UngroupResources),
141            (&Method::POST, ["list-group-resources"]) => Some(Route::ListGroupResources),
142            (&Method::POST, ["resources", "search"]) => Some(Route::SearchResources),
143            (&Method::POST, ["get-group-configuration"]) => Some(Route::GetGroupConfiguration),
144            (&Method::POST, ["put-group-configuration"]) => Some(Route::PutGroupConfiguration),
145            (&Method::POST, ["get-account-settings"]) => Some(Route::GetAccountSettings),
146            (&Method::POST, ["update-account-settings"]) => Some(Route::UpdateAccountSettings),
147            (&Method::POST, ["list-grouping-statuses"]) => Some(Route::ListGroupingStatuses),
148            (&Method::POST, ["start-tag-sync-task"]) => Some(Route::StartTagSyncTask),
149            (&Method::POST, ["get-tag-sync-task"]) => Some(Route::GetTagSyncTask),
150            (&Method::POST, ["list-tag-sync-tasks"]) => Some(Route::ListTagSyncTasks),
151            (&Method::POST, ["cancel-tag-sync-task"]) => Some(Route::CancelTagSyncTask),
152            (&Method::GET, ["resources", arn, "tags"]) => Some(Route::GetTags(decode(arn))),
153            (&Method::PUT, ["resources", arn, "tags"]) => Some(Route::Tag(decode(arn))),
154            (&Method::PATCH, ["resources", arn, "tags"]) => Some(Route::Untag(decode(arn))),
155            _ => None,
156        }
157    }
158
159    // --- group lifecycle --------------------------------------------------
160
161    fn create_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
162        let body = parse_json(&req.body)?;
163        let name = require_str(&body, "Name")?.to_string();
164        validate_group_name(&name)?;
165        let query = parse_resource_query(body.get("ResourceQuery"))?;
166        let configuration = body
167            .get("Configuration")
168            .and_then(|v| v.as_array())
169            .cloned()
170            .unwrap_or_default();
171        if query.is_none() && configuration.is_empty() {
172            return Err(bad_request(
173                "A group must be created with either a ResourceQuery or a Configuration.",
174            ));
175        }
176        let tags = parse_tags(body.get("Tags"));
177
178        let mut accounts = self.state.write();
179        let st = accounts.get_or_create(&req.account_id);
180        if st.groups.contains_key(&name) {
181            return Err(bad_request(&format!(
182                "A group with the name '{name}' already exists."
183            )));
184        }
185        let arn = group_arn(&req.region, &req.account_id, &name);
186        let group = ResourceGroup {
187            name: name.clone(),
188            arn,
189            description: opt_string(&body, "Description"),
190            query,
191            configuration,
192            tags: tags.clone(),
193            criticality: body
194                .get("Criticality")
195                .and_then(|v| v.as_i64())
196                .map(|n| n as i32),
197            owner: opt_string(&body, "Owner"),
198            display_name: opt_string(&body, "DisplayName"),
199            application_tag: BTreeMap::new(),
200            resources: BTreeSet::new(),
201            created_at: Utc::now(),
202        };
203        let out = json!({
204            "Group": group_json(&group),
205            "ResourceQuery": group.query.as_ref().map(resource_query_json),
206            "Tags": tags,
207            "GroupConfiguration": group_configuration_json(&group),
208        });
209        st.groups.insert(name, group);
210        Ok(AwsResponse::json_value(StatusCode::OK, out))
211    }
212
213    fn get_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
214        let body = parse_json(&req.body)?;
215        let key = group_key(&body)?;
216        let accounts = self.state.read();
217        let st = accounts.get(&req.account_id);
218        let group = st
219            .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
220            .ok_or_else(|| not_found(&key))?;
221        Ok(AwsResponse::json_value(
222            StatusCode::OK,
223            json!({ "Group": group_json(group) }),
224        ))
225    }
226
227    fn get_group_query(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
228        let body = parse_json(&req.body)?;
229        let key = group_key(&body)?;
230        let accounts = self.state.read();
231        let st = accounts.get(&req.account_id);
232        let group = st
233            .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
234            .ok_or_else(|| not_found(&key))?;
235        let query = group.query.as_ref().ok_or_else(|| {
236            bad_request("The group is not a resource-query group and has no query.")
237        })?;
238        Ok(AwsResponse::json_value(
239            StatusCode::OK,
240            json!({
241                "GroupQuery": {
242                    "GroupName": group.name,
243                    "ResourceQuery": resource_query_json(query),
244                }
245            }),
246        ))
247    }
248
249    fn update_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
250        let body = parse_json(&req.body)?;
251        let key = group_key(&body)?;
252        let mut accounts = self.state.write();
253        let st = accounts.get_or_create(&req.account_id);
254        let Some(name) = st.resolve_name(&key).map(String::from) else {
255            return Err(not_found(&key));
256        };
257        let group = st.groups.get_mut(&name).unwrap();
258        if let Some(d) = body.get("Description").and_then(|v| v.as_str()) {
259            group.description = Some(d.to_string());
260        }
261        if let Some(c) = body.get("Criticality").and_then(|v| v.as_i64()) {
262            group.criticality = Some(c as i32);
263        }
264        if let Some(o) = body.get("Owner").and_then(|v| v.as_str()) {
265            group.owner = Some(o.to_string());
266        }
267        if let Some(dn) = body.get("DisplayName").and_then(|v| v.as_str()) {
268            group.display_name = Some(dn.to_string());
269        }
270        Ok(AwsResponse::json_value(
271            StatusCode::OK,
272            json!({ "Group": group_json(group) }),
273        ))
274    }
275
276    fn update_group_query(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
277        let body = parse_json(&req.body)?;
278        let key = group_key(&body)?;
279        let query = parse_resource_query(body.get("ResourceQuery"))?
280            .ok_or_else(|| bad_request("ResourceQuery is required."))?;
281        let mut accounts = self.state.write();
282        let st = accounts.get_or_create(&req.account_id);
283        let Some(name) = st.resolve_name(&key).map(String::from) else {
284            return Err(not_found(&key));
285        };
286        let group = st.groups.get_mut(&name).unwrap();
287        group.query = Some(query.clone());
288        Ok(AwsResponse::json_value(
289            StatusCode::OK,
290            json!({
291                "GroupQuery": {
292                    "GroupName": group.name,
293                    "ResourceQuery": resource_query_json(&query),
294                }
295            }),
296        ))
297    }
298
299    fn delete_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
300        let body = parse_json(&req.body)?;
301        let key = group_key(&body)?;
302        let mut accounts = self.state.write();
303        let st = accounts.get_or_create(&req.account_id);
304        let Some(name) = st.resolve_name(&key).map(String::from) else {
305            return Err(not_found(&key));
306        };
307        let group = st.groups.remove(&name).unwrap();
308        Ok(AwsResponse::json_value(
309            StatusCode::OK,
310            json!({ "Group": group_json(&group) }),
311        ))
312    }
313
314    fn list_groups(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
315        // ListGroups binds MaxResults/NextToken to the query string (@httpQuery),
316        // not the body, so fold them in before validating + paginating.
317        let mut body = parse_json(&req.body)?;
318        inject_query_pagination(req, &mut body);
319        validate_max_results(&body)?;
320        validate_next_token(&body)?;
321        let accounts = self.state.read();
322        let groups: Vec<&ResourceGroup> = accounts
323            .get(&req.account_id)
324            .map(|s| s.groups.values().collect())
325            .unwrap_or_default();
326        let identifiers: Vec<Value> = groups.iter().map(|g| group_identifier_json(g)).collect();
327        let full: Vec<Value> = groups.iter().map(|g| group_json(g)).collect();
328        let (ident_page, next) = paginate(identifiers, &body);
329        let (full_page, _) = paginate(full, &body);
330        let mut out = json!({
331            "GroupIdentifiers": ident_page,
332            "Groups": full_page,
333        });
334        if let Some(nt) = next {
335            out["NextToken"] = json!(nt);
336        }
337        Ok(AwsResponse::json_value(StatusCode::OK, out))
338    }
339
340    // --- membership -------------------------------------------------------
341
342    fn group_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
343        let body = parse_json(&req.body)?;
344        let key = require_str(&body, "Group")?.to_string();
345        let arns = string_list(body.get("ResourceArns"));
346        if arns.is_empty() {
347            return Err(bad_request("ResourceArns must not be empty."));
348        }
349        let mut accounts = self.state.write();
350        let st = accounts.get_or_create(&req.account_id);
351        let Some(name) = st.resolve_name(&key).map(String::from) else {
352            return Err(not_found(&key));
353        };
354        let group = st.groups.get_mut(&name).unwrap();
355        if group.query.is_some() {
356            return Err(bad_request(
357                "Resources cannot be added to a resource-query group; membership is computed from its query.",
358            ));
359        }
360        for a in &arns {
361            group.resources.insert(a.clone());
362        }
363        Ok(AwsResponse::json_value(
364            StatusCode::OK,
365            json!({ "Succeeded": arns, "Failed": [], "Pending": [] }),
366        ))
367    }
368
369    fn ungroup_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
370        let body = parse_json(&req.body)?;
371        let key = require_str(&body, "Group")?.to_string();
372        let arns = string_list(body.get("ResourceArns"));
373        let mut accounts = self.state.write();
374        let st = accounts.get_or_create(&req.account_id);
375        let Some(name) = st.resolve_name(&key).map(String::from) else {
376            return Err(not_found(&key));
377        };
378        let group = st.groups.get_mut(&name).unwrap();
379        if group.query.is_some() {
380            return Err(bad_request(
381                "Resources cannot be removed from a resource-query group; membership is computed from its query.",
382            ));
383        }
384        for a in &arns {
385            group.resources.remove(a);
386        }
387        Ok(AwsResponse::json_value(
388            StatusCode::OK,
389            json!({ "Succeeded": arns, "Failed": [], "Pending": [] }),
390        ))
391    }
392
393    fn list_group_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
394        let body = parse_json(&req.body)?;
395        validate_max_results(&body)?;
396        validate_next_token(&body)?;
397        let key = group_key_field(&body, "GroupName", "Group")?;
398        let accounts = self.state.read();
399        let st = accounts.get(&req.account_id);
400        let group = st
401            .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
402            .ok_or_else(|| not_found(&key))?;
403        let items: Vec<Value> = group
404            .resources
405            .iter()
406            .map(|arn| {
407                json!({
408                    "Identifier": resource_identifier_json(arn),
409                    "Status": { "Name": "ACTIVE" },
410                })
411            })
412            .collect();
413        let idents: Vec<Value> = group
414            .resources
415            .iter()
416            .map(|arn| resource_identifier_json(arn))
417            .collect();
418        let (items_page, next) = paginate(items, &body);
419        let (idents_page, _) = paginate(idents, &body);
420        let mut out = json!({
421            "Resources": items_page,
422            "ResourceIdentifiers": idents_page,
423            "QueryErrors": [],
424        });
425        if let Some(nt) = next {
426            out["NextToken"] = json!(nt);
427        }
428        Ok(AwsResponse::json_value(StatusCode::OK, out))
429    }
430
431    fn search_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
432        let body = parse_json(&req.body)?;
433        validate_max_results(&body)?;
434        validate_next_token(&body)?;
435        // Evaluating a free-standing ResourceQuery against live resources needs
436        // the cross-service tag index (Resource Groups Tagging API batch). Until
437        // then SearchResources returns no matches rather than a fabricated set.
438        parse_resource_query(body.get("ResourceQuery"))?
439            .ok_or_else(|| bad_request("ResourceQuery is required."))?;
440        Ok(AwsResponse::json_value(
441            StatusCode::OK,
442            json!({ "ResourceIdentifiers": [], "QueryErrors": [] }),
443        ))
444    }
445
446    // --- configuration ----------------------------------------------------
447
448    fn get_group_configuration(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
449        let body = parse_json(&req.body)?;
450        let key = group_key_field(&body, "Group", "Group")?;
451        let accounts = self.state.read();
452        let st = accounts.get(&req.account_id);
453        let group = st
454            .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
455            .ok_or_else(|| not_found(&key))?;
456        Ok(AwsResponse::json_value(
457            StatusCode::OK,
458            json!({ "GroupConfiguration": group_configuration_json(group) }),
459        ))
460    }
461
462    fn put_group_configuration(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
463        let body = parse_json(&req.body)?;
464        let key = require_str(&body, "Group")?.to_string();
465        let configuration = body
466            .get("Configuration")
467            .and_then(|v| v.as_array())
468            .cloned()
469            .unwrap_or_default();
470        let mut accounts = self.state.write();
471        let st = accounts.get_or_create(&req.account_id);
472        let Some(name) = st.resolve_name(&key).map(String::from) else {
473            return Err(not_found(&key));
474        };
475        st.groups.get_mut(&name).unwrap().configuration = configuration;
476        Ok(AwsResponse::json_value(StatusCode::OK, json!({})))
477    }
478
479    // --- tagging ----------------------------------------------------------
480
481    fn get_tags(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
482        let accounts = self.state.read();
483        let st = accounts.get(&req.account_id);
484        let group = st
485            .and_then(|s| s.groups.values().find(|g| g.arn == arn))
486            .ok_or_else(|| not_found(arn))?;
487        Ok(AwsResponse::json_value(
488            StatusCode::OK,
489            json!({ "Arn": arn, "Tags": group.tags }),
490        ))
491    }
492
493    fn tag(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
494        let body = parse_json(&req.body)?;
495        let tags = parse_tags(body.get("Tags"));
496        let mut accounts = self.state.write();
497        let st = accounts.get_or_create(&req.account_id);
498        let group = st
499            .groups
500            .values_mut()
501            .find(|g| g.arn == arn)
502            .ok_or_else(|| not_found(arn))?;
503        for (k, v) in &tags {
504            group.tags.insert(k.clone(), v.clone());
505        }
506        Ok(AwsResponse::json_value(
507            StatusCode::OK,
508            json!({ "Arn": arn, "Tags": tags }),
509        ))
510    }
511
512    fn untag(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
513        let body = parse_json(&req.body)?;
514        let keys = string_list(body.get("Keys"));
515        let mut accounts = self.state.write();
516        let st = accounts.get_or_create(&req.account_id);
517        let group = st
518            .groups
519            .values_mut()
520            .find(|g| g.arn == arn)
521            .ok_or_else(|| not_found(arn))?;
522        for k in &keys {
523            group.tags.remove(k);
524        }
525        Ok(AwsResponse::json_value(
526            StatusCode::OK,
527            json!({ "Arn": arn, "Keys": keys }),
528        ))
529    }
530
531    // --- account settings -------------------------------------------------
532
533    fn get_account_settings(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
534        let accounts = self.state.read();
535        let settings = accounts
536            .get(&req.account_id)
537            .map(|s| s.account_settings.clone())
538            .unwrap_or_default();
539        Ok(AwsResponse::json_value(
540            StatusCode::OK,
541            json!({ "AccountSettings": account_settings_json(&settings) }),
542        ))
543    }
544
545    fn update_account_settings(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
546        let body = parse_json(&req.body)?;
547        let desired = body
548            .get("GroupLifecycleEventsDesiredStatus")
549            .and_then(|v| v.as_str());
550        let mut accounts = self.state.write();
551        let st = accounts.get_or_create(&req.account_id);
552        if let Some(d) = desired {
553            if d != "ACTIVE" && d != "INACTIVE" {
554                return Err(bad_request(
555                    "GroupLifecycleEventsDesiredStatus must be ACTIVE or INACTIVE.",
556                ));
557            }
558            st.account_settings.desired_status = Some(d.to_string());
559        }
560        Ok(AwsResponse::json_value(
561            StatusCode::OK,
562            json!({ "AccountSettings": account_settings_json(&st.account_settings) }),
563        ))
564    }
565
566    fn list_grouping_statuses(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
567        let body = parse_json(&req.body)?;
568        validate_max_results(&body)?;
569        validate_next_token(&body)?;
570        let key = require_str(&body, "Group")?.to_string();
571        let accounts = self.state.read();
572        let st = accounts.get(&req.account_id);
573        let group = st
574            .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
575            // ListGroupingStatuses does not declare NotFoundException in its
576            // Smithy error set, so a missing group is a BadRequest here.
577            .ok_or_else(|| bad_request(&format!("The specified group '{key}' was not found.")))?;
578        // Membership changes here are synchronous, so nothing is ever pending.
579        Ok(AwsResponse::json_value(
580            StatusCode::OK,
581            json!({ "Group": group.arn, "GroupingStatuses": [] }),
582        ))
583    }
584
585    // --- tag-sync tasks ---------------------------------------------------
586
587    fn start_tag_sync_task(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
588        let body = parse_json(&req.body)?;
589        let key = require_str(&body, "Group")?.to_string();
590        let role_arn = require_str(&body, "RoleArn")?.to_string();
591        let mut accounts = self.state.write();
592        let st = accounts.get_or_create(&req.account_id);
593        let Some(name) = st.resolve_name(&key).map(String::from) else {
594            return Err(not_found(&key));
595        };
596        let group_arn = st.groups.get(&name).unwrap().arn.clone();
597        let task_arn = tag_sync_task_arn(&group_arn);
598        let task = TagSyncTask {
599            task_arn: task_arn.clone(),
600            group_arn: group_arn.clone(),
601            group_name: name.clone(),
602            tag_key: opt_string(&body, "TagKey"),
603            tag_value: opt_string(&body, "TagValue"),
604            resource_query: parse_resource_query(body.get("ResourceQuery"))?,
605            role_arn: role_arn.clone(),
606            status: "ACTIVE".to_string(),
607            created_at: Utc::now(),
608        };
609        st.tag_sync_tasks.insert(task_arn.clone(), task.clone());
610        Ok(AwsResponse::json_value(
611            StatusCode::OK,
612            json!({
613                "GroupArn": group_arn,
614                "GroupName": name,
615                "TaskArn": task_arn,
616                "TagKey": task.tag_key,
617                "TagValue": task.tag_value,
618                "ResourceQuery": task.resource_query.as_ref().map(resource_query_json),
619                "RoleArn": role_arn,
620            }),
621        ))
622    }
623
624    fn get_tag_sync_task(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
625        let body = parse_json(&req.body)?;
626        let task_arn = require_str(&body, "TaskArn")?.to_string();
627        let accounts = self.state.read();
628        let task = accounts
629            .get(&req.account_id)
630            .and_then(|s| s.tag_sync_tasks.get(&task_arn))
631            .ok_or_else(|| not_found(&task_arn))?;
632        Ok(AwsResponse::json_value(
633            StatusCode::OK,
634            tag_sync_task_json(task),
635        ))
636    }
637
638    fn list_tag_sync_tasks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
639        let body = parse_json(&req.body)?;
640        validate_max_results(&body)?;
641        validate_next_token(&body)?;
642        let accounts = self.state.read();
643        let all: Vec<Value> = accounts
644            .get(&req.account_id)
645            .map(|s| s.tag_sync_tasks.values().map(tag_sync_task_json).collect())
646            .unwrap_or_default();
647        let (page, next) = paginate(all, &body);
648        let mut out = json!({ "TagSyncTasks": page });
649        if let Some(nt) = next {
650            out["NextToken"] = json!(nt);
651        }
652        Ok(AwsResponse::json_value(StatusCode::OK, out))
653    }
654
655    fn cancel_tag_sync_task(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
656        let body = parse_json(&req.body)?;
657        let task_arn = require_str(&body, "TaskArn")?.to_string();
658        let mut accounts = self.state.write();
659        let st = accounts.get_or_create(&req.account_id);
660        if st.tag_sync_tasks.remove(&task_arn).is_none() {
661            // CancelTagSyncTask does not declare NotFoundException; a missing
662            // task is reported as a BadRequest.
663            return Err(bad_request(&format!(
664                "The specified tag-sync task '{task_arn}' was not found."
665            )));
666        }
667        Ok(AwsResponse::json_value(StatusCode::OK, json!({})))
668    }
669}
670
671#[async_trait]
672impl AwsService for ResourceGroupsService {
673    fn service_name(&self) -> &str {
674        "resource-groups"
675    }
676
677    fn supported_actions(&self) -> &[&str] {
678        RESOURCE_GROUPS_ACTIONS
679    }
680
681    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
682        let Some(route) = Self::resolve_route(&req) else {
683            return Err(AwsServiceError::aws_error(
684                StatusCode::NOT_FOUND,
685                "UnknownOperationException",
686                format!("Unknown operation: {} {}", req.method, req.raw_path),
687            ));
688        };
689        let mutates = route.mutates();
690        let result = match route {
691            Route::CreateGroup => self.create_group(&req),
692            Route::GetGroup => self.get_group(&req),
693            Route::GetGroupQuery => self.get_group_query(&req),
694            Route::UpdateGroup => self.update_group(&req),
695            Route::UpdateGroupQuery => self.update_group_query(&req),
696            Route::DeleteGroup => self.delete_group(&req),
697            Route::ListGroups => self.list_groups(&req),
698            Route::GroupResources => self.group_resources(&req),
699            Route::UngroupResources => self.ungroup_resources(&req),
700            Route::ListGroupResources => self.list_group_resources(&req),
701            Route::SearchResources => self.search_resources(&req),
702            Route::GetGroupConfiguration => self.get_group_configuration(&req),
703            Route::PutGroupConfiguration => self.put_group_configuration(&req),
704            Route::GetTags(arn) => self.get_tags(&req, &arn),
705            Route::Tag(arn) => self.tag(&req, &arn),
706            Route::Untag(arn) => self.untag(&req, &arn),
707            Route::GetAccountSettings => self.get_account_settings(&req),
708            Route::UpdateAccountSettings => self.update_account_settings(&req),
709            Route::ListGroupingStatuses => self.list_grouping_statuses(&req),
710            Route::StartTagSyncTask => self.start_tag_sync_task(&req),
711            Route::GetTagSyncTask => self.get_tag_sync_task(&req),
712            Route::ListTagSyncTasks => self.list_tag_sync_tasks(&req),
713            Route::CancelTagSyncTask => self.cancel_tag_sync_task(&req),
714        };
715        if mutates && result.is_ok() {
716            self.persist().await;
717        }
718        result
719    }
720}
721
722// ---------------------------------------------------------------------------
723// JSON builders
724// ---------------------------------------------------------------------------
725
726fn group_json(g: &ResourceGroup) -> Value {
727    let mut v = json!({ "GroupArn": g.arn, "Name": g.name });
728    if let Some(d) = &g.description {
729        v["Description"] = json!(d);
730    }
731    if let Some(c) = g.criticality {
732        v["Criticality"] = json!(c);
733    }
734    if let Some(o) = &g.owner {
735        v["Owner"] = json!(o);
736    }
737    if let Some(dn) = &g.display_name {
738        v["DisplayName"] = json!(dn);
739    }
740    if !g.application_tag.is_empty() {
741        v["ApplicationTag"] = json!(g.application_tag);
742    }
743    v
744}
745
746fn group_identifier_json(g: &ResourceGroup) -> Value {
747    let mut v = json!({ "GroupName": g.name, "GroupArn": g.arn });
748    if let Some(d) = &g.description {
749        v["Description"] = json!(d);
750    }
751    if let Some(c) = g.criticality {
752        v["Criticality"] = json!(c);
753    }
754    if let Some(o) = &g.owner {
755        v["Owner"] = json!(o);
756    }
757    if let Some(dn) = &g.display_name {
758        v["DisplayName"] = json!(dn);
759    }
760    v
761}
762
763fn resource_query_json(q: &ResourceQuery) -> Value {
764    json!({ "Type": q.type_, "Query": q.query })
765}
766
767fn group_configuration_json(g: &ResourceGroup) -> Value {
768    json!({
769        "Configuration": g.configuration,
770        "Status": "UPDATE_COMPLETE",
771    })
772}
773
774fn resource_identifier_json(arn: &str) -> Value {
775    let mut v = json!({ "ResourceArn": arn });
776    if let Some(t) = resource_type_from_arn(arn) {
777        v["ResourceType"] = json!(t);
778    }
779    v
780}
781
782fn account_settings_json(s: &AccountSettings) -> Value {
783    let desired = s
784        .desired_status
785        .clone()
786        .unwrap_or_else(|| "INACTIVE".into());
787    json!({
788        "GroupLifecycleEventsDesiredStatus": desired,
789        "GroupLifecycleEventsStatus": desired,
790    })
791}
792
793fn tag_sync_task_json(t: &TagSyncTask) -> Value {
794    json!({
795        "GroupArn": t.group_arn,
796        "GroupName": t.group_name,
797        "TaskArn": t.task_arn,
798        "TagKey": t.tag_key,
799        "TagValue": t.tag_value,
800        "ResourceQuery": t.resource_query.as_ref().map(resource_query_json),
801        "RoleArn": t.role_arn,
802        "Status": t.status,
803        "CreatedAt": t.created_at.timestamp() as f64,
804    })
805}
806
807// ---------------------------------------------------------------------------
808// Parsing + helpers
809// ---------------------------------------------------------------------------
810
811fn parse_json(body: &[u8]) -> Result<Value, AwsServiceError> {
812    if body.is_empty() {
813        return Ok(json!({}));
814    }
815    serde_json::from_slice(body).map_err(|e| bad_request(&format!("invalid request body: {e}")))
816}
817
818fn require_str<'a>(body: &'a Value, field: &str) -> Result<&'a str, AwsServiceError> {
819    body.get(field)
820        .and_then(|v| v.as_str())
821        .filter(|s| !s.is_empty())
822        .ok_or_else(|| bad_request(&format!("{field} is required.")))
823}
824
825fn opt_string(body: &Value, field: &str) -> Option<String> {
826    body.get(field).and_then(|v| v.as_str()).map(String::from)
827}
828
829/// GetGroup/GetGroupQuery/etc. accept the group as `GroupName` (legacy) or
830/// `Group` (name or ARN).
831fn group_key(body: &Value) -> Result<String, AwsServiceError> {
832    group_key_field(body, "GroupName", "Group")
833}
834
835fn group_key_field(body: &Value, a: &str, b: &str) -> Result<String, AwsServiceError> {
836    body.get(a)
837        .or_else(|| body.get(b))
838        .and_then(|v| v.as_str())
839        .filter(|s| !s.is_empty())
840        .map(String::from)
841        .ok_or_else(|| bad_request("A group name or ARN is required."))
842}
843
844fn parse_resource_query(v: Option<&Value>) -> Result<Option<ResourceQuery>, AwsServiceError> {
845    let Some(q) = v else { return Ok(None) };
846    if q.is_null() {
847        return Ok(None);
848    }
849    let type_ = q
850        .get("Type")
851        .and_then(|v| v.as_str())
852        .ok_or_else(|| bad_request("ResourceQuery.Type is required."))?;
853    if type_ != "TAG_FILTERS_1_0" && type_ != "CLOUDFORMATION_STACK_1_0" {
854        return Err(bad_request(
855            "ResourceQuery.Type must be TAG_FILTERS_1_0 or CLOUDFORMATION_STACK_1_0.",
856        ));
857    }
858    let query = q
859        .get("Query")
860        .and_then(|v| v.as_str())
861        .ok_or_else(|| bad_request("ResourceQuery.Query is required."))?;
862    // Query must itself be valid JSON.
863    serde_json::from_str::<Value>(query)
864        .map_err(|e| bad_request(&format!("ResourceQuery.Query is not valid JSON: {e}")))?;
865    Ok(Some(ResourceQuery {
866        type_: type_.to_string(),
867        query: query.to_string(),
868    }))
869}
870
871fn parse_tags(v: Option<&Value>) -> BTreeMap<String, String> {
872    v.and_then(|t| t.as_object())
873        .map(|o| {
874            o.iter()
875                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
876                .collect()
877        })
878        .unwrap_or_default()
879}
880
881fn string_list(v: Option<&Value>) -> Vec<String> {
882    v.and_then(|a| a.as_array())
883        .map(|a| {
884            a.iter()
885                .filter_map(|s| s.as_str().map(String::from))
886                .collect()
887        })
888        .unwrap_or_default()
889}
890
891fn validate_group_name(name: &str) -> Result<(), AwsServiceError> {
892    if name.len() > 300
893        || !name
894            .chars()
895            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
896    {
897        return Err(bad_request(
898            "Group name must be 1-300 chars of [A-Za-z0-9._-].",
899        ));
900    }
901    Ok(())
902}
903
904fn validate_max_results(body: &Value) -> Result<(), AwsServiceError> {
905    if let Some(v) = body.get("MaxResults") {
906        let n = v
907            .as_i64()
908            .ok_or_else(|| bad_request("MaxResults must be an integer."))?;
909        if !(1..=50).contains(&n) {
910            return Err(bad_request("MaxResults must be between 1 and 50."));
911        }
912    }
913    Ok(())
914}
915
916/// `NextToken` @length is 0..=8192.
917fn validate_next_token(body: &Value) -> Result<(), AwsServiceError> {
918    if let Some(t) = body.get("NextToken").and_then(|v| v.as_str()) {
919        if t.len() > 8192 {
920            return Err(bad_request("NextToken must be at most 8192 characters."));
921        }
922    }
923    Ok(())
924}
925
926/// Fold `@httpQuery`-bound `maxResults`/`nextToken` (used by ListGroups) into
927/// the body view so the shared validation + pagination helpers see them.
928fn inject_query_pagination(req: &AwsRequest, body: &mut Value) {
929    if body.get("MaxResults").is_none() {
930        if let Some(v) = req.query_params.get("maxResults") {
931            body["MaxResults"] = v
932                .parse::<i64>()
933                .map(|n| json!(n))
934                .unwrap_or_else(|_| json!(v));
935        }
936    }
937    if body.get("NextToken").is_none() {
938        if let Some(v) = req.query_params.get("nextToken") {
939            body["NextToken"] = json!(v);
940        }
941    }
942}
943
944/// Opaque zero-based-offset pagination. A never-issued NextToken yields an
945/// empty terminal page rather than restarting page one.
946fn paginate(items: Vec<Value>, body: &Value) -> (Vec<Value>, Option<String>) {
947    let max = body
948        .get("MaxResults")
949        .and_then(|v| v.as_i64())
950        .map(|n| n as usize)
951        .unwrap_or(50);
952    let start = match body.get("NextToken").and_then(|v| v.as_str()) {
953        Some(t) => match t.parse::<usize>() {
954            Ok(n) => n,
955            Err(_) => return (Vec::new(), None),
956        },
957        None => 0,
958    };
959    let total = items.len();
960    if start >= total {
961        return (Vec::new(), None);
962    }
963    let end = start.saturating_add(max).min(total);
964    let next = (end < total).then(|| end.to_string());
965    (items[start..end].to_vec(), next)
966}
967
968fn decode(seg: &str) -> String {
969    percent_decode_str(seg).decode_utf8_lossy().into_owned()
970}
971
972/// A 26-char lowercase-alphanumeric id, matching AWS's group/tag-sync-task id
973/// shape (`[a-z0-9]{26}`).
974fn short_id() -> String {
975    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
976    let mut bytes = uuid::Uuid::new_v4().into_bytes().to_vec();
977    bytes.extend_from_slice(&uuid::Uuid::new_v4().into_bytes()[..10]);
978    bytes[..26]
979        .iter()
980        .map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char)
981        .collect()
982}
983
984fn group_arn(region: &str, account: &str, name: &str) -> String {
985    format!(
986        "arn:aws:resource-groups:{region}:{account}:group/{name}/{}",
987        short_id()
988    )
989}
990
991/// Tag-sync-task ARN nests under the group ARN:
992/// `.../group/<name>/<group-id>/tag-sync-task/<task-id>`.
993fn tag_sync_task_arn(group_arn: &str) -> String {
994    format!("{group_arn}/tag-sync-task/{}", short_id())
995}
996
997/// Best-effort `AWS::Service::Type` from an ARN. Returns `None` when the ARN
998/// shape isn't recognized rather than fabricating a type.
999fn resource_type_from_arn(arn: &str) -> Option<String> {
1000    let parts: Vec<&str> = arn.split(':').collect();
1001    if parts.len() < 6 || parts[0] != "arn" {
1002        return None;
1003    }
1004    let service = parts[2];
1005    if service.is_empty() {
1006        return None;
1007    }
1008    let resource_seg = parts[5];
1009    let restype = resource_seg
1010        .split(['/', ':'])
1011        .next()
1012        .filter(|s| !s.is_empty())?;
1013    Some(format!(
1014        "AWS::{}::{}",
1015        titlecase(service),
1016        titlecase(restype)
1017    ))
1018}
1019
1020fn titlecase(s: &str) -> String {
1021    let mut c = s.chars();
1022    match c.next() {
1023        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1024        None => String::new(),
1025    }
1026}
1027
1028fn bad_request(msg: &str) -> AwsServiceError {
1029    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "BadRequestException", msg)
1030}
1031
1032fn not_found(what: &str) -> AwsServiceError {
1033    AwsServiceError::aws_error(
1034        StatusCode::NOT_FOUND,
1035        "NotFoundException",
1036        format!("The specified group or resource '{what}' was not found."),
1037    )
1038}