Skip to main content

fakecloud_sdk/
client.rs

1use crate::error::Error;
2use crate::types::*;
3use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
4
5/// Client for the fakecloud introspection and simulation API (`/_fakecloud/*`).
6pub struct FakeCloud {
7    base_url: String,
8    client: reqwest::Client,
9}
10
11impl FakeCloud {
12    /// Create a new client pointing at the given fakecloud base URL (e.g. `http://localhost:4566`).
13    pub fn new(base_url: &str) -> Self {
14        Self {
15            base_url: base_url.trim_end_matches('/').to_string(),
16            client: reqwest::Client::new(),
17        }
18    }
19
20    // ── Health & Reset ──────────────────────────────────────────────
21
22    /// Check server health.
23    pub async fn health(&self) -> Result<HealthResponse, Error> {
24        let resp = self
25            .client
26            .get(format!("{}/_fakecloud/health", self.base_url))
27            .send()
28            .await?;
29        Self::parse(resp).await
30    }
31
32    /// Reset all service state. Uses the legacy `/_reset` endpoint.
33    pub async fn reset(&self) -> Result<ResetResponse, Error> {
34        let resp = self
35            .client
36            .post(format!("{}/_reset", self.base_url))
37            .send()
38            .await?;
39        Self::parse(resp).await
40    }
41
42    /// Create an IAM admin user in a specific account. Returns credentials
43    /// for the new user. Solves the multi-account bootstrap problem: the
44    /// root bypass only targets the default account, so this endpoint lets
45    /// callers create credentials for any account.
46    pub async fn create_admin(
47        &self,
48        account_id: &str,
49        user_name: &str,
50    ) -> Result<CreateAdminResponse, Error> {
51        let resp = self
52            .client
53            .post(format!("{}/_fakecloud/iam/create-admin", self.base_url))
54            .json(&CreateAdminRequest {
55                account_id: account_id.to_string(),
56                user_name: user_name.to_string(),
57            })
58            .send()
59            .await?;
60        Self::parse(resp).await
61    }
62
63    /// Reset a single service's state.
64    pub async fn reset_service(&self, service: &str) -> Result<ResetServiceResponse, Error> {
65        let resp = self
66            .client
67            .post(format!("{}/_fakecloud/reset/{}", self.base_url, service))
68            .send()
69            .await?;
70        Self::parse(resp).await
71    }
72
73    /// Reset a single service's state for a specific account only.
74    pub async fn reset_service_for_account(
75        &self,
76        service: &str,
77        account_id: &str,
78    ) -> Result<ResetServiceResponse, Error> {
79        let resp = self
80            .client
81            .post(format!(
82                "{}/_fakecloud/reset/{}/{}",
83                self.base_url, service, account_id
84            ))
85            .send()
86            .await?;
87        Self::parse(resp).await
88    }
89
90    // ── Sub-clients ─────────────────────────────────────────────────
91
92    pub fn lambda(&self) -> LambdaClient<'_> {
93        LambdaClient { fc: self }
94    }
95
96    pub fn ses(&self) -> SesClient<'_> {
97        SesClient { fc: self }
98    }
99
100    pub fn sns(&self) -> SnsClient<'_> {
101        SnsClient { fc: self }
102    }
103
104    pub fn sqs(&self) -> SqsClient<'_> {
105        SqsClient { fc: self }
106    }
107
108    pub fn events(&self) -> EventsClient<'_> {
109        EventsClient { fc: self }
110    }
111
112    pub fn s3(&self) -> S3Client<'_> {
113        S3Client { fc: self }
114    }
115
116    pub fn dynamodb(&self) -> DynamoDbClient<'_> {
117        DynamoDbClient { fc: self }
118    }
119
120    pub fn secretsmanager(&self) -> SecretsManagerClient<'_> {
121        SecretsManagerClient { fc: self }
122    }
123
124    pub fn cognito(&self) -> CognitoClient<'_> {
125        CognitoClient { fc: self }
126    }
127
128    pub fn rds(&self) -> RdsClient<'_> {
129        RdsClient { fc: self }
130    }
131
132    pub fn ec2(&self) -> Ec2Client<'_> {
133        Ec2Client { fc: self }
134    }
135
136    pub fn elasticache(&self) -> ElastiCacheClient<'_> {
137        ElastiCacheClient { fc: self }
138    }
139
140    pub fn apigatewayv2(&self) -> ApiGatewayV2Client<'_> {
141        ApiGatewayV2Client { fc: self }
142    }
143
144    pub fn stepfunctions(&self) -> StepFunctionsClient<'_> {
145        StepFunctionsClient { fc: self }
146    }
147
148    pub fn bedrock(&self) -> BedrockClient<'_> {
149        BedrockClient { fc: self }
150    }
151
152    pub fn bedrock_agent(&self) -> BedrockAgentClient<'_> {
153        BedrockAgentClient { fc: self }
154    }
155
156    pub fn bedrock_agent_runtime(&self) -> BedrockAgentRuntimeClient<'_> {
157        BedrockAgentRuntimeClient { fc: self }
158    }
159
160    pub fn ecs(&self) -> EcsClient<'_> {
161        EcsClient { fc: self }
162    }
163
164    pub fn application_autoscaling(&self) -> ApplicationAutoScalingClient<'_> {
165        ApplicationAutoScalingClient { fc: self }
166    }
167
168    pub fn athena(&self) -> AthenaClient<'_> {
169        AthenaClient { fc: self }
170    }
171
172    pub fn organizations(&self) -> OrganizationsClient<'_> {
173        OrganizationsClient { fc: self }
174    }
175
176    pub fn acm(&self) -> AcmClient<'_> {
177        AcmClient { fc: self }
178    }
179
180    pub fn ecr(&self) -> EcrClient<'_> {
181        EcrClient { fc: self }
182    }
183
184    pub fn elbv2(&self) -> Elbv2Client<'_> {
185        Elbv2Client { fc: self }
186    }
187
188    pub fn glue(&self) -> GlueClient<'_> {
189        GlueClient { fc: self }
190    }
191
192    pub fn cloudwatch(&self) -> CloudWatchClient<'_> {
193        CloudWatchClient { fc: self }
194    }
195
196    pub fn firehose(&self) -> FirehoseClient<'_> {
197        FirehoseClient { fc: self }
198    }
199
200    pub fn logs(&self) -> LogsClient<'_> {
201        LogsClient { fc: self }
202    }
203
204    pub fn route53(&self) -> Route53Client<'_> {
205        Route53Client { fc: self }
206    }
207
208    pub fn scheduler(&self) -> SchedulerClient<'_> {
209        SchedulerClient { fc: self }
210    }
211
212    pub fn ssm(&self) -> SsmClient<'_> {
213        SsmClient { fc: self }
214    }
215
216    pub fn kms(&self) -> KmsClient<'_> {
217        KmsClient { fc: self }
218    }
219
220    pub fn wafv2(&self) -> WafV2Client<'_> {
221        WafV2Client { fc: self }
222    }
223
224    pub fn cloudfront(&self) -> CloudFrontClient<'_> {
225        CloudFrontClient { fc: self }
226    }
227
228    // ── Internal helpers ────────────────────────────────────────────
229
230    async fn parse<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T, Error> {
231        let status = resp.status().as_u16();
232        if !resp.status().is_success() {
233            let body = resp.text().await.unwrap_or_default();
234            return Err(Error::Api { status, body });
235        }
236        Ok(resp.json::<T>().await?)
237    }
238}
239
240// ── RDS ─────────────────────────────────────────────────────────────
241
242pub struct Ec2Client<'a> {
243    fc: &'a FakeCloud,
244}
245
246impl Ec2Client<'_> {
247    /// List fakecloud-managed EC2 instances with control-plane metadata.
248    pub async fn get_instances(&self) -> Result<Ec2InstancesResponse, Error> {
249        let resp = self
250            .fc
251            .client
252            .get(format!("{}/_fakecloud/ec2/instances", self.fc.base_url))
253            .send()
254            .await?;
255        FakeCloud::parse(resp).await
256    }
257
258    /// Inspect the real backing network of each EC2 instance — which
259    /// Docker/Podman network or k8s NetworkPolicy backs it, its container IP,
260    /// and whether security-group enforcement is active or degraded. A
261    /// debugging aid for "why can't X reach Y" (issue #1745).
262    pub async fn get_instance_networks(&self) -> Result<Ec2InstanceNetworksResponse, Error> {
263        let resp = self
264            .fc
265            .client
266            .get(format!(
267                "{}/_fakecloud/ec2/instance-networks",
268                self.fc.base_url
269            ))
270            .send()
271            .await?;
272        FakeCloud::parse(resp).await
273    }
274}
275
276pub struct RdsClient<'a> {
277    fc: &'a FakeCloud,
278}
279
280impl RdsClient<'_> {
281    /// List fakecloud-managed RDS DB instances with runtime metadata.
282    pub async fn get_instances(&self) -> Result<RdsInstancesResponse, Error> {
283        let resp = self
284            .fc
285            .client
286            .get(format!("{}/_fakecloud/rds/instances", self.fc.base_url))
287            .send()
288            .await?;
289        FakeCloud::parse(resp).await
290    }
291
292    /// Bridge endpoint used by the PostgreSQL `aws_lambda` extension
293    /// running inside an RDS container to invoke a Lambda function. A
294    /// `status_code` of `502` is returned (with the response body still
295    /// JSON-decodable) when the target Lambda is unavailable.
296    pub async fn lambda_invoke(
297        &self,
298        req: &RdsLambdaInvokeRequest,
299    ) -> Result<RdsLambdaInvokeResponse, Error> {
300        let resp = self
301            .fc
302            .client
303            .post(format!("{}/_fakecloud/rds/lambda-invoke", self.fc.base_url))
304            .json(req)
305            .send()
306            .await?;
307        let status = resp.status().as_u16();
308        // 502 is a valid bridge response (SERVICE_UNAVAILABLE) — body is
309        // still a well-formed RdsLambdaInvokeResponse, so parse it
310        // rather than treating the call as a transport error.
311        if status == 502 || resp.status().is_success() {
312            return Ok(resp.json::<RdsLambdaInvokeResponse>().await?);
313        }
314        let body = resp.text().await.unwrap_or_default();
315        Err(Error::Api { status, body })
316    }
317
318    /// Bridge endpoint for the PostgreSQL `aws_s3` extension's import
319    /// path. Mirrors `aws_s3.table_import_from_s3` at the transport layer.
320    pub async fn s3_import(&self, req: &RdsS3ImportRequest) -> Result<RdsS3ImportResponse, Error> {
321        let resp = self
322            .fc
323            .client
324            .post(format!("{}/_fakecloud/rds/s3-import", self.fc.base_url))
325            .json(req)
326            .send()
327            .await?;
328        FakeCloud::parse(resp).await
329    }
330
331    /// Bridge endpoint for the PostgreSQL `aws_s3` extension's export
332    /// path. Mirrors `aws_s3.query_export_to_s3` at the transport layer.
333    pub async fn s3_export(&self, req: &RdsS3ExportRequest) -> Result<RdsS3ExportResponse, Error> {
334        let resp = self
335            .fc
336            .client
337            .post(format!("{}/_fakecloud/rds/s3-export", self.fc.base_url))
338            .json(req)
339            .send()
340            .await?;
341        FakeCloud::parse(resp).await
342    }
343}
344
345// ── ElastiCache ─────────────────────────────────────────────────────
346
347pub struct ElastiCacheClient<'a> {
348    fc: &'a FakeCloud,
349}
350
351impl ElastiCacheClient<'_> {
352    /// List fakecloud-managed ElastiCache cache clusters with runtime metadata.
353    pub async fn get_clusters(&self) -> Result<ElastiCacheClustersResponse, Error> {
354        let resp = self
355            .fc
356            .client
357            .get(format!(
358                "{}/_fakecloud/elasticache/clusters",
359                self.fc.base_url
360            ))
361            .send()
362            .await?;
363        FakeCloud::parse(resp).await
364    }
365
366    /// List fakecloud-managed ElastiCache replication groups with runtime metadata.
367    pub async fn get_replication_groups(
368        &self,
369    ) -> Result<ElastiCacheReplicationGroupsResponse, Error> {
370        let resp = self
371            .fc
372            .client
373            .get(format!(
374                "{}/_fakecloud/elasticache/replication-groups",
375                self.fc.base_url
376            ))
377            .send()
378            .await?;
379        FakeCloud::parse(resp).await
380    }
381
382    /// List fakecloud-managed ElastiCache serverless caches with runtime metadata.
383    pub async fn get_serverless_caches(
384        &self,
385    ) -> Result<ElastiCacheServerlessCachesResponse, Error> {
386        let resp = self
387            .fc
388            .client
389            .get(format!(
390                "{}/_fakecloud/elasticache/serverless-caches",
391                self.fc.base_url
392            ))
393            .send()
394            .await?;
395        FakeCloud::parse(resp).await
396    }
397
398    /// List ACL state (users + user groups) for ElastiCache replication groups
399    /// that have one or more user groups attached.
400    pub async fn get_acls(&self) -> Result<ElastiCacheAclsResponse, Error> {
401        let resp = self
402            .fc
403            .client
404            .get(format!("{}/_fakecloud/elasticache/acls", self.fc.base_url))
405            .send()
406            .await?;
407        FakeCloud::parse(resp).await
408    }
409}
410
411// ── Lambda ──────────────────────────────────────────────────────────
412
413pub struct LambdaClient<'a> {
414    fc: &'a FakeCloud,
415}
416
417impl LambdaClient<'_> {
418    /// List recorded Lambda invocations.
419    pub async fn get_invocations(&self) -> Result<LambdaInvocationsResponse, Error> {
420        let resp = self
421            .fc
422            .client
423            .get(format!(
424                "{}/_fakecloud/lambda/invocations",
425                self.fc.base_url
426            ))
427            .send()
428            .await?;
429        FakeCloud::parse(resp).await
430    }
431
432    /// List warm (cached) Lambda containers.
433    pub async fn get_warm_containers(&self) -> Result<WarmContainersResponse, Error> {
434        let resp = self
435            .fc
436            .client
437            .get(format!(
438                "{}/_fakecloud/lambda/warm-containers",
439                self.fc.base_url
440            ))
441            .send()
442            .await?;
443        FakeCloud::parse(resp).await
444    }
445
446    /// Evict the warm container for a specific function.
447    pub async fn evict_container(
448        &self,
449        function_name: &str,
450    ) -> Result<EvictContainerResponse, Error> {
451        let resp = self
452            .fc
453            .client
454            .post(format!(
455                "{}/_fakecloud/lambda/{}/evict-container",
456                self.fc.base_url, function_name
457            ))
458            .send()
459            .await?;
460        FakeCloud::parse(resp).await
461    }
462
463    /// Download the raw zip bytes for a function's code. Pass `"latest"`
464    /// for the live function code, or a published version string (e.g.
465    /// `"1"`) for a version snapshot.
466    pub async fn download_function_code(
467        &self,
468        account_id: &str,
469        function_name: &str,
470        qualifier_or_latest: &str,
471    ) -> Result<Vec<u8>, Error> {
472        let resp = self
473            .fc
474            .client
475            .get(format!(
476                "{}/_fakecloud/lambda/function-code/{}/{}/{}.zip",
477                self.fc.base_url, account_id, function_name, qualifier_or_latest
478            ))
479            .send()
480            .await?;
481        let status = resp.status().as_u16();
482        if !resp.status().is_success() {
483            let body = resp.text().await.unwrap_or_default();
484            return Err(Error::Api { status, body });
485        }
486        Ok(resp.bytes().await?.to_vec())
487    }
488
489    /// Download the raw zip bytes for a published Lambda layer version.
490    pub async fn download_layer_content(
491        &self,
492        account_id: &str,
493        layer_name: &str,
494        version: i64,
495    ) -> Result<Vec<u8>, Error> {
496        let resp = self
497            .fc
498            .client
499            .get(format!(
500                "{}/_fakecloud/lambda/layer-content/{}/{}/{}.zip",
501                self.fc.base_url, account_id, layer_name, version
502            ))
503            .send()
504            .await?;
505        let status = resp.status().as_u16();
506        if !resp.status().is_success() {
507            let body = resp.text().await.unwrap_or_default();
508            return Err(Error::Api { status, body });
509        }
510        Ok(resp.bytes().await?.to_vec())
511    }
512}
513
514// ── SES ─────────────────────────────────────────────────────────────
515
516pub struct SesClient<'a> {
517    fc: &'a FakeCloud,
518}
519
520impl SesClient<'_> {
521    /// List all sent emails.
522    pub async fn get_emails(&self) -> Result<SesEmailsResponse, Error> {
523        let resp = self
524            .fc
525            .client
526            .get(format!("{}/_fakecloud/ses/emails", self.fc.base_url))
527            .send()
528            .await?;
529        FakeCloud::parse(resp).await
530    }
531
532    /// Simulate an inbound email (SES receipt rules).
533    pub async fn simulate_inbound(
534        &self,
535        req: &InboundEmailRequest,
536    ) -> Result<InboundEmailResponse, Error> {
537        let resp = self
538            .fc
539            .client
540            .post(format!("{}/_fakecloud/ses/inbound", self.fc.base_url))
541            .json(req)
542            .send()
543            .await?;
544        FakeCloud::parse(resp).await
545    }
546
547    /// Snapshot the running SES counters (currently only
548    /// `suppressed_drops_total`).
549    pub async fn get_metrics(&self) -> Result<SesMetricsResponse, Error> {
550        let resp = self
551            .fc
552            .client
553            .get(format!("{}/_fakecloud/ses/metrics", self.fc.base_url))
554            .send()
555            .await?;
556        FakeCloud::parse(resp).await
557    }
558
559    /// List recorded SES bounces with per-recipient bounce metadata.
560    pub async fn get_bounces(&self) -> Result<SesBouncesResponse, Error> {
561        let resp = self
562            .fc
563            .client
564            .get(format!("{}/_fakecloud/ses/bounces", self.fc.base_url))
565            .send()
566            .await?;
567        FakeCloud::parse(resp).await
568    }
569
570    /// Flip the SES account-level `production_access_enabled` flag.
571    /// `sandbox=true` puts the account back into sandbox mode (production
572    /// access disabled); `sandbox=false` re-enables production access.
573    pub async fn set_sandbox(&self, sandbox: bool) -> Result<SesSandboxResponse, Error> {
574        let resp = self
575            .fc
576            .client
577            .post(format!(
578                "{}/_fakecloud/ses/account/sandbox",
579                self.fc.base_url
580            ))
581            .json(&SesSandboxRequest { sandbox })
582            .send()
583            .await?;
584        FakeCloud::parse(resp).await
585    }
586
587    /// List event-destination delivery dispatches recorded by the
588    /// SES sender (one row per dispatched event-destination target).
589    pub async fn get_event_destination_deliveries(
590        &self,
591    ) -> Result<SesEventDestinationDeliveriesResponse, Error> {
592        let resp = self
593            .fc
594            .client
595            .get(format!(
596                "{}/_fakecloud/ses/event-destinations/deliveries",
597                self.fc.base_url
598            ))
599            .send()
600            .await?;
601        FakeCloud::parse(resp).await
602    }
603
604    /// Get the deterministic DKIM public key + selector + signing-enabled
605    /// flag for an identity. 404 if the identity is unknown.
606    pub async fn get_dkim_public_key(
607        &self,
608        identity: &str,
609    ) -> Result<SesDkimPublicKeyResponse, Error> {
610        let resp = self
611            .fc
612            .client
613            .get(format!(
614                "{}/_fakecloud/ses/identities/{}/dkim-public-key",
615                self.fc.base_url, identity
616            ))
617            .send()
618            .await?;
619        FakeCloud::parse(resp).await
620    }
621
622    /// Flip an identity's `MailFromDomainStatus`. Must be one of
623    /// `NotStarted` / `Pending` / `Success` / `Failed`.
624    pub async fn set_mail_from_status(
625        &self,
626        identity: &str,
627        status: &str,
628    ) -> Result<SesMailFromStatusResponse, Error> {
629        let resp = self
630            .fc
631            .client
632            .post(format!(
633                "{}/_fakecloud/ses/identities/{}/mail-from-status",
634                self.fc.base_url, identity
635            ))
636            .json(&SesMailFromStatusRequest {
637                status: status.to_string(),
638            })
639            .send()
640            .await?;
641        FakeCloud::parse(resp).await
642    }
643
644    /// Get a per-message insights snapshot (sends, deliveries, bounces,
645    /// complaints, ...). 404 if the message id is unknown.
646    pub async fn get_message_insights(
647        &self,
648        message_id: &str,
649    ) -> Result<SesMessageInsightsResponse, Error> {
650        let resp = self
651            .fc
652            .client
653            .get(format!(
654                "{}/_fakecloud/ses/messages/{}/insights",
655                self.fc.base_url, message_id
656            ))
657            .send()
658            .await?;
659        FakeCloud::parse(resp).await
660    }
661
662    /// List submissions received via the SES SMTP endpoint.
663    pub async fn get_smtp_submissions(&self) -> Result<SesSmtpSubmissionsResponse, Error> {
664        let resp = self
665            .fc
666            .client
667            .get(format!(
668                "{}/_fakecloud/ses/smtp/submissions",
669                self.fc.base_url
670            ))
671            .send()
672            .await?;
673        FakeCloud::parse(resp).await
674    }
675}
676
677// ── SNS ─────────────────────────────────────────────────────────────
678
679pub struct SnsClient<'a> {
680    fc: &'a FakeCloud,
681}
682
683impl SnsClient<'_> {
684    /// List all published SNS messages.
685    pub async fn get_messages(&self) -> Result<SnsMessagesResponse, Error> {
686        let resp = self
687            .fc
688            .client
689            .get(format!("{}/_fakecloud/sns/messages", self.fc.base_url))
690            .send()
691            .await?;
692        FakeCloud::parse(resp).await
693    }
694
695    /// List subscriptions pending confirmation.
696    pub async fn get_pending_confirmations(&self) -> Result<PendingConfirmationsResponse, Error> {
697        let resp = self
698            .fc
699            .client
700            .get(format!(
701                "{}/_fakecloud/sns/pending-confirmations",
702                self.fc.base_url
703            ))
704            .send()
705            .await?;
706        FakeCloud::parse(resp).await
707    }
708
709    /// Confirm a pending subscription.
710    pub async fn confirm_subscription(
711        &self,
712        req: &ConfirmSubscriptionRequest,
713    ) -> Result<ConfirmSubscriptionResponse, Error> {
714        let resp = self
715            .fc
716            .client
717            .post(format!(
718                "{}/_fakecloud/sns/confirm-subscription",
719                self.fc.base_url
720            ))
721            .json(req)
722            .send()
723            .await?;
724        FakeCloud::parse(resp).await
725    }
726
727    /// Fetch the PEM-encoded SNS signing certificate used to sign
728    /// outbound notification payloads. Served as
729    /// `application/x-pem-file`; returned as the raw PEM string so
730    /// callers can hand it straight to an X.509 parser.
731    pub async fn cert_pem(&self) -> Result<String, Error> {
732        let resp = self
733            .fc
734            .client
735            .get(format!("{}/_fakecloud/sns/cert.pem", self.fc.base_url))
736            .send()
737            .await?;
738        let status = resp.status().as_u16();
739        if !resp.status().is_success() {
740            let body = resp.text().await.unwrap_or_default();
741            return Err(Error::Api { status, body });
742        }
743        Ok(resp.text().await?)
744    }
745
746    /// List recorded SMS publications (phone number + message body).
747    pub async fn sms(&self) -> Result<SnsSmsResponse, Error> {
748        let resp = self
749            .fc
750            .client
751            .get(format!("{}/_fakecloud/sns/sms", self.fc.base_url))
752            .send()
753            .await?;
754        FakeCloud::parse(resp).await
755    }
756}
757
758// ── SQS ─────────────────────────────────────────────────────────────
759
760pub struct SqsClient<'a> {
761    fc: &'a FakeCloud,
762}
763
764impl SqsClient<'_> {
765    /// List all messages across all queues.
766    pub async fn get_messages(&self) -> Result<SqsMessagesResponse, Error> {
767        let resp = self
768            .fc
769            .client
770            .get(format!("{}/_fakecloud/sqs/messages", self.fc.base_url))
771            .send()
772            .await?;
773        FakeCloud::parse(resp).await
774    }
775
776    /// Tick the message expiration processor (expire visibility-timed-out messages).
777    pub async fn tick_expiration(&self) -> Result<ExpirationTickResponse, Error> {
778        let resp = self
779            .fc
780            .client
781            .post(format!(
782                "{}/_fakecloud/sqs/expiration-processor/tick",
783                self.fc.base_url
784            ))
785            .send()
786            .await?;
787        FakeCloud::parse(resp).await
788    }
789
790    /// Force all messages in a queue to its DLQ.
791    pub async fn force_dlq(&self, queue_name: &str) -> Result<ForceDlqResponse, Error> {
792        let resp = self
793            .fc
794            .client
795            .post(format!(
796                "{}/_fakecloud/sqs/{}/force-dlq",
797                self.fc.base_url, queue_name
798            ))
799            .send()
800            .await?;
801        FakeCloud::parse(resp).await
802    }
803}
804
805// ── Application Auto Scaling ────────────────────────────────────────
806
807pub struct ApplicationAutoScalingClient<'a> {
808    fc: &'a FakeCloud,
809}
810
811impl ApplicationAutoScalingClient<'_> {
812    /// Force the watcher to evaluate every scaling policy now. Returns
813    /// the number of policies that applied a capacity change on this
814    /// tick. Useful in tests so callers don't have to wait for the
815    /// wall-clock 15s interval.
816    pub async fn tick(&self) -> Result<AppAsTickResponse, Error> {
817        let resp = self
818            .fc
819            .client
820            .post(format!(
821                "{}/_fakecloud/application-autoscaling/tick",
822                self.fc.base_url
823            ))
824            .send()
825            .await?;
826        FakeCloud::parse(resp).await
827    }
828
829    /// Force the scheduled-action executor to evaluate every
830    /// `ScheduledAction` now. Returns the number of actions that
831    /// fired this tick. Useful in tests so callers don't have to wait
832    /// for the wall-clock 30s interval.
833    pub async fn scheduled_tick(&self) -> Result<AppAsScheduledTickResponse, Error> {
834        let resp = self
835            .fc
836            .client
837            .post(format!(
838                "{}/_fakecloud/application-autoscaling/scheduled-tick",
839                self.fc.base_url
840            ))
841            .send()
842            .await?;
843        FakeCloud::parse(resp).await
844    }
845}
846
847// ── EventBridge ─────────────────────────────────────────────────────
848
849pub struct EventsClient<'a> {
850    fc: &'a FakeCloud,
851}
852
853impl EventsClient<'_> {
854    /// Get event history and delivery records.
855    pub async fn get_history(&self) -> Result<EventHistoryResponse, Error> {
856        let resp = self
857            .fc
858            .client
859            .get(format!("{}/_fakecloud/events/history", self.fc.base_url))
860            .send()
861            .await?;
862        FakeCloud::parse(resp).await
863    }
864
865    /// Fire a specific EventBridge rule manually.
866    pub async fn fire_rule(&self, req: &FireRuleRequest) -> Result<FireRuleResponse, Error> {
867        let resp = self
868            .fc
869            .client
870            .post(format!("{}/_fakecloud/events/fire-rule", self.fc.base_url))
871            .json(req)
872            .send()
873            .await?;
874        FakeCloud::parse(resp).await
875    }
876}
877
878// ── S3 ──────────────────────────────────────────────────────────────
879
880pub struct S3Client<'a> {
881    fc: &'a FakeCloud,
882}
883
884impl S3Client<'_> {
885    /// List S3 notification events.
886    pub async fn get_notifications(&self) -> Result<S3NotificationsResponse, Error> {
887        let resp = self
888            .fc
889            .client
890            .get(format!("{}/_fakecloud/s3/notifications", self.fc.base_url))
891            .send()
892            .await?;
893        FakeCloud::parse(resp).await
894    }
895
896    /// Tick the S3 lifecycle processor.
897    pub async fn tick_lifecycle(&self) -> Result<LifecycleTickResponse, Error> {
898        let resp = self
899            .fc
900            .client
901            .post(format!(
902                "{}/_fakecloud/s3/lifecycle-processor/tick",
903                self.fc.base_url
904            ))
905            .send()
906            .await?;
907        FakeCloud::parse(resp).await
908    }
909
910    /// List S3 access points across all accounts.
911    pub async fn get_access_points(&self) -> Result<S3AccessPointsResponse, Error> {
912        let resp = self
913            .fc
914            .client
915            .get(format!("{}/_fakecloud/s3/access-points", self.fc.base_url))
916            .send()
917            .await?;
918        FakeCloud::parse(resp).await
919    }
920
921    /// List stored WriteGetObjectResponse bodies (S3 Object Lambda).
922    pub async fn get_object_lambda_responses(
923        &self,
924    ) -> Result<S3ObjectLambdaResponsesResponse, Error> {
925        let resp = self
926            .fc
927            .client
928            .get(format!(
929                "{}/_fakecloud/s3/object-lambda-responses",
930                self.fc.base_url
931            ))
932            .send()
933            .await?;
934        FakeCloud::parse(resp).await
935    }
936}
937
938// ── DynamoDB ────────────────────────────────────────────────────────
939
940pub struct DynamoDbClient<'a> {
941    fc: &'a FakeCloud,
942}
943
944impl DynamoDbClient<'_> {
945    /// Tick the DynamoDB TTL processor.
946    pub async fn tick_ttl(&self) -> Result<TtlTickResponse, Error> {
947        let resp = self
948            .fc
949            .client
950            .post(format!(
951                "{}/_fakecloud/dynamodb/ttl-processor/tick",
952                self.fc.base_url
953            ))
954            .send()
955            .await?;
956        FakeCloud::parse(resp).await
957    }
958}
959
960// ── SecretsManager ──────────────────────────────────────────────────
961
962pub struct SecretsManagerClient<'a> {
963    fc: &'a FakeCloud,
964}
965
966impl SecretsManagerClient<'_> {
967    /// Tick the SecretsManager rotation scheduler.
968    pub async fn tick_rotation(&self) -> Result<RotationTickResponse, Error> {
969        let resp = self
970            .fc
971            .client
972            .post(format!(
973                "{}/_fakecloud/secretsmanager/rotation-scheduler/tick",
974                self.fc.base_url
975            ))
976            .send()
977            .await?;
978        FakeCloud::parse(resp).await
979    }
980}
981
982// ── Cognito ─────────────────────────────────────────────────────────
983
984pub struct CognitoClient<'a> {
985    fc: &'a FakeCloud,
986}
987
988impl CognitoClient<'_> {
989    /// Get confirmation codes for a specific user.
990    pub async fn get_user_codes(
991        &self,
992        pool_id: &str,
993        username: &str,
994    ) -> Result<UserConfirmationCodes, Error> {
995        let resp = self
996            .fc
997            .client
998            .get(format!(
999                "{}/_fakecloud/cognito/confirmation-codes/{}/{}",
1000                self.fc.base_url, pool_id, username
1001            ))
1002            .send()
1003            .await?;
1004        FakeCloud::parse(resp).await
1005    }
1006
1007    /// List all confirmation codes across all pools.
1008    pub async fn get_confirmation_codes(&self) -> Result<ConfirmationCodesResponse, Error> {
1009        let resp = self
1010            .fc
1011            .client
1012            .get(format!(
1013                "{}/_fakecloud/cognito/confirmation-codes",
1014                self.fc.base_url
1015            ))
1016            .send()
1017            .await?;
1018        FakeCloud::parse(resp).await
1019    }
1020
1021    /// Confirm a user (bypass email/phone verification).
1022    pub async fn confirm_user(
1023        &self,
1024        req: &ConfirmUserRequest,
1025    ) -> Result<ConfirmUserResponse, Error> {
1026        let resp = self
1027            .fc
1028            .client
1029            .post(format!(
1030                "{}/_fakecloud/cognito/confirm-user",
1031                self.fc.base_url
1032            ))
1033            .json(req)
1034            .send()
1035            .await?;
1036        let status = resp.status().as_u16();
1037        let body: ConfirmUserResponse = resp.json().await?;
1038        if status >= 400 {
1039            return Err(Error::Api {
1040                status,
1041                body: body.error.unwrap_or_default(),
1042            });
1043        }
1044        Ok(body)
1045    }
1046
1047    /// List all active tokens.
1048    pub async fn get_tokens(&self) -> Result<TokensResponse, Error> {
1049        let resp = self
1050            .fc
1051            .client
1052            .get(format!("{}/_fakecloud/cognito/tokens", self.fc.base_url))
1053            .send()
1054            .await?;
1055        FakeCloud::parse(resp).await
1056    }
1057
1058    /// Expire tokens (optionally filtered by pool/user).
1059    pub async fn expire_tokens(
1060        &self,
1061        req: &ExpireTokensRequest,
1062    ) -> Result<ExpireTokensResponse, Error> {
1063        let resp = self
1064            .fc
1065            .client
1066            .post(format!(
1067                "{}/_fakecloud/cognito/expire-tokens",
1068                self.fc.base_url
1069            ))
1070            .json(req)
1071            .send()
1072            .await?;
1073        FakeCloud::parse(resp).await
1074    }
1075
1076    /// List auth events.
1077    pub async fn get_auth_events(&self) -> Result<AuthEventsResponse, Error> {
1078        let resp = self
1079            .fc
1080            .client
1081            .get(format!(
1082                "{}/_fakecloud/cognito/auth-events",
1083                self.fc.base_url
1084            ))
1085            .send()
1086            .await?;
1087        FakeCloud::parse(resp).await
1088    }
1089
1090    /// List PreTokenGeneration Lambda trigger invocations recorded
1091    /// during `InitiateAuth`. Each entry includes the full request /
1092    /// response payloads plus pre-parsed `claims_added`,
1093    /// `claims_overridden`, and `group_overrides` so tests can assert
1094    /// the claim mutation flow without inspecting the issued JWT.
1095    pub async fn get_pre_token_gen_invocations(
1096        &self,
1097    ) -> Result<PreTokenGenInvocationsResponse, Error> {
1098        let resp = self
1099            .fc
1100            .client
1101            .get(format!(
1102                "{}/_fakecloud/cognito/pretokengen/invocations",
1103                self.fc.base_url
1104            ))
1105            .send()
1106            .await?;
1107        FakeCloud::parse(resp).await
1108    }
1109
1110    /// Mint an OAuth2 `authorization_code` for the given `(client_id,
1111    /// redirect_uri, scopes, PKCE)` binding. Lets tests drive the
1112    /// `authorization_code` grant before the hosted-UI lands.
1113    pub async fn mint_authorization_code(
1114        &self,
1115        req: &MintAuthorizationCodeRequest,
1116    ) -> Result<MintAuthorizationCodeResponse, Error> {
1117        let resp = self
1118            .fc
1119            .client
1120            .post(format!(
1121                "{}/_fakecloud/cognito/authorization-codes",
1122                self.fc.base_url
1123            ))
1124            .json(req)
1125            .send()
1126            .await?;
1127        FakeCloud::parse(resp).await
1128    }
1129
1130    /// Register one or more plaintext passwords with the compromised-
1131    /// credentials set so subsequent `InitiateAuth` /
1132    /// `AdminInitiateAuth` calls trip the `BLOCK` action when the pool's
1133    /// `CompromisedCredentialsRiskConfiguration` is enabled.
1134    pub async fn set_compromised_passwords(
1135        &self,
1136        req: &CognitoCompromisedPasswordsRequest,
1137    ) -> Result<CompromisedPasswordsResponse, Error> {
1138        let resp = self
1139            .fc
1140            .client
1141            .post(format!(
1142                "{}/_fakecloud/cognito/compromised-passwords",
1143                self.fc.base_url
1144            ))
1145            .json(req)
1146            .send()
1147            .await?;
1148        FakeCloud::parse(resp).await
1149    }
1150
1151    /// List every registered WebAuthn credential across pools.
1152    pub async fn get_webauthn_credentials(&self) -> Result<WebAuthnCredentialsResponse, Error> {
1153        let resp = self
1154            .fc
1155            .client
1156            .get(format!(
1157                "{}/_fakecloud/cognito/webauthn-credentials",
1158                self.fc.base_url
1159            ))
1160            .send()
1161            .await?;
1162        FakeCloud::parse(resp).await
1163    }
1164}
1165
1166// ── API Gateway v2 ──────────────────────────────────────────────────
1167
1168pub struct ApiGatewayV2Client<'a> {
1169    fc: &'a FakeCloud,
1170}
1171
1172impl ApiGatewayV2Client<'_> {
1173    /// List all HTTP API requests that were received and processed.
1174    pub async fn get_requests(&self) -> Result<ApiGatewayV2RequestsResponse, Error> {
1175        let resp = self
1176            .fc
1177            .client
1178            .get(format!(
1179                "{}/_fakecloud/apigatewayv2/requests",
1180                self.fc.base_url
1181            ))
1182            .send()
1183            .await?;
1184        FakeCloud::parse(resp).await
1185    }
1186
1187    /// List currently-active WebSocket connections across all WebSocket
1188    /// APIs the server has seen.
1189    pub async fn connections(&self) -> Result<ApiGatewayV2ConnectionsResponse, Error> {
1190        let resp = self
1191            .fc
1192            .client
1193            .get(format!(
1194                "{}/_fakecloud/apigatewayv2/connections",
1195                self.fc.base_url
1196            ))
1197            .send()
1198            .await?;
1199        FakeCloud::parse(resp).await
1200    }
1201
1202    /// Fetch the mTLS configuration recorded for a custom domain name
1203    /// (truststore bundle, version, validity). Pass-through JSON — the
1204    /// shape mirrors what the server emits unmodified.
1205    pub async fn mtls_info(&self, name: &str) -> Result<serde_json::Value, Error> {
1206        let resp = self
1207            .fc
1208            .client
1209            .get(format!(
1210                "{}/_fakecloud/apigatewayv2/domain-names/{}/mtls-info",
1211                self.fc.base_url, name
1212            ))
1213            .send()
1214            .await?;
1215        FakeCloud::parse(resp).await
1216    }
1217
1218    /// Build the WebSocket upgrade URL for `api_id`. The SDK doesn't
1219    /// open the socket itself — pair this with `tokio-tungstenite` or
1220    /// any other WebSocket client in test code.
1221    pub fn ws_url(&self, api_id: &str, stage: Option<&str>) -> String {
1222        let base = self
1223            .fc
1224            .base_url
1225            .replacen("https://", "wss://", 1)
1226            .replacen("http://", "ws://", 1);
1227        // API Gateway v2 API IDs are 10-char alphanumeric so encoding is
1228        // a no-op, but defend against future changes by passing through
1229        // reqwest's URL builder for the query parameter.
1230        let url = reqwest::Url::parse(&format!("{}/_fakecloud/apigatewayv2/ws/{}", base, api_id))
1231            .expect("base url + api id form a valid URL");
1232        match stage {
1233            Some(s) => {
1234                let mut u = url;
1235                u.query_pairs_mut().append_pair("stage", s);
1236                u.to_string()
1237            }
1238            None => url.to_string(),
1239        }
1240    }
1241}
1242
1243// ── Step Functions ──────────────────────────────────────────────────
1244
1245pub struct StepFunctionsClient<'a> {
1246    fc: &'a FakeCloud,
1247}
1248
1249impl StepFunctionsClient<'_> {
1250    /// List all Step Functions executions with status, input, output, and timestamps.
1251    pub async fn get_executions(&self) -> Result<StepFunctionsExecutionsResponse, Error> {
1252        let resp = self
1253            .fc
1254            .client
1255            .get(format!(
1256                "{}/_fakecloud/stepfunctions/executions",
1257                self.fc.base_url
1258            ))
1259            .send()
1260            .await?;
1261        FakeCloud::parse(resp).await
1262    }
1263
1264    /// List `StartSyncExecution` invocations with billing details. EXPRESS state
1265    /// machines only — async (`StartExecution`) calls show up under
1266    /// [`Self::get_executions`] instead.
1267    pub async fn get_sync_executions(&self) -> Result<StepFunctionsSyncExecutionsResponse, Error> {
1268        let resp = self
1269            .fc
1270            .client
1271            .get(format!(
1272                "{}/_fakecloud/stepfunctions/sync-executions",
1273                self.fc.base_url
1274            ))
1275            .send()
1276            .await?;
1277        FakeCloud::parse(resp).await
1278    }
1279
1280    /// Return the nested call tree rooted at `execution_arn`. Children are
1281    /// executions that were started by their parent via
1282    /// `arn:aws:states:::states:startExecution[.sync]`.
1283    /// Inject an activity task into the worker pool, skipping a
1284    /// state-machine execution. Used by tests that want to exercise the
1285    /// worker-pool API surface (`GetActivityTask` / `SendTaskSuccess`)
1286    /// without spinning up an ASL workflow.
1287    pub async fn enqueue_activity_task(
1288        &self,
1289        req: &SfnEnqueueActivityTaskRequest,
1290    ) -> Result<SfnEnqueueActivityTaskResponse, Error> {
1291        let resp = self
1292            .fc
1293            .client
1294            .post(format!(
1295                "{}/_fakecloud/stepfunctions/enqueue-activity-task",
1296                self.fc.base_url
1297            ))
1298            .json(req)
1299            .send()
1300            .await?;
1301        FakeCloud::parse(resp).await
1302    }
1303
1304    pub async fn get_execution_tree(
1305        &self,
1306        execution_arn: &str,
1307    ) -> Result<StepFunctionsExecutionTreeResponse, Error> {
1308        let mut encoded = String::with_capacity(execution_arn.len());
1309        for b in execution_arn.bytes() {
1310            match b {
1311                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1312                    encoded.push(b as char);
1313                }
1314                _ => encoded.push_str(&format!("%{:02X}", b)),
1315            }
1316        }
1317        let resp = self
1318            .fc
1319            .client
1320            .get(format!(
1321                "{}/_fakecloud/stepfunctions/execution-tree/{}",
1322                self.fc.base_url, encoded
1323            ))
1324            .send()
1325            .await?;
1326        FakeCloud::parse(resp).await
1327    }
1328}
1329
1330// ── Bedrock ─────────────────────────────────────────────────────────
1331
1332pub struct BedrockClient<'a> {
1333    fc: &'a FakeCloud,
1334}
1335
1336impl BedrockClient<'_> {
1337    /// List recorded Bedrock runtime invocations. Each invocation has an optional
1338    /// `error` field that is set for calls faulted via [`Self::queue_fault`].
1339    pub async fn get_invocations(&self) -> Result<BedrockInvocationsResponse, Error> {
1340        let resp = self
1341            .fc
1342            .client
1343            .get(format!(
1344                "{}/_fakecloud/bedrock/invocations",
1345                self.fc.base_url
1346            ))
1347            .send()
1348            .await?;
1349        FakeCloud::parse(resp).await
1350    }
1351
1352    /// Configure a single canned response for a Bedrock model.
1353    pub async fn set_model_response(
1354        &self,
1355        model_id: &str,
1356        response: &str,
1357    ) -> Result<BedrockModelResponseConfig, Error> {
1358        let resp = self
1359            .fc
1360            .client
1361            .post(format!(
1362                "{}/_fakecloud/bedrock/models/{}/response",
1363                self.fc.base_url, model_id
1364            ))
1365            .header("content-type", "text/plain")
1366            .body(response.to_string())
1367            .send()
1368            .await?;
1369        FakeCloud::parse(resp).await
1370    }
1371
1372    /// Replace the prompt-conditional response rule list for a Bedrock model.
1373    pub async fn set_response_rules(
1374        &self,
1375        model_id: &str,
1376        rules: &[BedrockResponseRule],
1377    ) -> Result<BedrockModelResponseConfig, Error> {
1378        let body = serde_json::json!({ "rules": rules });
1379        let resp = self
1380            .fc
1381            .client
1382            .post(format!(
1383                "{}/_fakecloud/bedrock/models/{}/responses",
1384                self.fc.base_url, model_id
1385            ))
1386            .json(&body)
1387            .send()
1388            .await?;
1389        FakeCloud::parse(resp).await
1390    }
1391
1392    /// Clear all prompt-conditional response rules for a Bedrock model.
1393    pub async fn clear_response_rules(
1394        &self,
1395        model_id: &str,
1396    ) -> Result<BedrockModelResponseConfig, Error> {
1397        let resp = self
1398            .fc
1399            .client
1400            .delete(format!(
1401                "{}/_fakecloud/bedrock/models/{}/responses",
1402                self.fc.base_url, model_id
1403            ))
1404            .send()
1405            .await?;
1406        FakeCloud::parse(resp).await
1407    }
1408
1409    /// Queue a fault rule that will cause the next matching Bedrock runtime call(s) to fail.
1410    pub async fn queue_fault(
1411        &self,
1412        rule: &BedrockFaultRule,
1413    ) -> Result<BedrockStatusResponse, Error> {
1414        let resp = self
1415            .fc
1416            .client
1417            .post(format!("{}/_fakecloud/bedrock/faults", self.fc.base_url))
1418            .json(rule)
1419            .send()
1420            .await?;
1421        FakeCloud::parse(resp).await
1422    }
1423
1424    /// List currently queued fault rules.
1425    pub async fn get_faults(&self) -> Result<BedrockFaultsResponse, Error> {
1426        let resp = self
1427            .fc
1428            .client
1429            .get(format!("{}/_fakecloud/bedrock/faults", self.fc.base_url))
1430            .send()
1431            .await?;
1432        FakeCloud::parse(resp).await
1433    }
1434
1435    /// Clear all queued fault rules.
1436    pub async fn clear_faults(&self) -> Result<BedrockStatusResponse, Error> {
1437        let resp = self
1438            .fc
1439            .client
1440            .delete(format!("{}/_fakecloud/bedrock/faults", self.fc.base_url))
1441            .send()
1442            .await?;
1443        FakeCloud::parse(resp).await
1444    }
1445}
1446
1447// ── Bedrock Agent (control plane) ───────────────────────────────────
1448
1449pub struct BedrockAgentClient<'a> {
1450    fc: &'a FakeCloud,
1451}
1452
1453impl BedrockAgentClient<'_> {
1454    /// List every recorded Bedrock Agent with its aliases, versions,
1455    /// knowledge-base attachments, and collaborators flattened into one
1456    /// row each.
1457    pub async fn get_agents(&self) -> Result<BedrockAgentAgentsResponse, Error> {
1458        let resp = self
1459            .fc
1460            .client
1461            .get(format!(
1462                "{}/_fakecloud/bedrock-agent/agents",
1463                self.fc.base_url
1464            ))
1465            .send()
1466            .await?;
1467        FakeCloud::parse(resp).await
1468    }
1469}
1470
1471// ── Bedrock Agent Runtime (data plane) ──────────────────────────────
1472
1473pub struct BedrockAgentRuntimeClient<'a> {
1474    fc: &'a FakeCloud,
1475}
1476
1477impl BedrockAgentRuntimeClient<'_> {
1478    /// List every recorded InvokeAgent / InvokeInlineAgent / InvokeFlow
1479    /// / Retrieve / RetrieveAndGenerate / CreateInvocation call.
1480    pub async fn get_invocations(&self) -> Result<BedrockAgentRuntimeInvocationsResponse, Error> {
1481        let resp = self
1482            .fc
1483            .client
1484            .get(format!(
1485                "{}/_fakecloud/bedrock-agent-runtime/invocations",
1486                self.fc.base_url
1487            ))
1488            .send()
1489            .await?;
1490        FakeCloud::parse(resp).await
1491    }
1492}
1493
1494// ── ECS ─────────────────────────────────────────────────────────────
1495
1496pub struct EcsClient<'a> {
1497    fc: &'a FakeCloud,
1498}
1499
1500impl EcsClient<'_> {
1501    /// List all ECS clusters across every account the server has seen.
1502    /// Deterministic, sorted by cluster ARN. Bypasses the ECS control-plane
1503    /// auth and pagination so tests can assert directly on raw state.
1504    pub async fn get_clusters(&self) -> Result<EcsClustersResponse, Error> {
1505        let resp = self
1506            .fc
1507            .client
1508            .get(format!("{}/_fakecloud/ecs/clusters", self.fc.base_url))
1509            .send()
1510            .await?;
1511        FakeCloud::parse(resp).await
1512    }
1513
1514    /// List every task the server has seen. Optional `cluster` / `status`
1515    /// filters restrict the dump when supplied.
1516    pub async fn get_tasks(
1517        &self,
1518        cluster: Option<&str>,
1519        status: Option<&str>,
1520    ) -> Result<EcsTasksResponse, Error> {
1521        fn encode(s: &str) -> String {
1522            let mut out = String::with_capacity(s.len());
1523            for b in s.bytes() {
1524                match b {
1525                    b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1526                        out.push(b as char);
1527                    }
1528                    _ => out.push_str(&format!("%{:02X}", b)),
1529                }
1530            }
1531            out
1532        }
1533        let mut url = format!("{}/_fakecloud/ecs/tasks", self.fc.base_url);
1534        let mut sep = '?';
1535        if let Some(c) = cluster {
1536            url.push(sep);
1537            url.push_str("cluster=");
1538            url.push_str(&encode(c));
1539            sep = '&';
1540        }
1541        if let Some(s) = status {
1542            url.push(sep);
1543            url.push_str("status=");
1544            url.push_str(&encode(s));
1545        }
1546        let resp = self.fc.client.get(url).send().await?;
1547        FakeCloud::parse(resp).await
1548    }
1549
1550    /// Tail stored container stdout/stderr for a single task. Works even
1551    /// when no `awslogs` driver is configured — fakecloud always captures
1552    /// docker stdout/stderr on exit and keeps it on the task.
1553    pub async fn get_task_logs(&self, task_id: &str) -> Result<EcsTaskLogsResponse, Error> {
1554        let resp = self
1555            .fc
1556            .client
1557            .get(format!(
1558                "{}/_fakecloud/ecs/tasks/{}/logs",
1559                self.fc.base_url, task_id
1560            ))
1561            .send()
1562            .await?;
1563        FakeCloud::parse(resp).await
1564    }
1565
1566    /// Force the running container behind a task to stop.
1567    pub async fn force_stop_task(&self, task_id: &str) -> Result<EcsTask, Error> {
1568        let resp = self
1569            .fc
1570            .client
1571            .post(format!(
1572                "{}/_fakecloud/ecs/tasks/{}/force-stop",
1573                self.fc.base_url, task_id
1574            ))
1575            .send()
1576            .await?;
1577        FakeCloud::parse(resp).await
1578    }
1579
1580    /// Flip the task to STOPPED without killing the underlying container
1581    /// — useful for simulating task failures in tests.
1582    pub async fn mark_task_failed(
1583        &self,
1584        task_id: &str,
1585        req: &EcsMarkFailedRequest,
1586    ) -> Result<EcsTask, Error> {
1587        let resp = self
1588            .fc
1589            .client
1590            .post(format!(
1591                "{}/_fakecloud/ecs/tasks/{}/mark-failed",
1592                self.fc.base_url, task_id
1593            ))
1594            .json(req)
1595            .send()
1596            .await?;
1597        FakeCloud::parse(resp).await
1598    }
1599
1600    /// Replay the lifecycle event log.
1601    pub async fn get_events(&self) -> Result<EcsEventsResponse, Error> {
1602        let resp = self
1603            .fc
1604            .client
1605            .get(format!("{}/_fakecloud/ecs/events", self.fc.base_url))
1606            .send()
1607            .await?;
1608        FakeCloud::parse(resp).await
1609    }
1610
1611    /// Get the ECS task-metadata v4 dump keyed by full task ARN. Unlike
1612    /// the per-container `/_fakecloud/ecs/v4/{task_id}` endpoint, this
1613    /// is keyed by ARN for assertion-friendly use from tests that hold
1614    /// the `RunTask` response. Returned as raw JSON because the shape
1615    /// is the aggregated container-metadata document AWS surfaces at
1616    /// `ECS_CONTAINER_METADATA_URI_V4`.
1617    pub async fn get_metadata_by_arn(&self, task_arn: &str) -> Result<serde_json::Value, Error> {
1618        let mut encoded = String::with_capacity(task_arn.len());
1619        for b in task_arn.bytes() {
1620            match b {
1621                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1622                    encoded.push(b as char);
1623                }
1624                _ => encoded.push_str(&format!("%{:02X}", b)),
1625            }
1626        }
1627        let resp = self
1628            .fc
1629            .client
1630            .get(format!(
1631                "{}/_fakecloud/ecs/metadata/{}",
1632                self.fc.base_url, encoded
1633            ))
1634            .send()
1635            .await?;
1636        FakeCloud::parse(resp).await
1637    }
1638
1639    /// Fetch the IAM task-role credentials that fakecloud hands out via
1640    /// `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` to the container. Fields
1641    /// use AWS's native PascalCase (`AccessKeyId`, `SecretAccessKey`,
1642    /// `Token`, `Expiration`, `RoleArn`).
1643    pub async fn task_credentials(
1644        &self,
1645        task_id: &str,
1646    ) -> Result<EcsTaskCredentialsResponse, Error> {
1647        let resp = self
1648            .fc
1649            .client
1650            .get(format!(
1651                "{}/_fakecloud/ecs/creds/{}",
1652                self.fc.base_url, task_id
1653            ))
1654            .send()
1655            .await?;
1656        FakeCloud::parse(resp).await
1657    }
1658
1659    /// Fetch the v3 ECS task metadata document for `task_id`. Returned
1660    /// as raw JSON (`Cluster`, `TaskARN`, `Family`, `Revision`,
1661    /// `DesiredStatus`, `KnownStatus`, `Containers`, `Limits`,
1662    /// `Networks`, ...) so callers can pick the fields they need
1663    /// without dragging in the entire metadata schema.
1664    pub async fn task_metadata_v3(&self, task_id: &str) -> Result<serde_json::Value, Error> {
1665        let resp = self
1666            .fc
1667            .client
1668            .get(format!(
1669                "{}/_fakecloud/ecs/v3/{}",
1670                self.fc.base_url, task_id
1671            ))
1672            .send()
1673            .await?;
1674        FakeCloud::parse(resp).await
1675    }
1676
1677    /// Fetch the v4 ECS task metadata document for `task_id`. Same
1678    /// pass-through shape as the v3 endpoint; v4 adds extra container
1679    /// runtime fields the server emits as-is.
1680    pub async fn task_metadata_v4(&self, task_id: &str) -> Result<serde_json::Value, Error> {
1681        let resp = self
1682            .fc
1683            .client
1684            .get(format!(
1685                "{}/_fakecloud/ecs/v4/{}",
1686                self.fc.base_url, task_id
1687            ))
1688            .send()
1689            .await?;
1690        FakeCloud::parse(resp).await
1691    }
1692}
1693
1694// ── Route 53 ────────────────────────────────────────────────────────
1695
1696pub struct Route53Client<'a> {
1697    fc: &'a FakeCloud,
1698}
1699
1700impl Route53Client<'_> {
1701    /// Fetch the deterministic DNSSEC chain-of-trust material for a
1702    /// hosted zone. Returns `Err(Error::Api { status: 404, .. })` when
1703    /// the zone has no ACTIVE KSK.
1704    pub async fn dnssec_material(
1705        &self,
1706        zone_id: &str,
1707    ) -> Result<Route53DnssecMaterialResponse, Error> {
1708        let resp = self
1709            .fc
1710            .client
1711            .get(format!(
1712                "{}/_fakecloud/route53/zones/{}/dnssec",
1713                self.fc.base_url, zone_id
1714            ))
1715            .send()
1716            .await?;
1717        FakeCloud::parse(resp).await
1718    }
1719
1720    /// Sign an RRset under the zone's first ACTIVE KSK and return the
1721    /// raw RRSIG fields so tests can verify the signature against the
1722    /// zone's DNSKEY material.
1723    pub async fn dnssec_sign(
1724        &self,
1725        zone_id: &str,
1726        req: &Route53DnssecSignRequest,
1727    ) -> Result<Route53DnssecSignResponse, Error> {
1728        let resp = self
1729            .fc
1730            .client
1731            .post(format!(
1732                "{}/_fakecloud/route53/zones/{}/dnssec/sign",
1733                self.fc.base_url, zone_id
1734            ))
1735            .json(req)
1736            .send()
1737            .await?;
1738        FakeCloud::parse(resp).await
1739    }
1740}
1741
1742// ── Athena ──────────────────────────────────────────────────────────
1743
1744pub struct AthenaClient<'a> {
1745    fc: &'a FakeCloud,
1746}
1747
1748impl AthenaClient<'_> {
1749    /// List every named query stored in the Athena named-query registry
1750    /// across all workgroups for the default account. Bumps `last_used_at`
1751    /// each time `StartQueryExecution` resolves a query by id so test
1752    /// authors can assert that a saved query was actually exercised.
1753    pub async fn get_named_queries(&self) -> Result<AthenaNamedQueriesResponse, Error> {
1754        let resp = self
1755            .fc
1756            .client
1757            .get(format!(
1758                "{}/_fakecloud/athena/named-queries",
1759                self.fc.base_url
1760            ))
1761            .send()
1762            .await?;
1763        FakeCloud::parse(resp).await
1764    }
1765}
1766
1767// ── Organizations ───────────────────────────────────────────────────
1768
1769pub struct OrganizationsClient<'a> {
1770    fc: &'a FakeCloud,
1771}
1772
1773impl OrganizationsClient<'_> {
1774    /// List every member account in the org with lifecycle state,
1775    /// parent OU, tags, and directly-attached SCPs. Returns an empty
1776    /// `accounts` list (and `None` for management/master ids) when no
1777    /// organization has been created yet.
1778    pub async fn get_accounts(&self) -> Result<OrganizationsAccountsResponse, Error> {
1779        let resp = self
1780            .fc
1781            .client
1782            .get(format!(
1783                "{}/_fakecloud/organizations/accounts",
1784                self.fc.base_url
1785            ))
1786            .send()
1787            .await?;
1788        FakeCloud::parse(resp).await
1789    }
1790
1791    /// List every billing-responsibility transfer in the org, with
1792    /// direction, lifecycle status, and the active handshake. Returns an
1793    /// empty list when no organization has been created.
1794    pub async fn get_responsibility_transfers(
1795        &self,
1796    ) -> Result<OrganizationsResponsibilityTransfersResponse, Error> {
1797        let resp = self
1798            .fc
1799            .client
1800            .get(format!(
1801                "{}/_fakecloud/organizations/responsibility-transfers",
1802                self.fc.base_url
1803            ))
1804            .send()
1805            .await?;
1806        FakeCloud::parse(resp).await
1807    }
1808}
1809
1810// ── ACM ─────────────────────────────────────────────────────────────
1811
1812pub struct AcmClient<'a> {
1813    fc: &'a FakeCloud,
1814}
1815
1816impl AcmClient<'_> {
1817    fn certificate_id(arn_or_id: &str) -> String {
1818        match arn_or_id.rfind("certificate/") {
1819            Some(idx) => arn_or_id[idx + "certificate/".len()..].to_string(),
1820            None => arn_or_id.to_string(),
1821        }
1822    }
1823
1824    /// Flip a stored ACM certificate's status (and optionally record a
1825    /// failure reason). Accepts either the full ACM ARN or just the
1826    /// trailing UUID. Returns `Error::Api { status: 404, .. }` if the
1827    /// certificate is unknown.
1828    pub async fn set_certificate_status(
1829        &self,
1830        arn_or_id: &str,
1831        req: &AcmCertificateStatusRequest,
1832    ) -> Result<(), Error> {
1833        let id = Self::certificate_id(arn_or_id);
1834        let resp = self
1835            .fc
1836            .client
1837            .post(format!(
1838                "{}/_fakecloud/acm/certificates/{}/status",
1839                self.fc.base_url, id
1840            ))
1841            .json(req)
1842            .send()
1843            .await?;
1844        if !resp.status().is_success() {
1845            let status = resp.status().as_u16();
1846            let body = resp.text().await.unwrap_or_default();
1847            return Err(Error::Api { status, body });
1848        }
1849        Ok(())
1850    }
1851
1852    /// Inspect a stored certificate's PEM block counts and byte sizes.
1853    /// `external_ca_validated` is always `false` — fakecloud does not run
1854    /// real X.509 verification.
1855    pub async fn get_certificate_chain_info(
1856        &self,
1857        arn_or_id: &str,
1858    ) -> Result<AcmCertificateChainInfo, Error> {
1859        let id = Self::certificate_id(arn_or_id);
1860        let resp = self
1861            .fc
1862            .client
1863            .get(format!(
1864                "{}/_fakecloud/acm/certificates/{}/chain-info",
1865                self.fc.base_url, id
1866            ))
1867            .send()
1868            .await?;
1869        FakeCloud::parse(resp).await
1870    }
1871
1872    /// Approve a `PENDING_VALIDATION` certificate (synchronous equivalent
1873    /// of "user clicked the validation link"). Flips the cert to `ISSUED`
1874    /// and refreshes its renewal eligibility / RenewalSummary.
1875    pub async fn approve_certificate(&self, arn_or_id: &str) -> Result<(), Error> {
1876        let id = Self::certificate_id(arn_or_id);
1877        let resp = self
1878            .fc
1879            .client
1880            .post(format!(
1881                "{}/_fakecloud/acm/certificates/{}/approve",
1882                self.fc.base_url, id
1883            ))
1884            .send()
1885            .await?;
1886        if !resp.status().is_success() {
1887            let status = resp.status().as_u16();
1888            let body = resp.text().await.unwrap_or_default();
1889            return Err(Error::Api { status, body });
1890        }
1891        Ok(())
1892    }
1893}
1894
1895// ── ECR ─────────────────────────────────────────────────────────────
1896
1897pub struct EcrClient<'a> {
1898    fc: &'a FakeCloud,
1899}
1900
1901impl EcrClient<'_> {
1902    /// List every ECR image across every repository.
1903    pub async fn get_images(&self) -> Result<EcrImagesResponse, Error> {
1904        let resp = self
1905            .fc
1906            .client
1907            .get(format!("{}/_fakecloud/ecr/images", self.fc.base_url))
1908            .send()
1909            .await?;
1910        FakeCloud::parse(resp).await
1911    }
1912
1913    /// List every ECR repository.
1914    pub async fn get_repositories(&self) -> Result<EcrRepositoriesResponse, Error> {
1915        let resp = self
1916            .fc
1917            .client
1918            .get(format!("{}/_fakecloud/ecr/repositories", self.fc.base_url))
1919            .send()
1920            .await?;
1921        FakeCloud::parse(resp).await
1922    }
1923
1924    /// List configured ECR pull-through-cache rules.
1925    pub async fn get_pull_through_rules(&self) -> Result<EcrPullThroughRulesResponse, Error> {
1926        let resp = self
1927            .fc
1928            .client
1929            .get(format!(
1930                "{}/_fakecloud/ecr/pull-through-rules",
1931                self.fc.base_url
1932            ))
1933            .send()
1934            .await?;
1935        FakeCloud::parse(resp).await
1936    }
1937}
1938
1939// ── ELBv2 ───────────────────────────────────────────────────────────
1940
1941pub struct Elbv2Client<'a> {
1942    fc: &'a FakeCloud,
1943}
1944
1945impl Elbv2Client<'_> {
1946    /// List every ELBv2 load balancer (ALB / NLB / GWLB).
1947    pub async fn get_load_balancers(&self) -> Result<Elbv2LoadBalancersResponse, Error> {
1948        let resp = self
1949            .fc
1950            .client
1951            .get(format!(
1952                "{}/_fakecloud/elbv2/load-balancers",
1953                self.fc.base_url
1954            ))
1955            .send()
1956            .await?;
1957        FakeCloud::parse(resp).await
1958    }
1959
1960    /// List every ELBv2 listener.
1961    pub async fn get_listeners(&self) -> Result<Elbv2ListenersResponse, Error> {
1962        let resp = self
1963            .fc
1964            .client
1965            .get(format!("{}/_fakecloud/elbv2/listeners", self.fc.base_url))
1966            .send()
1967            .await?;
1968        FakeCloud::parse(resp).await
1969    }
1970
1971    /// List every ELBv2 routing rule.
1972    pub async fn get_rules(&self) -> Result<Elbv2RulesResponse, Error> {
1973        let resp = self
1974            .fc
1975            .client
1976            .get(format!("{}/_fakecloud/elbv2/rules", self.fc.base_url))
1977            .send()
1978            .await?;
1979        FakeCloud::parse(resp).await
1980    }
1981
1982    /// List every ELBv2 target group with its registered targets and
1983    /// health-check configuration.
1984    pub async fn get_target_groups(&self) -> Result<Elbv2TargetGroupsResponse, Error> {
1985        let resp = self
1986            .fc
1987            .client
1988            .get(format!(
1989                "{}/_fakecloud/elbv2/target-groups",
1990                self.fc.base_url
1991            ))
1992            .send()
1993            .await?;
1994        FakeCloud::parse(resp).await
1995    }
1996
1997    /// Flush buffered access-log records to the configured S3 bucket
1998    /// now. Returns the number of records that were flushed.
1999    pub async fn flush_access_logs(&self) -> Result<Elbv2AccessLogsFlushResponse, Error> {
2000        let resp = self
2001            .fc
2002            .client
2003            .post(format!(
2004                "{}/_fakecloud/elbv2/access-logs/flush",
2005                self.fc.base_url
2006            ))
2007            .send()
2008            .await?;
2009        FakeCloud::parse(resp).await
2010    }
2011}
2012
2013// ── Glue ────────────────────────────────────────────────────────────
2014
2015pub struct GlueClient<'a> {
2016    fc: &'a FakeCloud,
2017}
2018
2019impl GlueClient<'_> {
2020    /// List every configured Glue job.
2021    pub async fn get_jobs(&self) -> Result<GlueJobsResponse, Error> {
2022        let resp = self
2023            .fc
2024            .client
2025            .get(format!("{}/_fakecloud/glue/jobs", self.fc.base_url))
2026            .send()
2027            .await?;
2028        FakeCloud::parse(resp).await
2029    }
2030
2031    /// List Glue JobRun records. Optionally scope to a single job by
2032    /// name (matches the `job_name` query parameter).
2033    pub async fn get_job_runs(&self, job_name: Option<&str>) -> Result<GlueJobRunsResponse, Error> {
2034        fn encode(s: &str) -> String {
2035            let mut out = String::with_capacity(s.len());
2036            for b in s.bytes() {
2037                match b {
2038                    b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2039                        out.push(b as char);
2040                    }
2041                    _ => out.push_str(&format!("%{:02X}", b)),
2042                }
2043            }
2044            out
2045        }
2046        let mut url = format!("{}/_fakecloud/glue/job-runs", self.fc.base_url);
2047        if let Some(name) = job_name {
2048            url.push_str("?job_name=");
2049            url.push_str(&encode(name));
2050        }
2051        let resp = self.fc.client.get(url).send().await?;
2052        FakeCloud::parse(resp).await
2053    }
2054
2055    /// List every configured Glue crawler across all accounts.
2056    pub async fn get_crawlers(&self) -> Result<GlueCrawlersResponse, Error> {
2057        let resp = self
2058            .fc
2059            .client
2060            .get(format!("{}/_fakecloud/glue/crawlers", self.fc.base_url))
2061            .send()
2062            .await?;
2063        FakeCloud::parse(resp).await
2064    }
2065}
2066
2067// ── CloudWatch ──────────────────────────────────────────────────────
2068
2069pub struct CloudWatchClient<'a> {
2070    fc: &'a FakeCloud,
2071}
2072
2073impl CloudWatchClient<'_> {
2074    /// List every metric and composite alarm across all accounts and
2075    /// regions, flattened with current state.
2076    pub async fn get_alarms(&self) -> Result<CloudWatchAlarmsResponse, Error> {
2077        let resp = self
2078            .fc
2079            .client
2080            .get(format!("{}/_fakecloud/cloudwatch/alarms", self.fc.base_url))
2081            .send()
2082            .await?;
2083        FakeCloud::parse(resp).await
2084    }
2085
2086    /// List every unique metric series (account, region, namespace,
2087    /// metric, dimensions) with its datapoint count and latest value.
2088    pub async fn get_metrics(&self) -> Result<CloudWatchMetricsResponse, Error> {
2089        let resp = self
2090            .fc
2091            .client
2092            .get(format!(
2093                "{}/_fakecloud/cloudwatch/metrics",
2094                self.fc.base_url
2095            ))
2096            .send()
2097            .await?;
2098        FakeCloud::parse(resp).await
2099    }
2100}
2101
2102// ── Firehose ────────────────────────────────────────────────────────
2103
2104pub struct FirehoseClient<'a> {
2105    fc: &'a FakeCloud,
2106}
2107
2108impl FirehoseClient<'_> {
2109    /// List every Firehose delivery stream across all accounts and
2110    /// regions, with stream type, lifecycle status, encryption summary,
2111    /// and destination count.
2112    pub async fn get_delivery_streams(&self) -> Result<FirehoseDeliveryStreamsResponse, Error> {
2113        let resp = self
2114            .fc
2115            .client
2116            .get(format!(
2117                "{}/_fakecloud/firehose/delivery-streams",
2118                self.fc.base_url
2119            ))
2120            .send()
2121            .await?;
2122        FakeCloud::parse(resp).await
2123    }
2124}
2125
2126// ── Logs ────────────────────────────────────────────────────────────
2127
2128pub struct LogsClient<'a> {
2129    fc: &'a FakeCloud,
2130}
2131
2132impl LogsClient<'_> {
2133    /// Seed a synthetic CloudWatch Logs anomaly so tests can exercise
2134    /// `ListAnomalies` / `UpdateAnomaly` deterministically. Returns the
2135    /// minted anomaly id.
2136    pub async fn inject_anomaly(
2137        &self,
2138        req: &LogsAnomalyInjectRequest,
2139    ) -> Result<LogsAnomalyInjectResponse, Error> {
2140        let resp = self
2141            .fc
2142            .client
2143            .post(format!(
2144                "{}/_fakecloud/logs/anomalies/inject",
2145                self.fc.base_url
2146            ))
2147            .json(req)
2148            .send()
2149            .await?;
2150        FakeCloud::parse(resp).await
2151    }
2152
2153    /// Snapshot the per-delivery configuration (one row per
2154    /// `Delivery`), joined with the `log_type` of its associated
2155    /// `DeliverySource`.
2156    pub async fn get_delivery_config(&self) -> Result<LogsDeliveryConfigResponse, Error> {
2157        let resp = self
2158            .fc
2159            .client
2160            .get(format!(
2161                "{}/_fakecloud/logs/delivery-config",
2162                self.fc.base_url
2163            ))
2164            .send()
2165            .await?;
2166        FakeCloud::parse(resp).await
2167    }
2168
2169    /// Get the parsed `Fields` lists from a single log group's index
2170    /// policies. Returns `Error::Api { status: 404, .. }` if the log
2171    /// group is unknown.
2172    pub async fn get_field_indexes(
2173        &self,
2174        log_group_name: &str,
2175    ) -> Result<LogsFieldIndexesResponse, Error> {
2176        let resp = self
2177            .fc
2178            .client
2179            .get(format!(
2180                "{}/_fakecloud/logs/field-indexes/{}",
2181                self.fc.base_url,
2182                utf8_percent_encode(log_group_name, NON_ALPHANUMERIC)
2183            ))
2184            .send()
2185            .await?;
2186        FakeCloud::parse(resp).await
2187    }
2188}
2189
2190impl Route53Client<'_> {
2191    /// Flip a stored Route 53 health check's reported status (and
2192    /// optionally its last-failure observation) so tests can simulate
2193    /// failover scenarios without a live checker. Returns
2194    /// `Error::Api { status: 404, .. }` if the health check is unknown.
2195    pub async fn set_health_check_status(
2196        &self,
2197        id: &str,
2198        req: &Route53HealthCheckStatusRequest,
2199    ) -> Result<(), Error> {
2200        let resp = self
2201            .fc
2202            .client
2203            .post(format!(
2204                "{}/_fakecloud/route53/health-checks/{}/status",
2205                self.fc.base_url, id
2206            ))
2207            .json(req)
2208            .send()
2209            .await?;
2210        if !resp.status().is_success() {
2211            let status = resp.status().as_u16();
2212            let body = resp.text().await.unwrap_or_default();
2213            return Err(Error::Api { status, body });
2214        }
2215        Ok(())
2216    }
2217}
2218
2219// ── Scheduler ───────────────────────────────────────────────────────
2220
2221pub struct SchedulerClient<'a> {
2222    fc: &'a FakeCloud,
2223}
2224
2225impl SchedulerClient<'_> {
2226    /// List every EventBridge Scheduler schedule across every account
2227    /// and group.
2228    pub async fn get_schedules(&self) -> Result<SchedulerSchedulesResponse, Error> {
2229        let resp = self
2230            .fc
2231            .client
2232            .get(format!(
2233                "{}/_fakecloud/scheduler/schedules",
2234                self.fc.base_url
2235            ))
2236            .send()
2237            .await?;
2238        FakeCloud::parse(resp).await
2239    }
2240
2241    /// Fire a single schedule by `(group, name)` immediately, bypassing
2242    /// the cron tick. Returns the schedule + target ARN that received the
2243    /// invocation.
2244    pub async fn fire_schedule(
2245        &self,
2246        group: &str,
2247        name: &str,
2248    ) -> Result<FireScheduleResponse, Error> {
2249        let resp = self
2250            .fc
2251            .client
2252            .post(format!(
2253                "{}/_fakecloud/scheduler/fire/{}/{}",
2254                self.fc.base_url, group, name
2255            ))
2256            .send()
2257            .await?;
2258        FakeCloud::parse(resp).await
2259    }
2260}
2261
2262// ── SSM ─────────────────────────────────────────────────────────────
2263
2264pub struct SsmClient<'a> {
2265    fc: &'a FakeCloud,
2266}
2267
2268impl SsmClient<'_> {
2269    /// Force a specific SSM command's status. Useful for driving the
2270    /// `Pending`/`InProgress`/`Success` lifecycle synchronously in tests.
2271    pub async fn set_command_status(
2272        &self,
2273        command_id: &str,
2274        req: &SetSsmCommandStatusRequest,
2275    ) -> Result<SetSsmCommandStatusResponse, Error> {
2276        let resp = self
2277            .fc
2278            .client
2279            .post(format!(
2280                "{}/_fakecloud/ssm/commands/{}/status",
2281                self.fc.base_url, command_id
2282            ))
2283            .json(req)
2284            .send()
2285            .await?;
2286        FakeCloud::parse(resp).await
2287    }
2288
2289    /// Flip a command's invocations to `Failed`. `req` is optional; when
2290    /// `None`, the server uses default values (all invocations, default
2291    /// account, generic "Failed" status detail).
2292    pub async fn fail_command(
2293        &self,
2294        command_id: &str,
2295        req: Option<&FailSsmCommandRequest>,
2296    ) -> Result<FailSsmCommandResponse, Error> {
2297        let mut builder = self.fc.client.post(format!(
2298            "{}/_fakecloud/ssm/commands/{}/fail",
2299            self.fc.base_url, command_id
2300        ));
2301        if let Some(body) = req {
2302            builder = builder.json(body);
2303        }
2304        let resp = builder.send().await?;
2305        FakeCloud::parse(resp).await
2306    }
2307
2308    /// List recorded parameter-policy events for an account. Pass `None`
2309    /// to use the server's default account.
2310    pub async fn parameter_policy_events(
2311        &self,
2312        account_id: Option<&str>,
2313    ) -> Result<SsmParameterPolicyEventsResponse, Error> {
2314        let mut url = format!(
2315            "{}/_fakecloud/ssm/parameter-policy-events",
2316            self.fc.base_url
2317        );
2318        if let Some(id) = account_id {
2319            url.push_str("?accountId=");
2320            url.push_str(id);
2321        }
2322        let resp = self.fc.client.get(url).send().await?;
2323        FakeCloud::parse(resp).await
2324    }
2325
2326    /// Inject a fake SSM session record so tests can exercise
2327    /// `DescribeSessions`/`TerminateSession` without going through
2328    /// `StartSession`.
2329    pub async fn inject_session(
2330        &self,
2331        req: &InjectSsmSessionRequest,
2332    ) -> Result<InjectSsmSessionResponse, Error> {
2333        let resp = self
2334            .fc
2335            .client
2336            .post(format!(
2337                "{}/_fakecloud/ssm/sessions/inject",
2338                self.fc.base_url
2339            ))
2340            .json(req)
2341            .send()
2342            .await?;
2343        FakeCloud::parse(resp).await
2344    }
2345}
2346
2347// ── KMS ─────────────────────────────────────────────────────────────
2348
2349pub struct KmsClient<'a> {
2350    fc: &'a FakeCloud,
2351}
2352
2353impl KmsClient<'_> {
2354    /// List recorded KMS data-plane invocations.
2355    pub async fn usage(&self) -> Result<KmsUsageResponse, Error> {
2356        let resp = self
2357            .fc
2358            .client
2359            .get(format!("{}/_fakecloud/kms/usage", self.fc.base_url))
2360            .send()
2361            .await?;
2362        FakeCloud::parse(resp).await
2363    }
2364}
2365
2366// ── WAFv2 ───────────────────────────────────────────────────────────
2367
2368pub struct WafV2Client<'a> {
2369    fc: &'a FakeCloud,
2370}
2371
2372impl WafV2Client<'_> {
2373    /// Evaluate a synthetic request against a `WebACL` and return the
2374    /// raw verdict JSON. Both request and response shapes are
2375    /// intentionally free-form so the SDK doesn't have to track every
2376    /// new field the evaluator emits.
2377    pub async fn evaluate(&self, body: &serde_json::Value) -> Result<serde_json::Value, Error> {
2378        let resp = self
2379            .fc
2380            .client
2381            .post(format!("{}/_fakecloud/wafv2/evaluate", self.fc.base_url))
2382            .json(body)
2383            .send()
2384            .await?;
2385        FakeCloud::parse(resp).await
2386    }
2387}
2388
2389// ── CloudFront ──────────────────────────────────────────────────────
2390
2391pub struct CloudFrontClient<'a> {
2392    fc: &'a FakeCloud,
2393}
2394
2395impl CloudFrontClient<'_> {
2396    /// Force a stored CloudFront Distribution into a new status (typically
2397    /// `"Deployed"` or `"InProgress"`). Returns an `Api { status: 404, .. }`
2398    /// error when the distribution doesn't exist.
2399    pub async fn set_distribution_status(
2400        &self,
2401        distribution_id: &str,
2402        req: &CloudFrontDistributionStatusRequest,
2403    ) -> Result<(), Error> {
2404        let resp = self
2405            .fc
2406            .client
2407            .post(format!(
2408                "{}/_fakecloud/cloudfront/distributions/{}/status",
2409                self.fc.base_url, distribution_id
2410            ))
2411            .json(req)
2412            .send()
2413            .await?;
2414        let status = resp.status().as_u16();
2415        if !resp.status().is_success() {
2416            let body = resp.text().await.unwrap_or_default();
2417            return Err(Error::Api { status, body });
2418        }
2419        Ok(())
2420    }
2421}
2422
2423impl Elbv2Client<'_> {
2424    /// Snapshot the ELBv2 WAF count-metric registry. The `counts` field
2425    /// is intentionally free-form JSON because its shape tracks the
2426    /// service-internal metric layout.
2427    pub async fn waf_counts(&self) -> Result<Elbv2WafCountsResponse, Error> {
2428        let resp = self
2429            .fc
2430            .client
2431            .get(format!("{}/_fakecloud/elbv2/waf-counts", self.fc.base_url))
2432            .send()
2433            .await?;
2434        FakeCloud::parse(resp).await
2435    }
2436}