1use std::sync::Arc;
11
12use async_trait::async_trait;
13use http::StatusCode;
14use serde_json::{json, Map, Value};
15use tokio::sync::Mutex as AsyncMutex;
16use uuid::Uuid;
17
18use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
19use fakecloud_persistence::SnapshotStore;
20
21use crate::persistence::save_snapshot;
22use crate::state::{CeData, SharedCeState};
23use crate::validate::validate_input;
24
25#[cfg(test)]
26mod tests;
27
28pub const CE_ACTIONS: &[&str] = &[
30 "CreateAnomalyMonitor",
31 "CreateAnomalySubscription",
32 "CreateCostCategoryDefinition",
33 "DeleteAnomalyMonitor",
34 "DeleteAnomalySubscription",
35 "DeleteCostCategoryDefinition",
36 "DescribeCostCategoryDefinition",
37 "GetAnomalies",
38 "GetAnomalyMonitors",
39 "GetAnomalySubscriptions",
40 "GetApproximateUsageRecords",
41 "GetCommitmentPurchaseAnalysis",
42 "GetCostAndUsage",
43 "GetCostAndUsageComparisons",
44 "GetCostAndUsageWithResources",
45 "GetCostCategories",
46 "GetCostComparisonDrivers",
47 "GetCostForecast",
48 "GetDimensionValues",
49 "GetReservationCoverage",
50 "GetReservationPurchaseRecommendation",
51 "GetReservationUtilization",
52 "GetRightsizingRecommendation",
53 "GetSavingsPlanPurchaseRecommendationDetails",
54 "GetSavingsPlansCoverage",
55 "GetSavingsPlansPurchaseRecommendation",
56 "GetSavingsPlansUtilization",
57 "GetSavingsPlansUtilizationDetails",
58 "GetTags",
59 "GetUsageForecast",
60 "ListCommitmentPurchaseAnalyses",
61 "ListCostAllocationTagBackfillHistory",
62 "ListCostAllocationTags",
63 "ListCostCategoryDefinitions",
64 "ListCostCategoryResourceAssociations",
65 "ListSavingsPlansPurchaseRecommendationGeneration",
66 "ListTagsForResource",
67 "ProvideAnomalyFeedback",
68 "StartCommitmentPurchaseAnalysis",
69 "StartCostAllocationTagBackfill",
70 "StartSavingsPlansPurchaseRecommendationGeneration",
71 "TagResource",
72 "UntagResource",
73 "UpdateAnomalyMonitor",
74 "UpdateAnomalySubscription",
75 "UpdateCostAllocationTagsStatus",
76 "UpdateCostCategoryDefinition",
77];
78
79pub struct CeService {
80 state: SharedCeState,
81 snapshot_store: Option<Arc<dyn SnapshotStore>>,
82 snapshot_lock: Arc<AsyncMutex<()>>,
83}
84
85impl CeService {
86 pub fn new(state: SharedCeState) -> Self {
87 Self {
88 state,
89 snapshot_store: None,
90 snapshot_lock: Arc::new(AsyncMutex::new(())),
91 }
92 }
93
94 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
95 self.snapshot_store = Some(store);
96 self
97 }
98
99 async fn save(&self) {
100 save_snapshot(
101 &self.state,
102 self.snapshot_store.clone(),
103 &self.snapshot_lock,
104 )
105 .await;
106 }
107}
108
109#[async_trait]
110impl AwsService for CeService {
111 fn service_name(&self) -> &str {
112 "ce"
113 }
114
115 async fn handle(&self, request: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
116 let mutates = is_mutating(request.action.as_str());
117 let result = dispatch(self, &request);
118 if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
119 self.save().await;
120 }
121 result
122 }
123
124 fn supported_actions(&self) -> &[&str] {
125 CE_ACTIONS
126 }
127}
128
129fn is_mutating(action: &str) -> bool {
131 action.starts_with("Create")
132 || action.starts_with("Delete")
133 || action.starts_with("Update")
134 || action.starts_with("Start")
135 || action.starts_with("Tag")
136 || action.starts_with("Untag")
137 || action == "ProvideAnomalyFeedback"
138}
139
140fn dispatch(s: &CeService, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
141 let b = parse(req)?;
142 validate_input(req.action.as_str(), &b)?;
143 let ctx = Ctx {
144 account: req.account_id.clone(),
145 };
146 match req.action.as_str() {
147 "CreateAnomalyMonitor" => s.create_anomaly_monitor(&ctx, &b),
149 "GetAnomalyMonitors" => s.get_anomaly_monitors(&ctx, &b),
150 "UpdateAnomalyMonitor" => s.update_anomaly_monitor(&ctx, &b),
151 "DeleteAnomalyMonitor" => s.delete_anomaly_monitor(&ctx, &b),
152 "CreateAnomalySubscription" => s.create_anomaly_subscription(&ctx, &b),
154 "GetAnomalySubscriptions" => s.get_anomaly_subscriptions(&ctx, &b),
155 "UpdateAnomalySubscription" => s.update_anomaly_subscription(&ctx, &b),
156 "DeleteAnomalySubscription" => s.delete_anomaly_subscription(&ctx, &b),
157 "GetAnomalies" => s.get_anomalies(&ctx, &b),
159 "ProvideAnomalyFeedback" => s.provide_anomaly_feedback(&ctx, &b),
160 "CreateCostCategoryDefinition" => s.create_cost_category(&ctx, &b),
162 "DescribeCostCategoryDefinition" => s.describe_cost_category(&ctx, &b),
163 "UpdateCostCategoryDefinition" => s.update_cost_category(&ctx, &b),
164 "DeleteCostCategoryDefinition" => s.delete_cost_category(&ctx, &b),
165 "ListCostCategoryDefinitions" => s.list_cost_categories(&ctx, &b),
166 "ListCostCategoryResourceAssociations" => s.list_cost_category_associations(&ctx, &b),
167 "ListCostAllocationTags" => s.list_cost_allocation_tags(&ctx, &b),
169 "UpdateCostAllocationTagsStatus" => s.update_cost_allocation_tags_status(&ctx, &b),
170 "StartCostAllocationTagBackfill" => s.start_backfill(&ctx, &b),
171 "ListCostAllocationTagBackfillHistory" => s.list_backfill_history(&ctx, &b),
172 "TagResource" => s.tag_resource(&ctx, &b),
174 "UntagResource" => s.untag_resource(&ctx, &b),
175 "ListTagsForResource" => s.list_tags_for_resource(&ctx, &b),
176 "StartCommitmentPurchaseAnalysis" => s.start_commitment_analysis(&ctx, &b),
178 "GetCommitmentPurchaseAnalysis" => s.get_commitment_analysis(&ctx, &b),
179 "ListCommitmentPurchaseAnalyses" => s.list_commitment_analyses(&ctx, &b),
180 "StartSavingsPlansPurchaseRecommendationGeneration" => s.start_sp_generation(&ctx, &b),
181 "ListSavingsPlansPurchaseRecommendationGeneration" => s.list_sp_generation(&ctx, &b),
182 "GetCostAndUsage" => cost_and_usage(&b, false),
184 "GetCostAndUsageWithResources" => cost_and_usage(&b, true),
185 "GetCostAndUsageComparisons" => cost_and_usage_comparisons(&b),
186 "GetCostComparisonDrivers" => cost_comparison_drivers(&b),
187 "GetCostCategories" => get_cost_categories_report(&b),
188 "GetDimensionValues" => get_dimension_values(&b),
189 "GetTags" => get_tags_report(&b),
190 "GetCostForecast" => forecast(&b),
191 "GetUsageForecast" => forecast(&b),
192 "GetApproximateUsageRecords" => approximate_usage_records(&b),
193 "GetReservationCoverage" => reservation_coverage(&b),
194 "GetReservationUtilization" => reservation_utilization(&b),
195 "GetReservationPurchaseRecommendation" => reservation_purchase_recommendation(&b),
196 "GetRightsizingRecommendation" => rightsizing_recommendation(&b),
197 "GetSavingsPlansCoverage" => savings_plans_coverage(&b),
198 "GetSavingsPlansUtilization" => savings_plans_utilization(&b),
199 "GetSavingsPlansUtilizationDetails" => savings_plans_utilization_details(&b),
200 "GetSavingsPlansPurchaseRecommendation" => savings_plans_purchase_recommendation(&b),
201 "GetSavingsPlanPurchaseRecommendationDetails" => {
202 savings_plan_purchase_recommendation_details(&b)
203 }
204 _ => Err(AwsServiceError::action_not_implemented(
205 s.service_name(),
206 &req.action,
207 )),
208 }
209}
210
211struct Ctx {
216 account: String,
217}
218
219fn ok(v: Value) -> Result<AwsResponse, AwsServiceError> {
220 Ok(AwsResponse::json_value(StatusCode::OK, v))
221}
222
223fn empty_ok() -> Result<AwsResponse, AwsServiceError> {
224 ok(json!({}))
225}
226
227fn parse(req: &AwsRequest) -> Result<Value, AwsServiceError> {
228 if req.body.is_empty() {
229 return Ok(json!({}));
230 }
231 serde_json::from_slice(&req.body)
232 .map_err(|e| validation(&format!("Request body is malformed: {e}")))
233}
234
235fn err(code: &str, msg: &str) -> AwsServiceError {
236 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, code, msg)
237}
238
239fn validation(msg: &str) -> AwsServiceError {
240 err("ValidationException", msg)
241}
242
243fn unknown_monitor(arn: &str) -> AwsServiceError {
244 err(
245 "UnknownMonitorException",
246 &format!("Cannot find the anomaly monitor: {arn}."),
247 )
248}
249
250fn unknown_subscription(arn: &str) -> AwsServiceError {
251 err(
252 "UnknownSubscriptionException",
253 &format!("Cannot find the anomaly subscription: {arn}."),
254 )
255}
256
257fn cost_category_not_found(arn: &str) -> AwsServiceError {
258 err(
259 "ResourceNotFoundException",
260 &format!("Cannot find the cost category: {arn}."),
261 )
262}
263
264fn req_str<'a>(b: &'a Value, f: &str) -> Result<&'a str, AwsServiceError> {
270 b.get(f)
271 .and_then(Value::as_str)
272 .ok_or_else(|| validation(&format!("{f} is a required field.")))
273}
274
275fn req_present(b: &Value, f: &str) -> Result<(), AwsServiceError> {
277 match b.get(f) {
278 Some(v) if !v.is_null() => Ok(()),
279 _ => Err(validation(&format!("{f} is a required field."))),
280 }
281}
282
283fn opt_str<'a>(b: &'a Value, f: &str) -> Option<&'a str> {
284 b.get(f).and_then(Value::as_str)
285}
286
287fn now_zoned() -> String {
290 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
291}
292
293fn today() -> String {
295 chrono::Utc::now().format("%Y-%m-%d").to_string()
296}
297
298fn new_uuid() -> String {
299 Uuid::new_v4().to_string()
300}
301
302fn monitor_arn(account: &str) -> String {
303 format!("arn:aws:ce::{account}:anomalymonitor/{}", new_uuid())
304}
305
306fn subscription_arn(account: &str) -> String {
307 format!("arn:aws:ce::{account}:anomalysubscription/{}", new_uuid())
308}
309
310fn cost_category_arn(account: &str) -> String {
311 format!("arn:aws:ce::{account}:costcategory/{}", new_uuid())
312}
313
314fn interval_bounds(tp: Option<&Value>) -> (String, String) {
317 let start = tp
318 .and_then(|v| v.get("Start"))
319 .and_then(Value::as_str)
320 .unwrap_or("2024-01-01")
321 .to_string();
322 let end = tp
323 .and_then(|v| v.get("End"))
324 .and_then(Value::as_str)
325 .unwrap_or("2024-01-02")
326 .to_string();
327 (start, end)
328}
329
330fn date_interval(start: &str, end: &str) -> Value {
331 json!({ "Start": start, "End": end })
332}
333
334fn metric_unit(metric: &str) -> &'static str {
341 let upper = metric.to_ascii_uppercase();
342 if upper.contains("USAGE") || upper.contains("NORMALIZED") {
343 "N/A"
344 } else {
345 "USD"
346 }
347}
348
349fn metric_value(unit: &str) -> Value {
351 json!({ "Amount": "0", "Unit": unit })
352}
353
354const MAX_BUCKETS: usize = 100_000;
359
360type BucketSplit = (Vec<(String, String)>, Option<String>);
363
364fn split_buckets(start: &str, end: &str, granularity: &str) -> Option<BucketSplit> {
371 use chrono::{Datelike, Duration, NaiveDate};
372 let fmt = "%Y-%m-%d";
373 let s = NaiveDate::parse_from_str(start.get(0..10)?, fmt).ok()?;
374 let e = NaiveDate::parse_from_str(end.get(0..10)?, fmt).ok()?;
375 if e <= s {
376 return None;
377 }
378 let mut out = Vec::new();
379 let mut resume: Option<String> = None;
380 let mut cur = s;
381 while cur < e {
382 if out.len() >= MAX_BUCKETS {
383 resume = Some(cur.format(fmt).to_string());
384 break;
385 }
386 let next = match granularity {
387 "DAILY" => cur + Duration::days(1),
388 "MONTHLY" => {
389 let (mut y, mut m) = (cur.year(), cur.month());
390 if m == 12 {
391 y += 1;
392 m = 1;
393 } else {
394 m += 1;
395 }
396 NaiveDate::from_ymd_opt(y, m, 1).unwrap_or(e).min(e)
397 }
398 _ => return None,
402 };
403 out.push((cur.format(fmt).to_string(), next.format(fmt).to_string()));
404 cur = next;
405 }
406 if out.is_empty() {
407 None
408 } else {
409 Some((out, resume))
410 }
411}
412
413fn paginate(rows: Vec<Value>, b: &Value, default_max: usize) -> (Vec<Value>, Option<String>) {
415 let start = b
416 .get("NextToken")
417 .or_else(|| b.get("NextPageToken"))
418 .and_then(Value::as_str)
419 .and_then(|t| t.parse::<usize>().ok())
420 .unwrap_or(0);
421 let max = b
422 .get("MaxResults")
423 .or_else(|| b.get("PageSize"))
424 .and_then(Value::as_u64)
425 .map(|m| m.clamp(1, 10_000) as usize)
426 .unwrap_or(default_max);
427 let end = start.saturating_add(max).min(rows.len());
428 let page = rows.get(start..end).unwrap_or(&[]).to_vec();
429 let next = if end < rows.len() {
430 Some(format!("{end}"))
431 } else {
432 None
433 };
434 (page, next)
435}
436
437fn copy_fields(src: &Value, dst: &mut Map<String, Value>, fields: &[&str]) {
439 for f in fields {
440 if let Some(v) = src.get(*f) {
441 dst.insert((*f).to_string(), v.clone());
442 }
443 }
444}
445
446fn store_resource_tags(data: &mut CeData, key: &str, b: &Value) {
448 if let Some(tags) = b.get("ResourceTags").and_then(Value::as_array) {
449 let entry = data.tags.entry(key.to_string()).or_default();
450 for t in tags {
451 if let Some(k) = t.get("Key").and_then(Value::as_str) {
452 let v = t.get("Value").and_then(Value::as_str).unwrap_or("");
453 entry.insert(k.to_string(), v.to_string());
454 }
455 }
456 }
457}
458
459impl CeService {
462 fn create_anomaly_monitor(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
463 req_present(b, "AnomalyMonitor")?;
464 let arn = monitor_arn(&ctx.account);
465 let mut mon = b
466 .get("AnomalyMonitor")
467 .and_then(Value::as_object)
468 .cloned()
469 .unwrap_or_default();
470 mon.insert("MonitorArn".into(), json!(arn));
471 mon.entry("CreationDate").or_insert(json!(today()));
472 mon.insert("LastUpdatedDate".into(), json!(today()));
473 let mut guard = self.state.write();
474 let data = guard.get_or_create(&ctx.account);
475 store_resource_tags(data, &arn, b);
476 data.anomaly_monitors
477 .insert(arn.clone(), Value::Object(mon));
478 ok(json!({ "MonitorArn": arn }))
479 }
480
481 fn get_anomaly_monitors(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
482 let wanted: Option<Vec<String>> =
483 b.get("MonitorArnList").and_then(Value::as_array).map(|a| {
484 a.iter()
485 .filter_map(|v| v.as_str().map(str::to_string))
486 .collect()
487 });
488 let guard = self.state.read();
489 let rows: Vec<Value> = guard
490 .get(&ctx.account)
491 .map(|d| {
492 d.anomaly_monitors
493 .iter()
494 .filter(|(arn, _)| wanted.as_ref().is_none_or(|w| w.contains(arn)))
495 .map(|(_, v)| v.clone())
496 .collect()
497 })
498 .unwrap_or_default();
499 let (page, next) = paginate(rows, b, 100);
500 let mut out = Map::new();
501 out.insert("AnomalyMonitors".into(), Value::Array(page));
502 if let Some(t) = next {
503 out.insert("NextPageToken".into(), json!(t));
504 }
505 ok(Value::Object(out))
506 }
507
508 fn update_anomaly_monitor(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
509 let arn = req_str(b, "MonitorArn")?.to_string();
510 let mut guard = self.state.write();
511 let data = guard.get_or_create(&ctx.account);
512 let mon = data
513 .anomaly_monitors
514 .get_mut(&arn)
515 .ok_or_else(|| unknown_monitor(&arn))?;
516 if let Some(obj) = mon.as_object_mut() {
517 if let Some(name) = opt_str(b, "MonitorName") {
518 obj.insert("MonitorName".into(), json!(name));
519 }
520 obj.insert("LastUpdatedDate".into(), json!(today()));
521 }
522 ok(json!({ "MonitorArn": arn }))
523 }
524
525 fn delete_anomaly_monitor(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
526 let arn = req_str(b, "MonitorArn")?.to_string();
527 let mut guard = self.state.write();
528 let data = guard.get_or_create(&ctx.account);
529 if data.anomaly_monitors.remove(&arn).is_none() {
530 return Err(unknown_monitor(&arn));
531 }
532 data.tags.remove(&arn);
533 empty_ok()
534 }
535}
536
537impl CeService {
540 fn create_anomaly_subscription(
541 &self,
542 ctx: &Ctx,
543 b: &Value,
544 ) -> Result<AwsResponse, AwsServiceError> {
545 req_present(b, "AnomalySubscription")?;
546 let arn = subscription_arn(&ctx.account);
547 let mut sub = b
548 .get("AnomalySubscription")
549 .and_then(Value::as_object)
550 .cloned()
551 .unwrap_or_default();
552 sub.insert("SubscriptionArn".into(), json!(arn));
553 sub.entry("AccountId").or_insert(json!(ctx.account));
554 let mut guard = self.state.write();
555 let data = guard.get_or_create(&ctx.account);
556 store_resource_tags(data, &arn, b);
557 data.anomaly_subscriptions
558 .insert(arn.clone(), Value::Object(sub));
559 ok(json!({ "SubscriptionArn": arn }))
560 }
561
562 fn get_anomaly_subscriptions(
563 &self,
564 ctx: &Ctx,
565 b: &Value,
566 ) -> Result<AwsResponse, AwsServiceError> {
567 let wanted: Option<Vec<String>> = b
568 .get("SubscriptionArnList")
569 .and_then(Value::as_array)
570 .map(|a| {
571 a.iter()
572 .filter_map(|v| v.as_str().map(str::to_string))
573 .collect()
574 });
575 let monitor_filter = opt_str(b, "MonitorArn").map(str::to_string);
576 let guard = self.state.read();
577 let rows: Vec<Value> = guard
578 .get(&ctx.account)
579 .map(|d| {
580 d.anomaly_subscriptions
581 .iter()
582 .filter(|(arn, _)| wanted.as_ref().is_none_or(|w| w.contains(arn)))
583 .filter(|(_, v)| {
584 monitor_filter.as_deref().is_none_or(|m| {
585 v.get("MonitorArnList")
586 .and_then(Value::as_array)
587 .map(|l| l.iter().any(|x| x.as_str() == Some(m)))
588 .unwrap_or(false)
589 })
590 })
591 .map(|(_, v)| v.clone())
592 .collect()
593 })
594 .unwrap_or_default();
595 let (page, next) = paginate(rows, b, 100);
596 let mut out = Map::new();
597 out.insert("AnomalySubscriptions".into(), Value::Array(page));
598 if let Some(t) = next {
599 out.insert("NextPageToken".into(), json!(t));
600 }
601 ok(Value::Object(out))
602 }
603
604 fn update_anomaly_subscription(
605 &self,
606 ctx: &Ctx,
607 b: &Value,
608 ) -> Result<AwsResponse, AwsServiceError> {
609 let arn = req_str(b, "SubscriptionArn")?.to_string();
610 let mut guard = self.state.write();
611 let data = guard.get_or_create(&ctx.account);
612 let sub = data
613 .anomaly_subscriptions
614 .get_mut(&arn)
615 .ok_or_else(|| unknown_subscription(&arn))?;
616 if let Some(obj) = sub.as_object_mut() {
617 copy_fields(
618 b,
619 obj,
620 &[
621 "Threshold",
622 "Frequency",
623 "MonitorArnList",
624 "Subscribers",
625 "ThresholdExpression",
626 ],
627 );
628 if let Some(name) = opt_str(b, "SubscriptionName") {
629 obj.insert("SubscriptionName".into(), json!(name));
630 }
631 }
632 ok(json!({ "SubscriptionArn": arn }))
633 }
634
635 fn delete_anomaly_subscription(
636 &self,
637 ctx: &Ctx,
638 b: &Value,
639 ) -> Result<AwsResponse, AwsServiceError> {
640 let arn = req_str(b, "SubscriptionArn")?.to_string();
641 let mut guard = self.state.write();
642 let data = guard.get_or_create(&ctx.account);
643 if data.anomaly_subscriptions.remove(&arn).is_none() {
644 return Err(unknown_subscription(&arn));
645 }
646 data.tags.remove(&arn);
647 empty_ok()
648 }
649}
650
651impl CeService {
654 fn get_anomalies(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
655 req_present(b, "DateInterval")?;
656 let monitor_filter = opt_str(b, "MonitorArn").map(str::to_string);
657 let feedback_filter = opt_str(b, "Feedback").map(str::to_string);
658 let guard = self.state.read();
659 let rows: Vec<Value> = guard
660 .get(&ctx.account)
661 .map(|d| {
662 d.anomalies
663 .values()
664 .filter(|a| {
665 monitor_filter
666 .as_deref()
667 .is_none_or(|m| a.get("MonitorArn").and_then(Value::as_str) == Some(m))
668 })
669 .filter(|a| {
670 feedback_filter
671 .as_deref()
672 .is_none_or(|f| a.get("Feedback").and_then(Value::as_str) == Some(f))
673 })
674 .cloned()
675 .collect()
676 })
677 .unwrap_or_default();
678 let (page, next) = paginate(rows, b, 100);
679 let mut out = Map::new();
680 out.insert("Anomalies".into(), Value::Array(page));
681 if let Some(t) = next {
682 out.insert("NextPageToken".into(), json!(t));
683 }
684 ok(Value::Object(out))
685 }
686
687 fn provide_anomaly_feedback(
688 &self,
689 ctx: &Ctx,
690 b: &Value,
691 ) -> Result<AwsResponse, AwsServiceError> {
692 let id = req_str(b, "AnomalyId")?.to_string();
693 let feedback = req_str(b, "Feedback")?.to_string();
694 let mut guard = self.state.write();
695 let data = guard.get_or_create(&ctx.account);
696 if let Some(anomaly) = data.anomalies.get_mut(&id) {
697 if let Some(obj) = anomaly.as_object_mut() {
698 obj.insert("Feedback".into(), json!(feedback));
699 }
700 }
701 ok(json!({ "AnomalyId": id }))
702 }
703}
704
705impl CeService {
708 fn build_cost_category(&self, ctx: &Ctx, arn: &str, b: &Value, effective_start: &str) -> Value {
709 let mut cc = Map::new();
710 cc.insert("CostCategoryArn".into(), json!(arn));
711 cc.insert("EffectiveStart".into(), json!(effective_start));
712 cc.insert(
713 "Name".into(),
714 b.get("Name").cloned().unwrap_or(json!("cost-category")),
715 );
716 cc.insert(
717 "RuleVersion".into(),
718 b.get("RuleVersion")
719 .cloned()
720 .unwrap_or(json!("CostCategoryExpression.v1")),
721 );
722 cc.insert("Rules".into(), b.get("Rules").cloned().unwrap_or(json!([])));
723 copy_fields(b, &mut cc, &["SplitChargeRules", "DefaultValue"]);
724 let _ = ctx;
725 Value::Object(cc)
726 }
727
728 fn create_cost_category(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
729 req_str(b, "Name")?;
730 req_str(b, "RuleVersion")?;
731 req_present(b, "Rules")?;
732 let arn = cost_category_arn(&ctx.account);
733 let effective_start = opt_str(b, "EffectiveStart")
734 .map(str::to_string)
735 .unwrap_or_else(now_zoned);
736 let cc = self.build_cost_category(ctx, &arn, b, &effective_start);
737 let mut guard = self.state.write();
738 let data = guard.get_or_create(&ctx.account);
739 store_resource_tags(data, &arn, b);
740 data.cost_categories.insert(arn.clone(), cc);
741 ok(json!({ "CostCategoryArn": arn, "EffectiveStart": effective_start }))
742 }
743
744 fn describe_cost_category(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
745 let arn = req_str(b, "CostCategoryArn")?.to_string();
746 let guard = self.state.read();
747 match guard
748 .get(&ctx.account)
749 .and_then(|d| d.cost_categories.get(&arn))
750 {
751 Some(cc) => ok(json!({ "CostCategory": cc })),
752 None => Err(cost_category_not_found(&arn)),
753 }
754 }
755
756 fn update_cost_category(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
757 let arn = req_str(b, "CostCategoryArn")?.to_string();
758 req_str(b, "RuleVersion")?;
759 req_present(b, "Rules")?;
760 let effective_start = opt_str(b, "EffectiveStart")
761 .map(str::to_string)
762 .unwrap_or_else(now_zoned);
763 let mut guard = self.state.write();
764 let data = guard.get_or_create(&ctx.account);
765 let existing = data
766 .cost_categories
767 .get(&arn)
768 .cloned()
769 .ok_or_else(|| cost_category_not_found(&arn))?;
770 let mut merged = b.clone();
772 if let (Some(obj), Some(name)) = (merged.as_object_mut(), existing.get("Name")) {
773 obj.entry("Name").or_insert(name.clone());
774 }
775 let cc = self.build_cost_category(ctx, &arn, &merged, &effective_start);
776 data.cost_categories.insert(arn.clone(), cc);
777 ok(json!({ "CostCategoryArn": arn, "EffectiveStart": effective_start }))
778 }
779
780 fn delete_cost_category(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
781 let arn = req_str(b, "CostCategoryArn")?.to_string();
782 let mut guard = self.state.write();
783 let data = guard.get_or_create(&ctx.account);
784 if data.cost_categories.remove(&arn).is_none() {
785 return Err(cost_category_not_found(&arn));
786 }
787 data.cost_category_associations.remove(&arn);
788 data.tags.remove(&arn);
789 ok(json!({ "CostCategoryArn": arn, "EffectiveEnd": now_zoned() }))
790 }
791
792 fn list_cost_categories(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
793 let guard = self.state.read();
794 let rows: Vec<Value> = guard
795 .get(&ctx.account)
796 .map(|d| {
797 d.cost_categories
798 .values()
799 .map(|cc| {
800 let mut r = Map::new();
801 copy_fields(
802 cc,
803 &mut r,
804 &["CostCategoryArn", "Name", "EffectiveStart", "EffectiveEnd"],
805 );
806 let n = cc
807 .get("Rules")
808 .and_then(Value::as_array)
809 .map(|a| a.len())
810 .unwrap_or(0);
811 r.insert("NumberOfRules".into(), json!(n));
812 copy_fields(cc, &mut r, &["DefaultValue"]);
813 Value::Object(r)
814 })
815 .collect()
816 })
817 .unwrap_or_default();
818 let (page, next) = paginate(rows, b, 100);
819 let mut out = Map::new();
820 out.insert("CostCategoryReferences".into(), Value::Array(page));
821 if let Some(t) = next {
822 out.insert("NextToken".into(), json!(t));
823 }
824 ok(Value::Object(out))
825 }
826
827 fn list_cost_category_associations(
828 &self,
829 ctx: &Ctx,
830 b: &Value,
831 ) -> Result<AwsResponse, AwsServiceError> {
832 let guard = self.state.read();
833 let data = guard.get(&ctx.account);
834 let rows: Vec<Value> = if let Some(arn) = opt_str(b, "CostCategoryArn") {
835 if !data
836 .map(|d| d.cost_categories.contains_key(arn))
837 .unwrap_or(false)
838 {
839 return Err(cost_category_not_found(arn));
840 }
841 data.and_then(|d| d.cost_category_associations.get(arn))
842 .cloned()
843 .unwrap_or_default()
844 } else {
845 data.map(|d| {
846 d.cost_category_associations
847 .values()
848 .flatten()
849 .cloned()
850 .collect()
851 })
852 .unwrap_or_default()
853 };
854 let (page, next) = paginate(rows, b, 100);
855 let mut out = Map::new();
856 out.insert(
857 "CostCategoryResourceAssociations".into(),
858 Value::Array(page),
859 );
860 if let Some(t) = next {
861 out.insert("NextToken".into(), json!(t));
862 }
863 ok(Value::Object(out))
864 }
865}
866
867impl CeService {
870 fn list_cost_allocation_tags(
871 &self,
872 ctx: &Ctx,
873 b: &Value,
874 ) -> Result<AwsResponse, AwsServiceError> {
875 let status_filter = opt_str(b, "Status").map(str::to_string);
876 let type_filter = opt_str(b, "Type").map(str::to_string);
877 let key_filter: Option<Vec<String>> = b.get("TagKeys").and_then(Value::as_array).map(|a| {
878 a.iter()
879 .filter_map(|v| v.as_str().map(str::to_string))
880 .collect()
881 });
882 let guard = self.state.read();
883 let rows: Vec<Value> = guard
884 .get(&ctx.account)
885 .map(|d| {
886 d.cost_allocation_tags
887 .iter()
888 .filter(|(k, v)| {
889 status_filter
890 .as_deref()
891 .is_none_or(|s| v.get("Status").and_then(Value::as_str) == Some(s))
892 && type_filter
893 .as_deref()
894 .is_none_or(|t| v.get("Type").and_then(Value::as_str) == Some(t))
895 && key_filter.as_ref().is_none_or(|ks| ks.contains(k))
896 })
897 .map(|(_, v)| v.clone())
898 .collect()
899 })
900 .unwrap_or_default();
901 let (page, next) = paginate(rows, b, 100);
902 let mut out = Map::new();
903 out.insert("CostAllocationTags".into(), Value::Array(page));
904 if let Some(t) = next {
905 out.insert("NextToken".into(), json!(t));
906 }
907 ok(Value::Object(out))
908 }
909
910 fn update_cost_allocation_tags_status(
911 &self,
912 ctx: &Ctx,
913 b: &Value,
914 ) -> Result<AwsResponse, AwsServiceError> {
915 req_present(b, "CostAllocationTagsStatus")?;
916 let entries = b
917 .get("CostAllocationTagsStatus")
918 .and_then(Value::as_array)
919 .cloned()
920 .unwrap_or_default();
921 let mut guard = self.state.write();
922 let data = guard.get_or_create(&ctx.account);
923 for entry in &entries {
924 let (Some(key), Some(status)) = (
925 entry.get("TagKey").and_then(Value::as_str),
926 entry.get("Status").and_then(Value::as_str),
927 ) else {
928 continue;
929 };
930 let tag = data
931 .cost_allocation_tags
932 .entry(key.to_string())
933 .or_insert_with(|| {
934 json!({
935 "TagKey": key,
936 "Type": "UserDefined",
937 "Status": status,
938 })
939 });
940 if let Some(obj) = tag.as_object_mut() {
941 obj.insert("Status".into(), json!(status));
942 obj.insert("LastUpdatedDate".into(), json!(now_zoned()));
943 }
944 }
945 ok(json!({ "Errors": [] }))
947 }
948
949 fn start_backfill(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
950 let backfill_from = req_str(b, "BackfillFrom")?.to_string();
951 let now = now_zoned();
952 let request = json!({
953 "BackfillFrom": backfill_from,
954 "RequestedAt": now,
955 "CompletedAt": now,
956 "BackfillStatus": "SUCCEEDED",
957 "LastUpdatedAt": now,
958 });
959 let mut guard = self.state.write();
960 let data = guard.get_or_create(&ctx.account);
961 data.backfill_requests.push(request.clone());
962 ok(json!({ "BackfillRequest": request }))
963 }
964
965 fn list_backfill_history(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
966 let guard = self.state.read();
967 let rows: Vec<Value> = guard
968 .get(&ctx.account)
969 .map(|d| d.backfill_requests.iter().rev().cloned().collect())
970 .unwrap_or_default();
971 let (page, next) = paginate(rows, b, 100);
972 let mut out = Map::new();
973 out.insert("BackfillRequests".into(), Value::Array(page));
974 if let Some(t) = next {
975 out.insert("NextToken".into(), json!(t));
976 }
977 ok(Value::Object(out))
978 }
979}
980
981impl CeService {
984 fn tag_resource(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
985 let arn = req_str(b, "ResourceArn")?.to_string();
986 req_present(b, "ResourceTags")?;
987 let mut guard = self.state.write();
988 let data = guard.get_or_create(&ctx.account);
989 store_resource_tags(data, &arn, b);
990 empty_ok()
991 }
992
993 fn untag_resource(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
994 let arn = req_str(b, "ResourceArn")?.to_string();
995 req_present(b, "ResourceTagKeys")?;
996 let keys = b
997 .get("ResourceTagKeys")
998 .and_then(Value::as_array)
999 .cloned()
1000 .unwrap_or_default();
1001 let mut guard = self.state.write();
1002 let data = guard.get_or_create(&ctx.account);
1003 if let Some(entry) = data.tags.get_mut(&arn) {
1004 for k in &keys {
1005 if let Some(k) = k.as_str() {
1006 entry.remove(k);
1007 }
1008 }
1009 }
1010 empty_ok()
1011 }
1012
1013 fn list_tags_for_resource(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
1014 let arn = req_str(b, "ResourceArn")?.to_string();
1015 let guard = self.state.read();
1016 let tags: Vec<Value> = guard
1017 .get(&ctx.account)
1018 .and_then(|d| d.tags.get(&arn))
1019 .map(|m| {
1020 m.iter()
1021 .map(|(k, v)| json!({ "Key": k, "Value": v }))
1022 .collect()
1023 })
1024 .unwrap_or_default();
1025 ok(json!({ "ResourceTags": tags }))
1026 }
1027}
1028
1029impl CeService {
1032 fn start_commitment_analysis(
1033 &self,
1034 ctx: &Ctx,
1035 b: &Value,
1036 ) -> Result<AwsResponse, AwsServiceError> {
1037 req_present(b, "CommitmentPurchaseAnalysisConfiguration")?;
1038 let id = new_uuid();
1039 let now = now_zoned();
1040 let config = b
1041 .get("CommitmentPurchaseAnalysisConfiguration")
1042 .cloned()
1043 .unwrap_or(json!({}));
1044 let record = json!({
1045 "AnalysisId": id,
1046 "AnalysisStartedTime": now,
1047 "EstimatedCompletionTime": now,
1048 "AnalysisCompletionTime": now,
1049 "AnalysisStatus": "SUCCEEDED",
1050 "CommitmentPurchaseAnalysisConfiguration": config,
1051 });
1052 let mut guard = self.state.write();
1053 let data = guard.get_or_create(&ctx.account);
1054 data.commitment_analyses.insert(id.clone(), record);
1055 ok(json!({
1056 "AnalysisId": id,
1057 "AnalysisStartedTime": now,
1058 "EstimatedCompletionTime": now,
1059 }))
1060 }
1061
1062 fn get_commitment_analysis(
1063 &self,
1064 ctx: &Ctx,
1065 b: &Value,
1066 ) -> Result<AwsResponse, AwsServiceError> {
1067 let id = req_str(b, "AnalysisId")?.to_string();
1068 let guard = self.state.read();
1069 let rec = guard
1070 .get(&ctx.account)
1071 .and_then(|d| d.commitment_analyses.get(&id))
1072 .cloned()
1073 .ok_or_else(|| {
1074 err(
1075 "AnalysisNotFoundException",
1076 &format!("Cannot find the analysis: {id}."),
1077 )
1078 })?;
1079 ok(rec)
1080 }
1081
1082 fn list_commitment_analyses(
1083 &self,
1084 ctx: &Ctx,
1085 b: &Value,
1086 ) -> Result<AwsResponse, AwsServiceError> {
1087 let status_filter = opt_str(b, "AnalysisStatus").map(str::to_string);
1088 let id_filter: Option<Vec<String>> =
1089 b.get("AnalysisIds").and_then(Value::as_array).map(|a| {
1090 a.iter()
1091 .filter_map(|v| v.as_str().map(str::to_string))
1092 .collect()
1093 });
1094 let guard = self.state.read();
1095 let rows: Vec<Value> = guard
1096 .get(&ctx.account)
1097 .map(|d| {
1098 d.commitment_analyses
1099 .iter()
1100 .filter(|(id, v)| {
1101 status_filter.as_deref().is_none_or(|s| {
1102 v.get("AnalysisStatus").and_then(Value::as_str) == Some(s)
1103 }) && id_filter.as_ref().is_none_or(|ids| ids.contains(id))
1104 })
1105 .map(|(_, v)| {
1106 let mut summary = Map::new();
1107 copy_fields(
1108 v,
1109 &mut summary,
1110 &[
1111 "AnalysisId",
1112 "AnalysisStatus",
1113 "AnalysisStartedTime",
1114 "AnalysisCompletionTime",
1115 "EstimatedCompletionTime",
1116 "CommitmentPurchaseAnalysisConfiguration",
1117 ],
1118 );
1119 Value::Object(summary)
1120 })
1121 .collect()
1122 })
1123 .unwrap_or_default();
1124 let (page, next) = paginate(rows, b, 100);
1125 let mut out = Map::new();
1126 out.insert("AnalysisSummaryList".into(), Value::Array(page));
1127 if let Some(t) = next {
1128 out.insert("NextPageToken".into(), json!(t));
1129 }
1130 ok(Value::Object(out))
1131 }
1132
1133 fn start_sp_generation(&self, ctx: &Ctx, _b: &Value) -> Result<AwsResponse, AwsServiceError> {
1134 let id = new_uuid();
1135 let now = now_zoned();
1136 let record = json!({
1137 "RecommendationId": id,
1138 "GenerationStatus": "SUCCEEDED",
1139 "GenerationStartedTime": now,
1140 "GenerationCompletionTime": now,
1141 "EstimatedCompletionTime": now,
1142 });
1143 let mut guard = self.state.write();
1144 let data = guard.get_or_create(&ctx.account);
1145 data.sp_generations.insert(id.clone(), record);
1146 ok(json!({
1147 "RecommendationId": id,
1148 "GenerationStartedTime": now,
1149 "EstimatedCompletionTime": now,
1150 }))
1151 }
1152
1153 fn list_sp_generation(&self, ctx: &Ctx, b: &Value) -> Result<AwsResponse, AwsServiceError> {
1154 let status_filter = opt_str(b, "GenerationStatus").map(str::to_string);
1155 let id_filter: Option<Vec<String>> = b
1156 .get("RecommendationIds")
1157 .and_then(Value::as_array)
1158 .map(|a| {
1159 a.iter()
1160 .filter_map(|v| v.as_str().map(str::to_string))
1161 .collect()
1162 });
1163 let guard = self.state.read();
1164 let rows: Vec<Value> = guard
1165 .get(&ctx.account)
1166 .map(|d| {
1167 d.sp_generations
1168 .iter()
1169 .filter(|(id, v)| {
1170 status_filter.as_deref().is_none_or(|s| {
1171 v.get("GenerationStatus").and_then(Value::as_str) == Some(s)
1172 }) && id_filter.as_ref().is_none_or(|ids| ids.contains(id))
1173 })
1174 .map(|(_, v)| v.clone())
1175 .collect()
1176 })
1177 .unwrap_or_default();
1178 let (page, next) = paginate(rows, b, 100);
1179 let mut out = Map::new();
1180 out.insert("GenerationSummaryList".into(), Value::Array(page));
1181 if let Some(t) = next {
1182 out.insert("NextPageToken".into(), json!(t));
1183 }
1184 ok(Value::Object(out))
1185 }
1186}
1187
1188fn result_bucket(start: &str, end: &str, metrics: &[String]) -> Value {
1193 let mut total = Map::new();
1194 for m in metrics {
1195 total.insert(m.clone(), metric_value(metric_unit(m)));
1196 }
1197 json!({
1198 "TimePeriod": date_interval(start, end),
1199 "Total": Value::Object(total),
1200 "Groups": [],
1201 "Estimated": false,
1202 })
1203}
1204
1205fn cost_and_usage(b: &Value, require_filter: bool) -> Result<AwsResponse, AwsServiceError> {
1210 req_present(b, "TimePeriod")?;
1211 req_str(b, "Granularity")?;
1212 if require_filter {
1213 req_present(b, "Filter")?;
1214 } else {
1215 req_present(b, "Metrics")?;
1216 }
1217 let (period_start, end) = interval_bounds(b.get("TimePeriod"));
1218 let start = opt_str(b, "NextPageToken")
1222 .filter(|t| {
1223 chrono::NaiveDate::parse_from_str(t.get(0..10).unwrap_or(t), "%Y-%m-%d").is_ok()
1224 })
1225 .unwrap_or(&period_start)
1226 .to_string();
1227 let granularity = opt_str(b, "Granularity").unwrap_or("MONTHLY");
1228 let metrics: Vec<String> = b
1229 .get("Metrics")
1230 .and_then(Value::as_array)
1231 .map(|a| {
1232 a.iter()
1233 .filter_map(|v| v.as_str().map(str::to_string))
1234 .collect()
1235 })
1236 .unwrap_or_default();
1237 let (results, next_token): (Vec<Value>, Option<String>) =
1238 match split_buckets(&start, &end, granularity) {
1239 Some((buckets, resume)) => (
1240 buckets
1241 .iter()
1242 .map(|(s, e)| result_bucket(s, e, &metrics))
1243 .collect(),
1244 resume,
1245 ),
1246 None => (vec![result_bucket(&start, &end, &metrics)], None),
1247 };
1248 let mut out = Map::new();
1249 out.insert("ResultsByTime".into(), Value::Array(results));
1250 if let Some(gb) = b.get("GroupBy") {
1251 out.insert("GroupDefinitions".into(), gb.clone());
1252 }
1253 out.insert("DimensionValueAttributes".into(), json!([]));
1254 if let Some(token) = next_token {
1255 out.insert("NextPageToken".into(), json!(token));
1256 }
1257 ok(Value::Object(out))
1258}
1259
1260fn cost_and_usage_comparisons(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1261 req_present(b, "BaselineTimePeriod")?;
1262 req_present(b, "ComparisonTimePeriod")?;
1263 req_str(b, "MetricForComparison")?;
1264 ok(json!({
1265 "CostAndUsageComparisons": [],
1266 "TotalCostAndUsage": {},
1267 }))
1268}
1269
1270fn cost_comparison_drivers(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1271 req_present(b, "BaselineTimePeriod")?;
1272 req_present(b, "ComparisonTimePeriod")?;
1273 req_str(b, "MetricForComparison")?;
1274 ok(json!({ "CostComparisonDrivers": [] }))
1275}
1276
1277fn get_cost_categories_report(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1278 req_present(b, "TimePeriod")?;
1279 ok(json!({
1280 "CostCategoryNames": [],
1281 "CostCategoryValues": [],
1282 "ReturnSize": 0,
1283 "TotalSize": 0,
1284 }))
1285}
1286
1287fn get_dimension_values(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1288 req_present(b, "TimePeriod")?;
1289 req_str(b, "Dimension")?;
1290 ok(json!({
1291 "DimensionValues": [],
1292 "ReturnSize": 0,
1293 "TotalSize": 0,
1294 }))
1295}
1296
1297fn get_tags_report(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1298 req_present(b, "TimePeriod")?;
1299 ok(json!({
1300 "Tags": [],
1301 "ReturnSize": 0,
1302 "TotalSize": 0,
1303 }))
1304}
1305
1306fn forecast(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1307 req_present(b, "TimePeriod")?;
1308 let metric = req_str(b, "Metric")?;
1309 let unit = metric_unit(metric);
1310 req_str(b, "Granularity")?;
1311 ok(json!({
1312 "Total": metric_value(unit),
1313 "ForecastResultsByTime": [],
1314 }))
1315}
1316
1317fn approximate_usage_records(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1318 req_str(b, "Granularity")?;
1319 req_str(b, "ApproximationDimension")?;
1320 ok(json!({
1321 "Services": {},
1322 "TotalRecords": 0,
1323 "LookbackPeriod": date_interval("2024-01-01", "2024-01-02"),
1324 }))
1325}
1326
1327fn reservation_coverage(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1328 req_present(b, "TimePeriod")?;
1329 ok(json!({ "CoveragesByTime": [] }))
1330}
1331
1332fn reservation_utilization(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1333 req_present(b, "TimePeriod")?;
1334 ok(json!({ "UtilizationsByTime": [] }))
1335}
1336
1337fn reservation_purchase_recommendation(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1338 req_str(b, "Service")?;
1339 ok(json!({ "Recommendations": [] }))
1340}
1341
1342fn rightsizing_recommendation(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1343 req_str(b, "Service")?;
1344 ok(json!({ "RightsizingRecommendations": [] }))
1345}
1346
1347fn savings_plans_coverage(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1348 req_present(b, "TimePeriod")?;
1349 ok(json!({ "SavingsPlansCoverages": [] }))
1350}
1351
1352fn savings_plans_utilization(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1353 req_present(b, "TimePeriod")?;
1354 ok(json!({
1355 "Total": {
1356 "Utilization": {
1357 "TotalCommitment": "0.0",
1358 "UsedCommitment": "0.0",
1359 "UnusedCommitment": "0.0",
1360 "UtilizationPercentage": "0.0",
1361 }
1362 }
1363 }))
1364}
1365
1366fn savings_plans_utilization_details(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1367 req_present(b, "TimePeriod")?;
1368 let (start, end) = interval_bounds(b.get("TimePeriod"));
1369 ok(json!({
1370 "SavingsPlansUtilizationDetails": [],
1371 "TimePeriod": date_interval(&start, &end),
1372 }))
1373}
1374
1375fn savings_plans_purchase_recommendation(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1376 req_str(b, "SavingsPlansType")?;
1377 req_str(b, "TermInYears")?;
1378 req_str(b, "PaymentOption")?;
1379 req_str(b, "LookbackPeriodInDays")?;
1380 ok(json!({}))
1381}
1382
1383fn savings_plan_purchase_recommendation_details(b: &Value) -> Result<AwsResponse, AwsServiceError> {
1384 let id = req_str(b, "RecommendationDetailId")?.to_string();
1385 ok(json!({ "RecommendationDetailId": id }))
1386}