Skip to main content

fakecloud_sdk/
client.rs

1use crate::error::Error;
2use crate::types::*;
3
4/// Client for the fakecloud introspection and simulation API (`/_fakecloud/*`).
5pub struct FakeCloud {
6    base_url: String,
7    client: reqwest::Client,
8}
9
10impl FakeCloud {
11    /// Create a new client pointing at the given fakecloud base URL (e.g. `http://localhost:4566`).
12    pub fn new(base_url: &str) -> Self {
13        Self {
14            base_url: base_url.trim_end_matches('/').to_string(),
15            client: reqwest::Client::new(),
16        }
17    }
18
19    // ── Health & Reset ──────────────────────────────────────────────
20
21    /// Check server health.
22    pub async fn health(&self) -> Result<HealthResponse, Error> {
23        let resp = self
24            .client
25            .get(format!("{}/_fakecloud/health", self.base_url))
26            .send()
27            .await?;
28        Self::parse(resp).await
29    }
30
31    /// Reset all service state. Uses the legacy `/_reset` endpoint.
32    pub async fn reset(&self) -> Result<ResetResponse, Error> {
33        let resp = self
34            .client
35            .post(format!("{}/_reset", self.base_url))
36            .send()
37            .await?;
38        Self::parse(resp).await
39    }
40
41    /// Create an IAM admin user in a specific account. Returns credentials
42    /// for the new user. Solves the multi-account bootstrap problem: the
43    /// root bypass only targets the default account, so this endpoint lets
44    /// callers create credentials for any account.
45    pub async fn create_admin(
46        &self,
47        account_id: &str,
48        user_name: &str,
49    ) -> Result<CreateAdminResponse, Error> {
50        let resp = self
51            .client
52            .post(format!("{}/_fakecloud/iam/create-admin", self.base_url))
53            .json(&CreateAdminRequest {
54                account_id: account_id.to_string(),
55                user_name: user_name.to_string(),
56            })
57            .send()
58            .await?;
59        Self::parse(resp).await
60    }
61
62    /// Reset a single service's state.
63    pub async fn reset_service(&self, service: &str) -> Result<ResetServiceResponse, Error> {
64        let resp = self
65            .client
66            .post(format!("{}/_fakecloud/reset/{}", self.base_url, service))
67            .send()
68            .await?;
69        Self::parse(resp).await
70    }
71
72    /// Reset a single service's state for a specific account only.
73    pub async fn reset_service_for_account(
74        &self,
75        service: &str,
76        account_id: &str,
77    ) -> Result<ResetServiceResponse, Error> {
78        let resp = self
79            .client
80            .post(format!(
81                "{}/_fakecloud/reset/{}/{}",
82                self.base_url, service, account_id
83            ))
84            .send()
85            .await?;
86        Self::parse(resp).await
87    }
88
89    // ── Sub-clients ─────────────────────────────────────────────────
90
91    pub fn lambda(&self) -> LambdaClient<'_> {
92        LambdaClient { fc: self }
93    }
94
95    pub fn ses(&self) -> SesClient<'_> {
96        SesClient { fc: self }
97    }
98
99    pub fn sns(&self) -> SnsClient<'_> {
100        SnsClient { fc: self }
101    }
102
103    pub fn sqs(&self) -> SqsClient<'_> {
104        SqsClient { fc: self }
105    }
106
107    pub fn events(&self) -> EventsClient<'_> {
108        EventsClient { fc: self }
109    }
110
111    pub fn s3(&self) -> S3Client<'_> {
112        S3Client { fc: self }
113    }
114
115    pub fn dynamodb(&self) -> DynamoDbClient<'_> {
116        DynamoDbClient { fc: self }
117    }
118
119    pub fn secretsmanager(&self) -> SecretsManagerClient<'_> {
120        SecretsManagerClient { fc: self }
121    }
122
123    pub fn cognito(&self) -> CognitoClient<'_> {
124        CognitoClient { fc: self }
125    }
126
127    pub fn rds(&self) -> RdsClient<'_> {
128        RdsClient { fc: self }
129    }
130
131    pub fn elasticache(&self) -> ElastiCacheClient<'_> {
132        ElastiCacheClient { fc: self }
133    }
134
135    pub fn apigatewayv2(&self) -> ApiGatewayV2Client<'_> {
136        ApiGatewayV2Client { fc: self }
137    }
138
139    pub fn stepfunctions(&self) -> StepFunctionsClient<'_> {
140        StepFunctionsClient { fc: self }
141    }
142
143    pub fn bedrock(&self) -> BedrockClient<'_> {
144        BedrockClient { fc: self }
145    }
146
147    pub fn bedrock_agent(&self) -> BedrockAgentClient<'_> {
148        BedrockAgentClient { fc: self }
149    }
150
151    pub fn bedrock_agent_runtime(&self) -> BedrockAgentRuntimeClient<'_> {
152        BedrockAgentRuntimeClient { fc: self }
153    }
154
155    pub fn ecs(&self) -> EcsClient<'_> {
156        EcsClient { fc: self }
157    }
158
159    pub fn application_autoscaling(&self) -> ApplicationAutoScalingClient<'_> {
160        ApplicationAutoScalingClient { fc: self }
161    }
162
163    pub fn athena(&self) -> AthenaClient<'_> {
164        AthenaClient { fc: self }
165    }
166
167    pub fn organizations(&self) -> OrganizationsClient<'_> {
168        OrganizationsClient { fc: self }
169    }
170
171    // ── Internal helpers ────────────────────────────────────────────
172
173    async fn parse<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T, Error> {
174        let status = resp.status().as_u16();
175        if !resp.status().is_success() {
176            let body = resp.text().await.unwrap_or_default();
177            return Err(Error::Api { status, body });
178        }
179        Ok(resp.json::<T>().await?)
180    }
181}
182
183// ── RDS ─────────────────────────────────────────────────────────────
184
185pub struct RdsClient<'a> {
186    fc: &'a FakeCloud,
187}
188
189impl RdsClient<'_> {
190    /// List fakecloud-managed RDS DB instances with runtime metadata.
191    pub async fn get_instances(&self) -> Result<RdsInstancesResponse, Error> {
192        let resp = self
193            .fc
194            .client
195            .get(format!("{}/_fakecloud/rds/instances", self.fc.base_url))
196            .send()
197            .await?;
198        FakeCloud::parse(resp).await
199    }
200}
201
202// ── ElastiCache ─────────────────────────────────────────────────────
203
204pub struct ElastiCacheClient<'a> {
205    fc: &'a FakeCloud,
206}
207
208impl ElastiCacheClient<'_> {
209    /// List fakecloud-managed ElastiCache cache clusters with runtime metadata.
210    pub async fn get_clusters(&self) -> Result<ElastiCacheClustersResponse, Error> {
211        let resp = self
212            .fc
213            .client
214            .get(format!(
215                "{}/_fakecloud/elasticache/clusters",
216                self.fc.base_url
217            ))
218            .send()
219            .await?;
220        FakeCloud::parse(resp).await
221    }
222
223    /// List fakecloud-managed ElastiCache replication groups with runtime metadata.
224    pub async fn get_replication_groups(
225        &self,
226    ) -> Result<ElastiCacheReplicationGroupsResponse, Error> {
227        let resp = self
228            .fc
229            .client
230            .get(format!(
231                "{}/_fakecloud/elasticache/replication-groups",
232                self.fc.base_url
233            ))
234            .send()
235            .await?;
236        FakeCloud::parse(resp).await
237    }
238
239    /// List fakecloud-managed ElastiCache serverless caches with runtime metadata.
240    pub async fn get_serverless_caches(
241        &self,
242    ) -> Result<ElastiCacheServerlessCachesResponse, Error> {
243        let resp = self
244            .fc
245            .client
246            .get(format!(
247                "{}/_fakecloud/elasticache/serverless-caches",
248                self.fc.base_url
249            ))
250            .send()
251            .await?;
252        FakeCloud::parse(resp).await
253    }
254
255    /// List ACL state (users + user groups) for ElastiCache replication groups
256    /// that have one or more user groups attached.
257    pub async fn get_acls(&self) -> Result<ElastiCacheAclsResponse, Error> {
258        let resp = self
259            .fc
260            .client
261            .get(format!("{}/_fakecloud/elasticache/acls", self.fc.base_url))
262            .send()
263            .await?;
264        FakeCloud::parse(resp).await
265    }
266}
267
268// ── Lambda ──────────────────────────────────────────────────────────
269
270pub struct LambdaClient<'a> {
271    fc: &'a FakeCloud,
272}
273
274impl LambdaClient<'_> {
275    /// List recorded Lambda invocations.
276    pub async fn get_invocations(&self) -> Result<LambdaInvocationsResponse, Error> {
277        let resp = self
278            .fc
279            .client
280            .get(format!(
281                "{}/_fakecloud/lambda/invocations",
282                self.fc.base_url
283            ))
284            .send()
285            .await?;
286        FakeCloud::parse(resp).await
287    }
288
289    /// List warm (cached) Lambda containers.
290    pub async fn get_warm_containers(&self) -> Result<WarmContainersResponse, Error> {
291        let resp = self
292            .fc
293            .client
294            .get(format!(
295                "{}/_fakecloud/lambda/warm-containers",
296                self.fc.base_url
297            ))
298            .send()
299            .await?;
300        FakeCloud::parse(resp).await
301    }
302
303    /// Evict the warm container for a specific function.
304    pub async fn evict_container(
305        &self,
306        function_name: &str,
307    ) -> Result<EvictContainerResponse, Error> {
308        let resp = self
309            .fc
310            .client
311            .post(format!(
312                "{}/_fakecloud/lambda/{}/evict-container",
313                self.fc.base_url, function_name
314            ))
315            .send()
316            .await?;
317        FakeCloud::parse(resp).await
318    }
319}
320
321// ── SES ─────────────────────────────────────────────────────────────
322
323pub struct SesClient<'a> {
324    fc: &'a FakeCloud,
325}
326
327impl SesClient<'_> {
328    /// List all sent emails.
329    pub async fn get_emails(&self) -> Result<SesEmailsResponse, Error> {
330        let resp = self
331            .fc
332            .client
333            .get(format!("{}/_fakecloud/ses/emails", self.fc.base_url))
334            .send()
335            .await?;
336        FakeCloud::parse(resp).await
337    }
338
339    /// Simulate an inbound email (SES receipt rules).
340    pub async fn simulate_inbound(
341        &self,
342        req: &InboundEmailRequest,
343    ) -> Result<InboundEmailResponse, Error> {
344        let resp = self
345            .fc
346            .client
347            .post(format!("{}/_fakecloud/ses/inbound", self.fc.base_url))
348            .json(req)
349            .send()
350            .await?;
351        FakeCloud::parse(resp).await
352    }
353}
354
355// ── SNS ─────────────────────────────────────────────────────────────
356
357pub struct SnsClient<'a> {
358    fc: &'a FakeCloud,
359}
360
361impl SnsClient<'_> {
362    /// List all published SNS messages.
363    pub async fn get_messages(&self) -> Result<SnsMessagesResponse, Error> {
364        let resp = self
365            .fc
366            .client
367            .get(format!("{}/_fakecloud/sns/messages", self.fc.base_url))
368            .send()
369            .await?;
370        FakeCloud::parse(resp).await
371    }
372
373    /// List subscriptions pending confirmation.
374    pub async fn get_pending_confirmations(&self) -> Result<PendingConfirmationsResponse, Error> {
375        let resp = self
376            .fc
377            .client
378            .get(format!(
379                "{}/_fakecloud/sns/pending-confirmations",
380                self.fc.base_url
381            ))
382            .send()
383            .await?;
384        FakeCloud::parse(resp).await
385    }
386
387    /// Confirm a pending subscription.
388    pub async fn confirm_subscription(
389        &self,
390        req: &ConfirmSubscriptionRequest,
391    ) -> Result<ConfirmSubscriptionResponse, Error> {
392        let resp = self
393            .fc
394            .client
395            .post(format!(
396                "{}/_fakecloud/sns/confirm-subscription",
397                self.fc.base_url
398            ))
399            .json(req)
400            .send()
401            .await?;
402        FakeCloud::parse(resp).await
403    }
404}
405
406// ── SQS ─────────────────────────────────────────────────────────────
407
408pub struct SqsClient<'a> {
409    fc: &'a FakeCloud,
410}
411
412impl SqsClient<'_> {
413    /// List all messages across all queues.
414    pub async fn get_messages(&self) -> Result<SqsMessagesResponse, Error> {
415        let resp = self
416            .fc
417            .client
418            .get(format!("{}/_fakecloud/sqs/messages", self.fc.base_url))
419            .send()
420            .await?;
421        FakeCloud::parse(resp).await
422    }
423
424    /// Tick the message expiration processor (expire visibility-timed-out messages).
425    pub async fn tick_expiration(&self) -> Result<ExpirationTickResponse, Error> {
426        let resp = self
427            .fc
428            .client
429            .post(format!(
430                "{}/_fakecloud/sqs/expiration-processor/tick",
431                self.fc.base_url
432            ))
433            .send()
434            .await?;
435        FakeCloud::parse(resp).await
436    }
437
438    /// Force all messages in a queue to its DLQ.
439    pub async fn force_dlq(&self, queue_name: &str) -> Result<ForceDlqResponse, Error> {
440        let resp = self
441            .fc
442            .client
443            .post(format!(
444                "{}/_fakecloud/sqs/{}/force-dlq",
445                self.fc.base_url, queue_name
446            ))
447            .send()
448            .await?;
449        FakeCloud::parse(resp).await
450    }
451}
452
453// ── Application Auto Scaling ────────────────────────────────────────
454
455pub struct ApplicationAutoScalingClient<'a> {
456    fc: &'a FakeCloud,
457}
458
459impl ApplicationAutoScalingClient<'_> {
460    /// Force the watcher to evaluate every scaling policy now. Returns
461    /// the number of policies that applied a capacity change on this
462    /// tick. Useful in tests so callers don't have to wait for the
463    /// wall-clock 15s interval.
464    pub async fn tick(&self) -> Result<AppAsTickResponse, Error> {
465        let resp = self
466            .fc
467            .client
468            .post(format!(
469                "{}/_fakecloud/application-autoscaling/tick",
470                self.fc.base_url
471            ))
472            .send()
473            .await?;
474        FakeCloud::parse(resp).await
475    }
476
477    /// Force the scheduled-action executor to evaluate every
478    /// `ScheduledAction` now. Returns the number of actions that
479    /// fired this tick. Useful in tests so callers don't have to wait
480    /// for the wall-clock 30s interval.
481    pub async fn scheduled_tick(&self) -> Result<AppAsScheduledTickResponse, Error> {
482        let resp = self
483            .fc
484            .client
485            .post(format!(
486                "{}/_fakecloud/application-autoscaling/scheduled-tick",
487                self.fc.base_url
488            ))
489            .send()
490            .await?;
491        FakeCloud::parse(resp).await
492    }
493}
494
495// ── EventBridge ─────────────────────────────────────────────────────
496
497pub struct EventsClient<'a> {
498    fc: &'a FakeCloud,
499}
500
501impl EventsClient<'_> {
502    /// Get event history and delivery records.
503    pub async fn get_history(&self) -> Result<EventHistoryResponse, Error> {
504        let resp = self
505            .fc
506            .client
507            .get(format!("{}/_fakecloud/events/history", self.fc.base_url))
508            .send()
509            .await?;
510        FakeCloud::parse(resp).await
511    }
512
513    /// Fire a specific EventBridge rule manually.
514    pub async fn fire_rule(&self, req: &FireRuleRequest) -> Result<FireRuleResponse, Error> {
515        let resp = self
516            .fc
517            .client
518            .post(format!("{}/_fakecloud/events/fire-rule", self.fc.base_url))
519            .json(req)
520            .send()
521            .await?;
522        FakeCloud::parse(resp).await
523    }
524}
525
526// ── S3 ──────────────────────────────────────────────────────────────
527
528pub struct S3Client<'a> {
529    fc: &'a FakeCloud,
530}
531
532impl S3Client<'_> {
533    /// List S3 notification events.
534    pub async fn get_notifications(&self) -> Result<S3NotificationsResponse, Error> {
535        let resp = self
536            .fc
537            .client
538            .get(format!("{}/_fakecloud/s3/notifications", self.fc.base_url))
539            .send()
540            .await?;
541        FakeCloud::parse(resp).await
542    }
543
544    /// Tick the S3 lifecycle processor.
545    pub async fn tick_lifecycle(&self) -> Result<LifecycleTickResponse, Error> {
546        let resp = self
547            .fc
548            .client
549            .post(format!(
550                "{}/_fakecloud/s3/lifecycle-processor/tick",
551                self.fc.base_url
552            ))
553            .send()
554            .await?;
555        FakeCloud::parse(resp).await
556    }
557
558    /// List S3 access points across all accounts.
559    pub async fn get_access_points(&self) -> Result<S3AccessPointsResponse, Error> {
560        let resp = self
561            .fc
562            .client
563            .get(format!("{}/_fakecloud/s3/access-points", self.fc.base_url))
564            .send()
565            .await?;
566        FakeCloud::parse(resp).await
567    }
568
569    /// List stored WriteGetObjectResponse bodies (S3 Object Lambda).
570    pub async fn get_object_lambda_responses(
571        &self,
572    ) -> Result<S3ObjectLambdaResponsesResponse, Error> {
573        let resp = self
574            .fc
575            .client
576            .get(format!(
577                "{}/_fakecloud/s3/object-lambda-responses",
578                self.fc.base_url
579            ))
580            .send()
581            .await?;
582        FakeCloud::parse(resp).await
583    }
584}
585
586// ── DynamoDB ────────────────────────────────────────────────────────
587
588pub struct DynamoDbClient<'a> {
589    fc: &'a FakeCloud,
590}
591
592impl DynamoDbClient<'_> {
593    /// Tick the DynamoDB TTL processor.
594    pub async fn tick_ttl(&self) -> Result<TtlTickResponse, Error> {
595        let resp = self
596            .fc
597            .client
598            .post(format!(
599                "{}/_fakecloud/dynamodb/ttl-processor/tick",
600                self.fc.base_url
601            ))
602            .send()
603            .await?;
604        FakeCloud::parse(resp).await
605    }
606}
607
608// ── SecretsManager ──────────────────────────────────────────────────
609
610pub struct SecretsManagerClient<'a> {
611    fc: &'a FakeCloud,
612}
613
614impl SecretsManagerClient<'_> {
615    /// Tick the SecretsManager rotation scheduler.
616    pub async fn tick_rotation(&self) -> Result<RotationTickResponse, Error> {
617        let resp = self
618            .fc
619            .client
620            .post(format!(
621                "{}/_fakecloud/secretsmanager/rotation-scheduler/tick",
622                self.fc.base_url
623            ))
624            .send()
625            .await?;
626        FakeCloud::parse(resp).await
627    }
628}
629
630// ── Cognito ─────────────────────────────────────────────────────────
631
632pub struct CognitoClient<'a> {
633    fc: &'a FakeCloud,
634}
635
636impl CognitoClient<'_> {
637    /// Get confirmation codes for a specific user.
638    pub async fn get_user_codes(
639        &self,
640        pool_id: &str,
641        username: &str,
642    ) -> Result<UserConfirmationCodes, Error> {
643        let resp = self
644            .fc
645            .client
646            .get(format!(
647                "{}/_fakecloud/cognito/confirmation-codes/{}/{}",
648                self.fc.base_url, pool_id, username
649            ))
650            .send()
651            .await?;
652        FakeCloud::parse(resp).await
653    }
654
655    /// List all confirmation codes across all pools.
656    pub async fn get_confirmation_codes(&self) -> Result<ConfirmationCodesResponse, Error> {
657        let resp = self
658            .fc
659            .client
660            .get(format!(
661                "{}/_fakecloud/cognito/confirmation-codes",
662                self.fc.base_url
663            ))
664            .send()
665            .await?;
666        FakeCloud::parse(resp).await
667    }
668
669    /// Confirm a user (bypass email/phone verification).
670    pub async fn confirm_user(
671        &self,
672        req: &ConfirmUserRequest,
673    ) -> Result<ConfirmUserResponse, Error> {
674        let resp = self
675            .fc
676            .client
677            .post(format!(
678                "{}/_fakecloud/cognito/confirm-user",
679                self.fc.base_url
680            ))
681            .json(req)
682            .send()
683            .await?;
684        let status = resp.status().as_u16();
685        let body: ConfirmUserResponse = resp.json().await?;
686        if status >= 400 {
687            return Err(Error::Api {
688                status,
689                body: body.error.unwrap_or_default(),
690            });
691        }
692        Ok(body)
693    }
694
695    /// List all active tokens.
696    pub async fn get_tokens(&self) -> Result<TokensResponse, Error> {
697        let resp = self
698            .fc
699            .client
700            .get(format!("{}/_fakecloud/cognito/tokens", self.fc.base_url))
701            .send()
702            .await?;
703        FakeCloud::parse(resp).await
704    }
705
706    /// Expire tokens (optionally filtered by pool/user).
707    pub async fn expire_tokens(
708        &self,
709        req: &ExpireTokensRequest,
710    ) -> Result<ExpireTokensResponse, Error> {
711        let resp = self
712            .fc
713            .client
714            .post(format!(
715                "{}/_fakecloud/cognito/expire-tokens",
716                self.fc.base_url
717            ))
718            .json(req)
719            .send()
720            .await?;
721        FakeCloud::parse(resp).await
722    }
723
724    /// List auth events.
725    pub async fn get_auth_events(&self) -> Result<AuthEventsResponse, Error> {
726        let resp = self
727            .fc
728            .client
729            .get(format!(
730                "{}/_fakecloud/cognito/auth-events",
731                self.fc.base_url
732            ))
733            .send()
734            .await?;
735        FakeCloud::parse(resp).await
736    }
737
738    /// List PreTokenGeneration Lambda trigger invocations recorded
739    /// during `InitiateAuth`. Each entry includes the full request /
740    /// response payloads plus pre-parsed `claims_added`,
741    /// `claims_overridden`, and `group_overrides` so tests can assert
742    /// the claim mutation flow without inspecting the issued JWT.
743    pub async fn get_pre_token_gen_invocations(
744        &self,
745    ) -> Result<PreTokenGenInvocationsResponse, Error> {
746        let resp = self
747            .fc
748            .client
749            .get(format!(
750                "{}/_fakecloud/cognito/pretokengen/invocations",
751                self.fc.base_url
752            ))
753            .send()
754            .await?;
755        FakeCloud::parse(resp).await
756    }
757}
758
759// ── API Gateway v2 ──────────────────────────────────────────────────
760
761pub struct ApiGatewayV2Client<'a> {
762    fc: &'a FakeCloud,
763}
764
765impl ApiGatewayV2Client<'_> {
766    /// List all HTTP API requests that were received and processed.
767    pub async fn get_requests(&self) -> Result<ApiGatewayV2RequestsResponse, Error> {
768        let resp = self
769            .fc
770            .client
771            .get(format!(
772                "{}/_fakecloud/apigatewayv2/requests",
773                self.fc.base_url
774            ))
775            .send()
776            .await?;
777        FakeCloud::parse(resp).await
778    }
779}
780
781// ── Step Functions ──────────────────────────────────────────────────
782
783pub struct StepFunctionsClient<'a> {
784    fc: &'a FakeCloud,
785}
786
787impl StepFunctionsClient<'_> {
788    /// List all Step Functions executions with status, input, output, and timestamps.
789    pub async fn get_executions(&self) -> Result<StepFunctionsExecutionsResponse, Error> {
790        let resp = self
791            .fc
792            .client
793            .get(format!(
794                "{}/_fakecloud/stepfunctions/executions",
795                self.fc.base_url
796            ))
797            .send()
798            .await?;
799        FakeCloud::parse(resp).await
800    }
801
802    /// List `StartSyncExecution` invocations with billing details. EXPRESS state
803    /// machines only — async (`StartExecution`) calls show up under
804    /// [`Self::get_executions`] instead.
805    pub async fn get_sync_executions(&self) -> Result<StepFunctionsSyncExecutionsResponse, Error> {
806        let resp = self
807            .fc
808            .client
809            .get(format!(
810                "{}/_fakecloud/stepfunctions/sync-executions",
811                self.fc.base_url
812            ))
813            .send()
814            .await?;
815        FakeCloud::parse(resp).await
816    }
817
818    /// Return the nested call tree rooted at `execution_arn`. Children are
819    /// executions that were started by their parent via
820    /// `arn:aws:states:::states:startExecution[.sync]`.
821    pub async fn get_execution_tree(
822        &self,
823        execution_arn: &str,
824    ) -> Result<StepFunctionsExecutionTreeResponse, Error> {
825        let mut encoded = String::with_capacity(execution_arn.len());
826        for b in execution_arn.bytes() {
827            match b {
828                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
829                    encoded.push(b as char);
830                }
831                _ => encoded.push_str(&format!("%{:02X}", b)),
832            }
833        }
834        let resp = self
835            .fc
836            .client
837            .get(format!(
838                "{}/_fakecloud/stepfunctions/execution-tree/{}",
839                self.fc.base_url, encoded
840            ))
841            .send()
842            .await?;
843        FakeCloud::parse(resp).await
844    }
845}
846
847// ── Bedrock ─────────────────────────────────────────────────────────
848
849pub struct BedrockClient<'a> {
850    fc: &'a FakeCloud,
851}
852
853impl BedrockClient<'_> {
854    /// List recorded Bedrock runtime invocations. Each invocation has an optional
855    /// `error` field that is set for calls faulted via [`Self::queue_fault`].
856    pub async fn get_invocations(&self) -> Result<BedrockInvocationsResponse, Error> {
857        let resp = self
858            .fc
859            .client
860            .get(format!(
861                "{}/_fakecloud/bedrock/invocations",
862                self.fc.base_url
863            ))
864            .send()
865            .await?;
866        FakeCloud::parse(resp).await
867    }
868
869    /// Configure a single canned response for a Bedrock model.
870    pub async fn set_model_response(
871        &self,
872        model_id: &str,
873        response: &str,
874    ) -> Result<BedrockModelResponseConfig, Error> {
875        let resp = self
876            .fc
877            .client
878            .post(format!(
879                "{}/_fakecloud/bedrock/models/{}/response",
880                self.fc.base_url, model_id
881            ))
882            .header("content-type", "text/plain")
883            .body(response.to_string())
884            .send()
885            .await?;
886        FakeCloud::parse(resp).await
887    }
888
889    /// Replace the prompt-conditional response rule list for a Bedrock model.
890    pub async fn set_response_rules(
891        &self,
892        model_id: &str,
893        rules: &[BedrockResponseRule],
894    ) -> Result<BedrockModelResponseConfig, Error> {
895        let body = serde_json::json!({ "rules": rules });
896        let resp = self
897            .fc
898            .client
899            .post(format!(
900                "{}/_fakecloud/bedrock/models/{}/responses",
901                self.fc.base_url, model_id
902            ))
903            .json(&body)
904            .send()
905            .await?;
906        FakeCloud::parse(resp).await
907    }
908
909    /// Clear all prompt-conditional response rules for a Bedrock model.
910    pub async fn clear_response_rules(
911        &self,
912        model_id: &str,
913    ) -> Result<BedrockModelResponseConfig, Error> {
914        let resp = self
915            .fc
916            .client
917            .delete(format!(
918                "{}/_fakecloud/bedrock/models/{}/responses",
919                self.fc.base_url, model_id
920            ))
921            .send()
922            .await?;
923        FakeCloud::parse(resp).await
924    }
925
926    /// Queue a fault rule that will cause the next matching Bedrock runtime call(s) to fail.
927    pub async fn queue_fault(
928        &self,
929        rule: &BedrockFaultRule,
930    ) -> Result<BedrockStatusResponse, Error> {
931        let resp = self
932            .fc
933            .client
934            .post(format!("{}/_fakecloud/bedrock/faults", self.fc.base_url))
935            .json(rule)
936            .send()
937            .await?;
938        FakeCloud::parse(resp).await
939    }
940
941    /// List currently queued fault rules.
942    pub async fn get_faults(&self) -> Result<BedrockFaultsResponse, Error> {
943        let resp = self
944            .fc
945            .client
946            .get(format!("{}/_fakecloud/bedrock/faults", self.fc.base_url))
947            .send()
948            .await?;
949        FakeCloud::parse(resp).await
950    }
951
952    /// Clear all queued fault rules.
953    pub async fn clear_faults(&self) -> Result<BedrockStatusResponse, Error> {
954        let resp = self
955            .fc
956            .client
957            .delete(format!("{}/_fakecloud/bedrock/faults", self.fc.base_url))
958            .send()
959            .await?;
960        FakeCloud::parse(resp).await
961    }
962}
963
964// ── Bedrock Agent (control plane) ───────────────────────────────────
965
966pub struct BedrockAgentClient<'a> {
967    fc: &'a FakeCloud,
968}
969
970impl BedrockAgentClient<'_> {
971    /// List every recorded Bedrock Agent with its aliases, versions,
972    /// knowledge-base attachments, and collaborators flattened into one
973    /// row each.
974    pub async fn get_agents(&self) -> Result<BedrockAgentAgentsResponse, Error> {
975        let resp = self
976            .fc
977            .client
978            .get(format!(
979                "{}/_fakecloud/bedrock-agent/agents",
980                self.fc.base_url
981            ))
982            .send()
983            .await?;
984        FakeCloud::parse(resp).await
985    }
986}
987
988// ── Bedrock Agent Runtime (data plane) ──────────────────────────────
989
990pub struct BedrockAgentRuntimeClient<'a> {
991    fc: &'a FakeCloud,
992}
993
994impl BedrockAgentRuntimeClient<'_> {
995    /// List every recorded InvokeAgent / InvokeInlineAgent / InvokeFlow
996    /// / Retrieve / RetrieveAndGenerate / CreateInvocation call.
997    pub async fn get_invocations(&self) -> Result<BedrockAgentRuntimeInvocationsResponse, Error> {
998        let resp = self
999            .fc
1000            .client
1001            .get(format!(
1002                "{}/_fakecloud/bedrock-agent-runtime/invocations",
1003                self.fc.base_url
1004            ))
1005            .send()
1006            .await?;
1007        FakeCloud::parse(resp).await
1008    }
1009}
1010
1011// ── ECS ─────────────────────────────────────────────────────────────
1012
1013pub struct EcsClient<'a> {
1014    fc: &'a FakeCloud,
1015}
1016
1017impl EcsClient<'_> {
1018    /// List all ECS clusters across every account the server has seen.
1019    /// Deterministic, sorted by cluster ARN. Bypasses the ECS control-plane
1020    /// auth and pagination so tests can assert directly on raw state.
1021    pub async fn get_clusters(&self) -> Result<EcsClustersResponse, Error> {
1022        let resp = self
1023            .fc
1024            .client
1025            .get(format!("{}/_fakecloud/ecs/clusters", self.fc.base_url))
1026            .send()
1027            .await?;
1028        FakeCloud::parse(resp).await
1029    }
1030
1031    /// List every task the server has seen. Optional `cluster` / `status`
1032    /// filters restrict the dump when supplied.
1033    pub async fn get_tasks(
1034        &self,
1035        cluster: Option<&str>,
1036        status: Option<&str>,
1037    ) -> Result<EcsTasksResponse, Error> {
1038        fn encode(s: &str) -> String {
1039            let mut out = String::with_capacity(s.len());
1040            for b in s.bytes() {
1041                match b {
1042                    b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1043                        out.push(b as char);
1044                    }
1045                    _ => out.push_str(&format!("%{:02X}", b)),
1046                }
1047            }
1048            out
1049        }
1050        let mut url = format!("{}/_fakecloud/ecs/tasks", self.fc.base_url);
1051        let mut sep = '?';
1052        if let Some(c) = cluster {
1053            url.push(sep);
1054            url.push_str("cluster=");
1055            url.push_str(&encode(c));
1056            sep = '&';
1057        }
1058        if let Some(s) = status {
1059            url.push(sep);
1060            url.push_str("status=");
1061            url.push_str(&encode(s));
1062        }
1063        let resp = self.fc.client.get(url).send().await?;
1064        FakeCloud::parse(resp).await
1065    }
1066
1067    /// Tail stored container stdout/stderr for a single task. Works even
1068    /// when no `awslogs` driver is configured — fakecloud always captures
1069    /// docker stdout/stderr on exit and keeps it on the task.
1070    pub async fn get_task_logs(&self, task_id: &str) -> Result<EcsTaskLogsResponse, Error> {
1071        let resp = self
1072            .fc
1073            .client
1074            .get(format!(
1075                "{}/_fakecloud/ecs/tasks/{}/logs",
1076                self.fc.base_url, task_id
1077            ))
1078            .send()
1079            .await?;
1080        FakeCloud::parse(resp).await
1081    }
1082
1083    /// Force the running container behind a task to stop.
1084    pub async fn force_stop_task(&self, task_id: &str) -> Result<EcsTask, Error> {
1085        let resp = self
1086            .fc
1087            .client
1088            .post(format!(
1089                "{}/_fakecloud/ecs/tasks/{}/force-stop",
1090                self.fc.base_url, task_id
1091            ))
1092            .send()
1093            .await?;
1094        FakeCloud::parse(resp).await
1095    }
1096
1097    /// Flip the task to STOPPED without killing the underlying container
1098    /// — useful for simulating task failures in tests.
1099    pub async fn mark_task_failed(
1100        &self,
1101        task_id: &str,
1102        req: &EcsMarkFailedRequest,
1103    ) -> Result<EcsTask, Error> {
1104        let resp = self
1105            .fc
1106            .client
1107            .post(format!(
1108                "{}/_fakecloud/ecs/tasks/{}/mark-failed",
1109                self.fc.base_url, task_id
1110            ))
1111            .json(req)
1112            .send()
1113            .await?;
1114        FakeCloud::parse(resp).await
1115    }
1116
1117    /// Replay the lifecycle event log.
1118    pub async fn get_events(&self) -> Result<EcsEventsResponse, Error> {
1119        let resp = self
1120            .fc
1121            .client
1122            .get(format!("{}/_fakecloud/ecs/events", self.fc.base_url))
1123            .send()
1124            .await?;
1125        FakeCloud::parse(resp).await
1126    }
1127}
1128
1129// ── Athena ──────────────────────────────────────────────────────────
1130
1131pub struct AthenaClient<'a> {
1132    fc: &'a FakeCloud,
1133}
1134
1135impl AthenaClient<'_> {
1136    /// List every named query stored in the Athena named-query registry
1137    /// across all workgroups for the default account. Bumps `last_used_at`
1138    /// each time `StartQueryExecution` resolves a query by id so test
1139    /// authors can assert that a saved query was actually exercised.
1140    pub async fn get_named_queries(&self) -> Result<AthenaNamedQueriesResponse, Error> {
1141        let resp = self
1142            .fc
1143            .client
1144            .get(format!(
1145                "{}/_fakecloud/athena/named-queries",
1146                self.fc.base_url
1147            ))
1148            .send()
1149            .await?;
1150        FakeCloud::parse(resp).await
1151    }
1152}
1153
1154// ── Organizations ───────────────────────────────────────────────────
1155
1156pub struct OrganizationsClient<'a> {
1157    fc: &'a FakeCloud,
1158}
1159
1160impl OrganizationsClient<'_> {
1161    /// List every member account in the org with lifecycle state,
1162    /// parent OU, tags, and directly-attached SCPs. Returns an empty
1163    /// `accounts` list (and `None` for management/master ids) when no
1164    /// organization has been created yet.
1165    pub async fn get_accounts(&self) -> Result<OrganizationsAccountsResponse, Error> {
1166        let resp = self
1167            .fc
1168            .client
1169            .get(format!(
1170                "{}/_fakecloud/organizations/accounts",
1171                self.fc.base_url
1172            ))
1173            .send()
1174            .await?;
1175        FakeCloud::parse(resp).await
1176    }
1177}