Skip to main content

fakecloud_dsql/
service.rs

1//! Aurora DSQL restJson1 control-plane dispatch and operation handlers.
2
3use std::collections::BTreeMap;
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use chrono::Utc;
8use http::{Method, StatusCode};
9use serde_json::{json, Value};
10use tokio::sync::Mutex as AsyncMutex;
11
12use fakecloud_core::pagination::paginate_checked;
13use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
14use fakecloud_persistence::SnapshotStore;
15
16use crate::persistence::save_snapshot;
17use crate::state::{
18    cluster_arn, cluster_endpoint, gen_id, stream_arn, Cluster, EncryptionDetails,
19    MultiRegionProperties, SharedDsqlState, Stream,
20};
21
22/// AWS caps the number of clusters per account/Region. Mirrored so
23/// `ServiceQuotaExceededException` is reachable and testable.
24const MAX_CLUSTERS_PER_ACCOUNT: usize = 100;
25
26pub struct DsqlService {
27    state: SharedDsqlState,
28    snapshot_store: Option<Arc<dyn SnapshotStore>>,
29    snapshot_lock: Arc<AsyncMutex<()>>,
30}
31
32/// Parsed route: the operation and its path-derived arguments.
33enum Route {
34    CreateCluster,
35    ListClusters,
36    GetCluster(String),
37    UpdateCluster(String),
38    DeleteCluster(String),
39    GetClusterPolicy(String),
40    PutClusterPolicy(String),
41    DeleteClusterPolicy(String),
42    CreateStream(String),
43    ListStreams(String),
44    GetStream(String, String),
45    DeleteStream(String, String),
46    GetVpcEndpointServiceName(String),
47    TagResource(String),
48    UntagResource(String),
49    ListTagsForResource(String),
50}
51
52impl DsqlService {
53    pub fn new(state: SharedDsqlState) -> Self {
54        Self {
55            state,
56            snapshot_store: None,
57            snapshot_lock: Arc::new(AsyncMutex::new(())),
58        }
59    }
60
61    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
62        self.snapshot_store = Some(store);
63        self
64    }
65
66    /// Start the lifecycle ticker so clusters/streams advance out of their
67    /// transient states. Call once after construction + snapshot restore.
68    pub fn start_ticker(&self) {
69        crate::ticker::spawn(
70            self.state.clone(),
71            self.snapshot_store.clone(),
72            self.snapshot_lock.clone(),
73        );
74    }
75
76    async fn persist(&self) {
77        save_snapshot(
78            &self.state,
79            self.snapshot_store.clone(),
80            &self.snapshot_lock,
81        )
82        .await;
83    }
84
85    /// Snapshot hook used by the CloudFormation provisioner (which mutates
86    /// state directly) to write a provisioned resource through to disk.
87    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
88        let store = self.snapshot_store.clone()?;
89        let state = self.state.clone();
90        let lock = self.snapshot_lock.clone();
91        Some(Arc::new(move || {
92            let state = state.clone();
93            let store = store.clone();
94            let lock = lock.clone();
95            Box::pin(async move {
96                save_snapshot(&state, Some(store), &lock).await;
97            })
98        }))
99    }
100
101    fn resolve_route(req: &AwsRequest) -> Option<Route> {
102        let segs: Vec<&str> = req.path_segments.iter().map(|s| s.as_str()).collect();
103        match (&req.method, segs.as_slice()) {
104            (&Method::POST, ["cluster"]) => Some(Route::CreateCluster),
105            (&Method::GET, ["cluster"]) => Some(Route::ListClusters),
106            (&Method::GET, ["cluster", id]) => Some(Route::GetCluster((*id).to_string())),
107            (&Method::POST, ["cluster", id]) => Some(Route::UpdateCluster((*id).to_string())),
108            (&Method::DELETE, ["cluster", id]) => Some(Route::DeleteCluster((*id).to_string())),
109            (&Method::GET, ["cluster", id, "policy"]) => {
110                Some(Route::GetClusterPolicy((*id).to_string()))
111            }
112            (&Method::POST, ["cluster", id, "policy"]) => {
113                Some(Route::PutClusterPolicy((*id).to_string()))
114            }
115            (&Method::DELETE, ["cluster", id, "policy"]) => {
116                Some(Route::DeleteClusterPolicy((*id).to_string()))
117            }
118            (&Method::POST, ["stream", cid]) => Some(Route::CreateStream((*cid).to_string())),
119            (&Method::GET, ["stream", cid]) => Some(Route::ListStreams((*cid).to_string())),
120            (&Method::GET, ["stream", cid, sid]) => {
121                Some(Route::GetStream((*cid).to_string(), (*sid).to_string()))
122            }
123            (&Method::DELETE, ["stream", cid, sid]) => {
124                Some(Route::DeleteStream((*cid).to_string(), (*sid).to_string()))
125            }
126            (&Method::GET, ["clusters", id, "vpc-endpoint-service-name"]) => {
127                Some(Route::GetVpcEndpointServiceName((*id).to_string()))
128            }
129            (&Method::POST, ["tags", arn]) => Some(Route::TagResource(percent_decode(arn))),
130            (&Method::DELETE, ["tags", arn]) => Some(Route::UntagResource(percent_decode(arn))),
131            (&Method::GET, ["tags", arn]) => Some(Route::ListTagsForResource(percent_decode(arn))),
132            // An empty `resourceArn` label collapses `/tags/` to a single
133            // segment; route it through so the handler returns a modeled
134            // ValidationException rather than "unknown operation".
135            (&Method::POST, ["tags"]) => Some(Route::TagResource(String::new())),
136            (&Method::DELETE, ["tags"]) => Some(Route::UntagResource(String::new())),
137            (&Method::GET, ["tags"]) => Some(Route::ListTagsForResource(String::new())),
138            _ => None,
139        }
140    }
141
142    // --- Cluster operations ------------------------------------------------
143
144    fn create_cluster(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
145        let body: Value = parse_body(req)?;
146        let region = &req.region;
147        let account = &req.account_id;
148
149        // Enforce the input members' Smithy @length/@pattern constraints.
150        validate_str(&body, "clientToken", 1, 128, is_printable_ascii)?;
151        validate_str(&body, "kmsEncryptionKey", 1, 2048, is_kms_key_char)?;
152        validate_str(&body, "policy", 1, 20480, |_| true)?;
153
154        let client_token = body
155            .get("clientToken")
156            .and_then(|v| v.as_str())
157            .map(|s| s.to_string());
158
159        let deletion_protection = body
160            .get("deletionProtectionEnabled")
161            .and_then(|v| v.as_bool())
162            // Aurora DSQL enables deletion protection by default.
163            .unwrap_or(true);
164
165        let kms_encryption_key = body
166            .get("kmsEncryptionKey")
167            .and_then(|v| v.as_str())
168            .map(|s| s.to_string());
169
170        let multi_region = parse_multi_region(body.get("multiRegionProperties"));
171        let tags = parse_tag_map(body.get("tags"));
172
173        let mut accounts = self.state.write();
174        let st = accounts.get_or_create(account);
175
176        // clientToken idempotency: a repeat with the same token returns the
177        // cluster created the first time, rather than a second cluster.
178        if let Some(token) = &client_token {
179            if let Some(existing) = st
180                .clusters
181                .values()
182                .find(|c| c.client_token.as_deref() == Some(token.as_str()))
183            {
184                return Ok(cluster_mutation_response(existing));
185            }
186        }
187
188        if st.clusters.len() >= MAX_CLUSTERS_PER_ACCOUNT {
189            drop(accounts);
190            return Err(quota_exceeded(
191                "The maximum number of clusters for this account has been reached.",
192            ));
193        }
194
195        let id = gen_id();
196        let encryption_details = build_encryption(&kms_encryption_key);
197        let cluster = Cluster {
198            identifier: id.clone(),
199            arn: cluster_arn(region, account, &id),
200            status: "CREATING".to_string(),
201            creation_time: Utc::now(),
202            deletion_protection_enabled: deletion_protection,
203            multi_region_properties: multi_region,
204            encryption_details,
205            endpoint: cluster_endpoint(&id, region),
206            tags,
207            policy: body
208                .get("policy")
209                .and_then(|v| v.as_str())
210                .map(|s| s.to_string()),
211            policy_version: body.get("policy").map(|_| 1).unwrap_or(0),
212            client_token,
213            deleted_at: None,
214            streams: BTreeMap::new(),
215        };
216        let resp = cluster_mutation_response(&cluster);
217        st.clusters.insert(id, cluster);
218        Ok(resp)
219    }
220
221    fn get_cluster(&self, req: &AwsRequest, id: &str) -> Result<AwsResponse, AwsServiceError> {
222        let accounts = self.state.read();
223        let cluster = accounts
224            .get(&req.account_id)
225            .and_then(|s| s.clusters.get(id))
226            .ok_or_else(|| cluster_not_found(id))?;
227        let mut out = json!({
228            "identifier": cluster.identifier,
229            "arn": cluster.arn,
230            "status": cluster.status,
231            "creationTime": epoch_secs(cluster),
232            "deletionProtectionEnabled": cluster.deletion_protection_enabled,
233            "tags": tag_map_json(&cluster.tags),
234            "encryptionDetails": encryption_json(&cluster.encryption_details),
235            "endpoint": cluster.endpoint,
236        });
237        if let Some(mr) = &cluster.multi_region_properties {
238            out["multiRegionProperties"] = multi_region_json(mr);
239        }
240        Ok(AwsResponse::json_value(StatusCode::OK, out))
241    }
242
243    fn update_cluster(&self, req: &AwsRequest, id: &str) -> Result<AwsResponse, AwsServiceError> {
244        let body: Value = parse_body(req)?;
245        let mut accounts = self.state.write();
246        let st = accounts
247            .get_mut(&req.account_id)
248            .ok_or_else(|| cluster_not_found(id))?;
249        let cluster = st
250            .clusters
251            .get_mut(id)
252            .ok_or_else(|| cluster_not_found(id))?;
253
254        if let Some(dp) = body
255            .get("deletionProtectionEnabled")
256            .and_then(|v| v.as_bool())
257        {
258            cluster.deletion_protection_enabled = dp;
259        }
260        if let Some(k) = body.get("kmsEncryptionKey").and_then(|v| v.as_str()) {
261            cluster.encryption_details = build_encryption(&Some(k.to_string()));
262        }
263        if let Some(mr) = parse_multi_region(body.get("multiRegionProperties")) {
264            cluster.multi_region_properties = Some(mr);
265        }
266        Ok(cluster_mutation_response(cluster))
267    }
268
269    fn delete_cluster(&self, req: &AwsRequest, id: &str) -> Result<AwsResponse, AwsServiceError> {
270        let mut accounts = self.state.write();
271        let st = accounts
272            .get_mut(&req.account_id)
273            .ok_or_else(|| cluster_not_found(id))?;
274        let cluster = st
275            .clusters
276            .get_mut(id)
277            .ok_or_else(|| cluster_not_found(id))?;
278        if cluster.deletion_protection_enabled {
279            return Err(conflict(
280                "Deletion protection is enabled for this cluster. Disable deletion protection before deleting.",
281            ));
282        }
283        cluster.status = "DELETING".to_string();
284        cluster.deleted_at = Some(Utc::now());
285        Ok(cluster_mutation_response(cluster))
286    }
287
288    fn list_clusters(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
289        let (max_results, next_token) = pagination_params(req)?;
290        let accounts = self.state.read();
291        let mut summaries: Vec<Value> = accounts
292            .get(&req.account_id)
293            .map(|s| {
294                s.clusters
295                    .values()
296                    .map(|c| json!({ "identifier": c.identifier, "arn": c.arn }))
297                    .collect()
298            })
299            .unwrap_or_default();
300        summaries.sort_by(|a, b| a["identifier"].as_str().cmp(&b["identifier"].as_str()));
301        let (page, token) = paginate_lenient(&summaries, next_token.as_deref(), max_results);
302        let mut out = json!({ "clusters": page });
303        if let Some(t) = token {
304            out["nextToken"] = json!(t);
305        }
306        Ok(AwsResponse::json_value(StatusCode::OK, out))
307    }
308
309    fn get_vpc_endpoint_service_name(
310        &self,
311        req: &AwsRequest,
312        id: &str,
313    ) -> Result<AwsResponse, AwsServiceError> {
314        let accounts = self.state.read();
315        let _cluster = accounts
316            .get(&req.account_id)
317            .and_then(|s| s.clusters.get(id))
318            .ok_or_else(|| cluster_not_found(id))?;
319        let suffix = hex6(id);
320        let service_name = format!("com.amazonaws.{}.dsql-{}", req.region, suffix);
321        Ok(AwsResponse::json_value(
322            StatusCode::OK,
323            json!({ "serviceName": service_name }),
324        ))
325    }
326
327    // --- Cluster policy operations ----------------------------------------
328    //
329    // A DSQL cluster resource policy authorizes the data-plane `dsql:DbConnect`
330    // action (who may open a SQL connection to the cluster). It is stored and
331    // round-tripped faithfully here, but it is not yet registered with the
332    // central `resource_policy_provider` used by `--iam` enforcement: the only
333    // action it governs is `DbConnect`, which is a data-plane operation that
334    // lands with the Postgres data-plane batch. Enforcement is wired up there,
335    // alongside the connect path it actually gates, rather than as an
336    // enforce-nothing hook in this control-plane batch.
337
338    fn put_cluster_policy(
339        &self,
340        req: &AwsRequest,
341        id: &str,
342    ) -> Result<AwsResponse, AwsServiceError> {
343        let body: Value = parse_body(req)?;
344        let policy = body
345            .get("policy")
346            .and_then(|v| v.as_str())
347            .ok_or_else(|| validation("policy is required."))?
348            .to_string();
349        if policy.is_empty() {
350            return Err(validation("policy must not be empty."));
351        }
352        let expected = body
353            .get("expectedPolicyVersion")
354            .and_then(|v| v.as_str())
355            .and_then(|s| s.parse::<u64>().ok());
356
357        let mut accounts = self.state.write();
358        let st = accounts
359            .get_mut(&req.account_id)
360            .ok_or_else(|| cluster_not_found(id))?;
361        let cluster = st
362            .clusters
363            .get_mut(id)
364            .ok_or_else(|| cluster_not_found(id))?;
365        if let Some(exp) = expected {
366            if exp != cluster.policy_version {
367                return Err(conflict(
368                    "The expectedPolicyVersion does not match the current policy version.",
369                ));
370            }
371        }
372        cluster.policy = Some(policy);
373        cluster.policy_version += 1;
374        let version = cluster.policy_version.to_string();
375        Ok(AwsResponse::json_value(
376            StatusCode::OK,
377            json!({ "policyVersion": version }),
378        ))
379    }
380
381    fn get_cluster_policy(
382        &self,
383        req: &AwsRequest,
384        id: &str,
385    ) -> Result<AwsResponse, AwsServiceError> {
386        let accounts = self.state.read();
387        let cluster = accounts
388            .get(&req.account_id)
389            .and_then(|s| s.clusters.get(id))
390            .ok_or_else(|| cluster_not_found(id))?;
391        let policy = cluster
392            .policy
393            .as_ref()
394            .ok_or_else(|| resource_not_found("No policy is attached to this cluster."))?;
395        Ok(AwsResponse::json_value(
396            StatusCode::OK,
397            json!({ "policy": policy, "policyVersion": cluster.policy_version.to_string() }),
398        ))
399    }
400
401    fn delete_cluster_policy(
402        &self,
403        req: &AwsRequest,
404        id: &str,
405    ) -> Result<AwsResponse, AwsServiceError> {
406        let expected = req
407            .query_params
408            .get("expected-policy-version")
409            .and_then(|s| s.parse::<u64>().ok());
410        let mut accounts = self.state.write();
411        let st = accounts
412            .get_mut(&req.account_id)
413            .ok_or_else(|| cluster_not_found(id))?;
414        let cluster = st
415            .clusters
416            .get_mut(id)
417            .ok_or_else(|| cluster_not_found(id))?;
418        if let Some(exp) = expected {
419            if exp != cluster.policy_version {
420                return Err(conflict(
421                    "The expectedPolicyVersion does not match the current policy version.",
422                ));
423            }
424        }
425        cluster.policy = None;
426        cluster.policy_version += 1;
427        let version = cluster.policy_version.to_string();
428        Ok(AwsResponse::json_value(
429            StatusCode::OK,
430            json!({ "policyVersion": version }),
431        ))
432    }
433
434    // --- Stream operations -------------------------------------------------
435
436    fn create_stream(
437        &self,
438        req: &AwsRequest,
439        cluster_id: &str,
440    ) -> Result<AwsResponse, AwsServiceError> {
441        let body: Value = parse_body(req)?;
442        let target = body
443            .get("targetDefinition")
444            .cloned()
445            .ok_or_else(|| validation("targetDefinition is required."))?;
446        let ordering = body
447            .get("ordering")
448            .and_then(|v| v.as_str())
449            .ok_or_else(|| validation("ordering is required."))?
450            .to_string();
451        let format = body
452            .get("format")
453            .and_then(|v| v.as_str())
454            .ok_or_else(|| validation("format is required."))?
455            .to_string();
456        let client_token = body
457            .get("clientToken")
458            .and_then(|v| v.as_str())
459            .map(|s| s.to_string());
460        let tags = parse_tag_map(body.get("tags"));
461
462        let region = req.region.clone();
463        let account = req.account_id.clone();
464        let mut accounts = self.state.write();
465        let st = accounts
466            .get_mut(&account)
467            .ok_or_else(|| cluster_not_found(cluster_id))?;
468        let cluster = st
469            .clusters
470            .get_mut(cluster_id)
471            .ok_or_else(|| cluster_not_found(cluster_id))?;
472
473        if let Some(token) = &client_token {
474            if let Some(existing) = cluster
475                .streams
476                .values()
477                .find(|s| s.client_token.as_deref() == Some(token.as_str()))
478            {
479                return Ok(stream_mutation_response(existing));
480            }
481        }
482
483        let sid = gen_id();
484        let stream = Stream {
485            cluster_identifier: cluster_id.to_string(),
486            stream_identifier: sid.clone(),
487            arn: stream_arn(&region, &account, cluster_id, &sid),
488            status: "CREATING".to_string(),
489            creation_time: Utc::now(),
490            ordering,
491            format,
492            target_definition: target,
493            status_reason: None,
494            tags,
495            client_token,
496            deleted_at: None,
497        };
498        let resp = stream_mutation_response(&stream);
499        cluster.streams.insert(sid, stream);
500        Ok(resp)
501    }
502
503    fn get_stream(
504        &self,
505        req: &AwsRequest,
506        cluster_id: &str,
507        stream_id: &str,
508    ) -> Result<AwsResponse, AwsServiceError> {
509        let accounts = self.state.read();
510        let stream = accounts
511            .get(&req.account_id)
512            .and_then(|s| s.clusters.get(cluster_id))
513            .and_then(|c| c.streams.get(stream_id))
514            .ok_or_else(|| stream_not_found(stream_id))?;
515        let mut out = stream_full_json(stream);
516        out["tags"] = tag_map_json(&stream.tags);
517        Ok(AwsResponse::json_value(StatusCode::OK, out))
518    }
519
520    fn delete_stream(
521        &self,
522        req: &AwsRequest,
523        cluster_id: &str,
524        stream_id: &str,
525    ) -> Result<AwsResponse, AwsServiceError> {
526        let mut accounts = self.state.write();
527        let st = accounts
528            .get_mut(&req.account_id)
529            .ok_or_else(|| stream_not_found(stream_id))?;
530        let cluster = st
531            .clusters
532            .get_mut(cluster_id)
533            .ok_or_else(|| cluster_not_found(cluster_id))?;
534        let stream = cluster
535            .streams
536            .get_mut(stream_id)
537            .ok_or_else(|| stream_not_found(stream_id))?;
538        // Stamp DELETING and let the ticker remove the record after the grace
539        // window, mirroring the cluster lifecycle so the transient state is
540        // observable and persisted rather than vanishing immediately.
541        stream.status = "DELETING".to_string();
542        stream.deleted_at = Some(Utc::now());
543        let resp = json!({
544            "clusterIdentifier": stream.cluster_identifier,
545            "streamIdentifier": stream.stream_identifier,
546            "arn": stream.arn,
547            "status": stream.status,
548            "creationTime": stream.creation_time.timestamp() as f64
549                + stream.creation_time.timestamp_subsec_millis() as f64 / 1000.0,
550        });
551        Ok(AwsResponse::json_value(StatusCode::OK, resp))
552    }
553
554    fn list_streams(
555        &self,
556        req: &AwsRequest,
557        cluster_id: &str,
558    ) -> Result<AwsResponse, AwsServiceError> {
559        let (max_results, next_token) = pagination_params(req)?;
560        let accounts = self.state.read();
561        let cluster = accounts
562            .get(&req.account_id)
563            .and_then(|s| s.clusters.get(cluster_id))
564            .ok_or_else(|| cluster_not_found(cluster_id))?;
565        let mut summaries: Vec<Value> = cluster
566            .streams
567            .values()
568            .map(|s| {
569                json!({
570                    "clusterIdentifier": s.cluster_identifier,
571                    "streamIdentifier": s.stream_identifier,
572                    "arn": s.arn,
573                    "status": s.status,
574                    "creationTime": s.creation_time.timestamp() as f64
575                        + s.creation_time.timestamp_subsec_millis() as f64 / 1000.0,
576                })
577            })
578            .collect();
579        summaries.sort_by(|a, b| {
580            a["streamIdentifier"]
581                .as_str()
582                .cmp(&b["streamIdentifier"].as_str())
583        });
584        let (page, token) = paginate_lenient(&summaries, next_token.as_deref(), max_results);
585        let mut out = json!({ "streams": page });
586        if let Some(t) = token {
587            out["nextToken"] = json!(t);
588        }
589        Ok(AwsResponse::json_value(StatusCode::OK, out))
590    }
591
592    // --- Tagging -----------------------------------------------------------
593
594    fn tag_resource(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
595        require_resource_arn(arn)?;
596        let body: Value = parse_body(req)?;
597        let new_tags = parse_tag_map(body.get("tags"));
598        let mut accounts = self.state.write();
599        let st = accounts
600            .get_mut(&req.account_id)
601            .ok_or_else(|| resource_not_found_arn(arn))?;
602        let tags = resolve_tags_mut(st, arn).ok_or_else(|| resource_not_found_arn(arn))?;
603        for (k, v) in new_tags {
604            tags.insert(k, v);
605        }
606        Ok(AwsResponse::json_value(StatusCode::OK, json!({})))
607    }
608
609    fn untag_resource(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
610        require_resource_arn(arn)?;
611        let keys: Vec<String> = req
612            .query_params
613            .get("tagKeys")
614            .map(|v| v.split(',').map(|s| s.to_string()).collect())
615            .unwrap_or_default();
616        let mut accounts = self.state.write();
617        let st = accounts
618            .get_mut(&req.account_id)
619            .ok_or_else(|| resource_not_found_arn(arn))?;
620        let tags = resolve_tags_mut(st, arn).ok_or_else(|| resource_not_found_arn(arn))?;
621        for k in keys {
622            tags.remove(&k);
623        }
624        Ok(AwsResponse::json_value(StatusCode::OK, json!({})))
625    }
626
627    fn list_tags_for_resource(
628        &self,
629        req: &AwsRequest,
630        arn: &str,
631    ) -> Result<AwsResponse, AwsServiceError> {
632        require_resource_arn(arn)?;
633        let accounts = self.state.read();
634        let st = accounts
635            .get(&req.account_id)
636            .ok_or_else(|| resource_not_found_arn(arn))?;
637        let tags = resolve_tags_ref(st, arn).ok_or_else(|| resource_not_found_arn(arn))?;
638        Ok(AwsResponse::json_value(
639            StatusCode::OK,
640            json!({ "tags": tag_map_json(tags) }),
641        ))
642    }
643}
644
645/// Every operation name in the DSQL Smithy model, for introspection and the
646/// dispatch allowlist.
647pub const DSQL_ACTIONS: &[&str] = &[
648    "CreateCluster",
649    "GetCluster",
650    "UpdateCluster",
651    "DeleteCluster",
652    "ListClusters",
653    "GetClusterPolicy",
654    "PutClusterPolicy",
655    "DeleteClusterPolicy",
656    "CreateStream",
657    "GetStream",
658    "DeleteStream",
659    "ListStreams",
660    "GetVpcEndpointServiceName",
661    "TagResource",
662    "UntagResource",
663    "ListTagsForResource",
664];
665
666impl Route {
667    /// Whether this route mutates state and therefore needs a snapshot write.
668    fn mutates(&self) -> bool {
669        matches!(
670            self,
671            Route::CreateCluster
672                | Route::UpdateCluster(_)
673                | Route::DeleteCluster(_)
674                | Route::PutClusterPolicy(_)
675                | Route::DeleteClusterPolicy(_)
676                | Route::CreateStream(_)
677                | Route::DeleteStream(_, _)
678                | Route::TagResource(_)
679                | Route::UntagResource(_)
680        )
681    }
682}
683
684#[async_trait]
685impl AwsService for DsqlService {
686    fn service_name(&self) -> &str {
687        "dsql"
688    }
689
690    fn supported_actions(&self) -> &[&str] {
691        DSQL_ACTIONS
692    }
693
694    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
695        let Some(route) = Self::resolve_route(&req) else {
696            return Err(AwsServiceError::aws_error(
697                StatusCode::NOT_FOUND,
698                "UnknownOperationException",
699                format!("Unknown operation: {} {}", req.method, req.raw_path),
700            ));
701        };
702        let mutates = route.mutates();
703        let result = match route {
704            Route::CreateCluster => self.create_cluster(&req),
705            Route::ListClusters => self.list_clusters(&req),
706            Route::GetCluster(id) => self.get_cluster(&req, &id),
707            Route::UpdateCluster(id) => self.update_cluster(&req, &id),
708            Route::DeleteCluster(id) => self.delete_cluster(&req, &id),
709            Route::GetClusterPolicy(id) => self.get_cluster_policy(&req, &id),
710            Route::PutClusterPolicy(id) => self.put_cluster_policy(&req, &id),
711            Route::DeleteClusterPolicy(id) => self.delete_cluster_policy(&req, &id),
712            Route::CreateStream(cid) => self.create_stream(&req, &cid),
713            Route::ListStreams(cid) => self.list_streams(&req, &cid),
714            Route::GetStream(cid, sid) => self.get_stream(&req, &cid, &sid),
715            Route::DeleteStream(cid, sid) => self.delete_stream(&req, &cid, &sid),
716            Route::GetVpcEndpointServiceName(id) => self.get_vpc_endpoint_service_name(&req, &id),
717            Route::TagResource(arn) => self.tag_resource(&req, &arn),
718            Route::UntagResource(arn) => self.untag_resource(&req, &arn),
719            Route::ListTagsForResource(arn) => self.list_tags_for_resource(&req, &arn),
720        };
721        // Persist only after a successful mutation, mirroring the write-through
722        // behavior of the other services' handlers.
723        if mutates && result.is_ok() {
724            self.persist().await;
725        }
726        result
727    }
728}
729
730// ---------------------------------------------------------------------------
731// Response builders
732// ---------------------------------------------------------------------------
733
734/// The shared shape returned by Create/Update/DeleteCluster.
735fn cluster_mutation_response(cluster: &Cluster) -> AwsResponse {
736    let mut out = json!({
737        "identifier": cluster.identifier,
738        "arn": cluster.arn,
739        "status": cluster.status,
740        "creationTime": epoch_secs(cluster),
741        "deletionProtectionEnabled": cluster.deletion_protection_enabled,
742    });
743    if let Some(mr) = &cluster.multi_region_properties {
744        out["multiRegionProperties"] = multi_region_json(mr);
745    }
746    out["encryptionDetails"] = encryption_json(&cluster.encryption_details);
747    if !cluster.endpoint.is_empty() {
748        out["endpoint"] = json!(cluster.endpoint);
749    }
750    AwsResponse::json_value(StatusCode::OK, out)
751}
752
753fn stream_mutation_response(stream: &Stream) -> AwsResponse {
754    AwsResponse::json_value(StatusCode::OK, stream_core_json(stream))
755}
756
757fn stream_core_json(stream: &Stream) -> Value {
758    json!({
759        "clusterIdentifier": stream.cluster_identifier,
760        "streamIdentifier": stream.stream_identifier,
761        "arn": stream.arn,
762        "status": stream.status,
763        "creationTime": stream.creation_time.timestamp() as f64
764            + stream.creation_time.timestamp_subsec_millis() as f64 / 1000.0,
765        "ordering": stream.ordering,
766        "format": stream.format,
767    })
768}
769
770fn stream_full_json(stream: &Stream) -> Value {
771    let mut out = stream_core_json(stream);
772    out["targetDefinition"] = stream.target_definition.clone();
773    if let Some(reason) = &stream.status_reason {
774        out["statusReason"] = json!(reason);
775    }
776    out
777}
778
779fn epoch_secs(cluster: &Cluster) -> f64 {
780    cluster.creation_time.timestamp() as f64
781        + cluster.creation_time.timestamp_subsec_millis() as f64 / 1000.0
782}
783
784fn encryption_json(e: &EncryptionDetails) -> Value {
785    let mut out = json!({
786        "encryptionType": e.encryption_type,
787        "encryptionStatus": e.encryption_status,
788    });
789    if let Some(arn) = &e.kms_key_arn {
790        out["kmsKeyArn"] = json!(arn);
791    }
792    out
793}
794
795fn multi_region_json(mr: &MultiRegionProperties) -> Value {
796    let mut out = json!({ "clusters": mr.clusters });
797    if let Some(w) = &mr.witness_region {
798        out["witnessRegion"] = json!(w);
799    }
800    out
801}
802
803fn tag_map_json(tags: &BTreeMap<String, String>) -> Value {
804    Value::Object(
805        tags.iter()
806            .map(|(k, v)| (k.clone(), Value::String(v.clone())))
807            .collect(),
808    )
809}
810
811// ---------------------------------------------------------------------------
812// Parsing helpers
813// ---------------------------------------------------------------------------
814
815/// Enforce a string member's `@length` (and optional `@pattern`) constraint,
816/// returning a modeled `ValidationException` on violation. Only applied when
817/// the member is present.
818fn validate_str(
819    body: &Value,
820    field: &str,
821    min: usize,
822    max: usize,
823    charset_ok: impl Fn(char) -> bool,
824) -> Result<(), AwsServiceError> {
825    if let Some(s) = body.get(field).and_then(|v| v.as_str()) {
826        if s.len() < min || s.len() > max {
827            return Err(validation(&format!(
828                "Value at '{field}' failed to satisfy constraint: Member must have length between {min} and {max}."
829            )));
830        }
831        if !s.chars().all(&charset_ok) {
832            return Err(validation(&format!(
833                "Value at '{field}' failed to satisfy constraint: Member must satisfy the required pattern."
834            )));
835        }
836    }
837    Ok(())
838}
839
840/// Parse a request body as JSON. An empty body is the empty object (many DSQL
841/// inputs have only optional members); a non-empty but malformed body is a
842/// `ValidationException` rather than a silently-accepted `{}` (which would let
843/// a garbage payload drive a real mutation).
844fn parse_body(req: &AwsRequest) -> Result<Value, AwsServiceError> {
845    if req.body.is_empty() {
846        return Ok(json!({}));
847    }
848    serde_json::from_slice(&req.body).map_err(|e| validation(&format!("Invalid request body: {e}")))
849}
850
851/// Reject an empty/whitespace `resourceArn` path label with a modeled
852/// `ValidationException` (the too-short-arn constraint variant).
853fn require_resource_arn(arn: &str) -> Result<(), AwsServiceError> {
854    if arn.trim().is_empty() {
855        return Err(validation(
856            "Value at 'resourceArn' failed to satisfy constraint: Member must not be empty.",
857        ));
858    }
859    Ok(())
860}
861
862/// `^[!-~]+$` — any printable ASCII (no spaces/control).
863fn is_printable_ascii(c: char) -> bool {
864    ('!'..='~').contains(&c)
865}
866
867/// `^[a-zA-Z0-9:/_-]+$`.
868fn is_kms_key_char(c: char) -> bool {
869    c.is_ascii_alphanumeric() || matches!(c, ':' | '/' | '_' | '-')
870}
871
872fn parse_tag_map(v: Option<&Value>) -> BTreeMap<String, String> {
873    let mut out = BTreeMap::new();
874    if let Some(Value::Object(map)) = v {
875        for (k, val) in map {
876            if let Some(s) = val.as_str() {
877                out.insert(k.clone(), s.to_string());
878            }
879        }
880    }
881    out
882}
883
884fn parse_multi_region(v: Option<&Value>) -> Option<MultiRegionProperties> {
885    let obj = v?.as_object()?;
886    Some(MultiRegionProperties {
887        witness_region: obj
888            .get("witnessRegion")
889            .and_then(|v| v.as_str())
890            .map(|s| s.to_string()),
891        clusters: obj
892            .get("clusters")
893            .and_then(|v| v.as_array())
894            .map(|a| {
895                a.iter()
896                    .filter_map(|c| c.as_str().map(String::from))
897                    .collect()
898            })
899            .unwrap_or_default(),
900    })
901}
902
903fn build_encryption(kms_key: &Option<String>) -> EncryptionDetails {
904    match kms_key {
905        Some(k) => EncryptionDetails {
906            encryption_type: "CUSTOMER_MANAGED_KMS_KEY".to_string(),
907            kms_key_arn: Some(k.clone()),
908            encryption_status: "ENABLED".to_string(),
909        },
910        None => EncryptionDetails {
911            encryption_type: "AWS_OWNED_KMS_KEY".to_string(),
912            kms_key_arn: None,
913            encryption_status: "ENABLED".to_string(),
914        },
915    }
916}
917
918/// Paginate, tolerating an opaque/undecodable `nextToken` by restarting from
919/// the first page instead of raising an undeclared `ValidationException`
920/// (these list ops only model `ResourceNotFoundException`).
921fn paginate_lenient(
922    items: &[Value],
923    next_token: Option<&str>,
924    max_results: usize,
925) -> (Vec<Value>, Option<String>) {
926    match paginate_checked(items, next_token, max_results) {
927        Ok(res) => res,
928        Err(_) => paginate_checked(items, None, max_results)
929            .expect("pagination from the start is always valid"),
930    }
931}
932
933/// Parse and validate the shared list pagination parameters. `maxResults` is
934/// range-checked (1..=100 per the model's `@range`); an out-of-range value is
935/// a `ValidationException`. The `nextToken` is returned as-is and interpreted
936/// leniently by the caller (an opaque/expired token yields an empty page
937/// rather than an undeclared error, since these list ops only model
938/// `ResourceNotFoundException`).
939fn pagination_params(req: &AwsRequest) -> Result<(usize, Option<String>), AwsServiceError> {
940    let max = match req.query_params.get("max-results") {
941        Some(raw) => {
942            let n = raw.parse::<i64>().map_err(|_| {
943                validation("Value for 'maxResults' must be an integer between 1 and 100.")
944            })?;
945            if !(1..=100).contains(&n) {
946                return Err(validation(
947                    "Value for 'maxResults' must be between 1 and 100.",
948                ));
949            }
950            n as usize
951        }
952        None => 100,
953    };
954    let token = req.query_params.get("next-token").cloned();
955    Ok((max, token))
956}
957
958/// Resolve an ARN to the mutable tag map of the cluster or stream it names.
959fn resolve_tags_mut<'a>(
960    st: &'a mut crate::state::DsqlState,
961    arn: &str,
962) -> Option<&'a mut BTreeMap<String, String>> {
963    let (cluster_id, stream_id) = parse_resource_arn(arn)?;
964    let cluster = st.clusters.get_mut(&cluster_id)?;
965    match stream_id {
966        Some(sid) => cluster.streams.get_mut(&sid).map(|s| &mut s.tags),
967        None => Some(&mut cluster.tags),
968    }
969}
970
971fn resolve_tags_ref<'a>(
972    st: &'a crate::state::DsqlState,
973    arn: &str,
974) -> Option<&'a BTreeMap<String, String>> {
975    let (cluster_id, stream_id) = parse_resource_arn(arn)?;
976    let cluster = st.clusters.get(&cluster_id)?;
977    match stream_id {
978        Some(sid) => cluster.streams.get(&sid).map(|s| &s.tags),
979        None => Some(&cluster.tags),
980    }
981}
982
983/// Extract `(clusterId, Option<streamId>)` from a DSQL ARN of the form
984/// `arn:<partition>:dsql:<region>:<acct>:cluster/<id>[/stream/<id>]`. Returns
985/// `None` for anything that isn't a structurally-valid `dsql` ARN, so a
986/// malformed or wrong-service ARN can never alias a local cluster/stream.
987fn parse_resource_arn(arn: &str) -> Option<(String, Option<String>)> {
988    // arn : partition : service : region : account : resource
989    let fields: Vec<&str> = arn.splitn(6, ':').collect();
990    if fields.len() != 6 || fields[0] != "arn" || fields[2] != "dsql" {
991        return None;
992    }
993    let parts: Vec<&str> = fields[5].split('/').collect();
994    match parts.as_slice() {
995        ["cluster", cid] => Some(((*cid).to_string(), None)),
996        ["cluster", cid, "stream", sid] => Some(((*cid).to_string(), Some((*sid).to_string()))),
997        _ => None,
998    }
999}
1000
1001/// Deterministic 6-hex-char suffix for a cluster's VPC endpoint service name.
1002fn hex6(id: &str) -> String {
1003    let mut h: u32 = 2166136261;
1004    for b in id.bytes() {
1005        h ^= b as u32;
1006        h = h.wrapping_mul(16777619);
1007    }
1008    format!("{:06x}", h & 0x00ff_ffff)
1009}
1010
1011fn percent_decode(s: &str) -> String {
1012    percent_encoding::percent_decode_str(s)
1013        .decode_utf8_lossy()
1014        .to_string()
1015}
1016
1017// ---------------------------------------------------------------------------
1018// Error builders
1019// ---------------------------------------------------------------------------
1020
1021fn validation(msg: &str) -> AwsServiceError {
1022    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationException", msg)
1023}
1024
1025fn conflict(msg: &str) -> AwsServiceError {
1026    AwsServiceError::aws_error(StatusCode::CONFLICT, "ConflictException", msg)
1027}
1028
1029fn quota_exceeded(msg: &str) -> AwsServiceError {
1030    AwsServiceError::aws_error(
1031        StatusCode::PAYMENT_REQUIRED,
1032        "ServiceQuotaExceededException",
1033        msg,
1034    )
1035}
1036
1037fn resource_not_found(msg: &str) -> AwsServiceError {
1038    AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", msg)
1039}
1040
1041fn cluster_not_found(id: &str) -> AwsServiceError {
1042    resource_not_found(&format!("Cluster not found: {id}"))
1043}
1044
1045fn stream_not_found(id: &str) -> AwsServiceError {
1046    resource_not_found(&format!("Stream not found: {id}"))
1047}
1048
1049fn resource_not_found_arn(arn: &str) -> AwsServiceError {
1050    resource_not_found(&format!("Resource not found: {arn}"))
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055    use super::*;
1056
1057    #[test]
1058    fn parse_resource_arn_accepts_cluster_and_stream() {
1059        assert_eq!(
1060            parse_resource_arn("arn:aws:dsql:us-east-1:123456789012:cluster/abc"),
1061            Some(("abc".to_string(), None))
1062        );
1063        assert_eq!(
1064            parse_resource_arn("arn:aws:dsql:us-east-1:123456789012:cluster/abc/stream/xyz"),
1065            Some(("abc".to_string(), Some("xyz".to_string())))
1066        );
1067    }
1068
1069    #[test]
1070    fn parse_resource_arn_rejects_wrong_service_or_malformed() {
1071        // wrong service
1072        assert!(parse_resource_arn("arn:aws:rds:us-east-1:123456789012:cluster/abc").is_none());
1073        // not an ARN
1074        assert!(parse_resource_arn("cluster/abc").is_none());
1075        // missing "arn" literal
1076        assert!(parse_resource_arn("xrn:aws:dsql:us-east-1:123456789012:cluster/abc").is_none());
1077        // wrong resource type
1078        assert!(parse_resource_arn("arn:aws:dsql:us-east-1:123456789012:widget/abc").is_none());
1079    }
1080
1081    #[test]
1082    fn hex6_is_six_hex_chars() {
1083        let h = hex6("abcdefghij0123456789klmnop");
1084        assert_eq!(h.len(), 6);
1085        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1086    }
1087}