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        // `tagKeys` is an `@httpQuery` list sent as repeated `tagKeys=a&tagKeys=b`
612        // pairs; `query_params` collapses repeats to the last value, so parse the
613        // raw query string for every occurrence, percent-decoding each.
614        let keys: Vec<String> = req
615            .raw_query
616            .split('&')
617            .filter_map(|pair| pair.strip_prefix("tagKeys="))
618            .map(percent_decode)
619            .collect();
620        let mut accounts = self.state.write();
621        let st = accounts
622            .get_mut(&req.account_id)
623            .ok_or_else(|| resource_not_found_arn(arn))?;
624        let tags = resolve_tags_mut(st, arn).ok_or_else(|| resource_not_found_arn(arn))?;
625        for k in keys {
626            tags.remove(&k);
627        }
628        Ok(AwsResponse::json_value(StatusCode::OK, json!({})))
629    }
630
631    fn list_tags_for_resource(
632        &self,
633        req: &AwsRequest,
634        arn: &str,
635    ) -> Result<AwsResponse, AwsServiceError> {
636        require_resource_arn(arn)?;
637        let accounts = self.state.read();
638        let st = accounts
639            .get(&req.account_id)
640            .ok_or_else(|| resource_not_found_arn(arn))?;
641        let tags = resolve_tags_ref(st, arn).ok_or_else(|| resource_not_found_arn(arn))?;
642        Ok(AwsResponse::json_value(
643            StatusCode::OK,
644            json!({ "tags": tag_map_json(tags) }),
645        ))
646    }
647}
648
649/// Every operation name in the DSQL Smithy model, for introspection and the
650/// dispatch allowlist.
651pub const DSQL_ACTIONS: &[&str] = &[
652    "CreateCluster",
653    "GetCluster",
654    "UpdateCluster",
655    "DeleteCluster",
656    "ListClusters",
657    "GetClusterPolicy",
658    "PutClusterPolicy",
659    "DeleteClusterPolicy",
660    "CreateStream",
661    "GetStream",
662    "DeleteStream",
663    "ListStreams",
664    "GetVpcEndpointServiceName",
665    "TagResource",
666    "UntagResource",
667    "ListTagsForResource",
668];
669
670impl Route {
671    /// Whether this route mutates state and therefore needs a snapshot write.
672    fn mutates(&self) -> bool {
673        matches!(
674            self,
675            Route::CreateCluster
676                | Route::UpdateCluster(_)
677                | Route::DeleteCluster(_)
678                | Route::PutClusterPolicy(_)
679                | Route::DeleteClusterPolicy(_)
680                | Route::CreateStream(_)
681                | Route::DeleteStream(_, _)
682                | Route::TagResource(_)
683                | Route::UntagResource(_)
684        )
685    }
686}
687
688#[async_trait]
689impl AwsService for DsqlService {
690    fn service_name(&self) -> &str {
691        "dsql"
692    }
693
694    fn supported_actions(&self) -> &[&str] {
695        DSQL_ACTIONS
696    }
697
698    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
699        let Some(route) = Self::resolve_route(&req) else {
700            return Err(AwsServiceError::aws_error(
701                StatusCode::NOT_FOUND,
702                "UnknownOperationException",
703                format!("Unknown operation: {} {}", req.method, req.raw_path),
704            ));
705        };
706        let mutates = route.mutates();
707        let result = match route {
708            Route::CreateCluster => self.create_cluster(&req),
709            Route::ListClusters => self.list_clusters(&req),
710            Route::GetCluster(id) => self.get_cluster(&req, &id),
711            Route::UpdateCluster(id) => self.update_cluster(&req, &id),
712            Route::DeleteCluster(id) => self.delete_cluster(&req, &id),
713            Route::GetClusterPolicy(id) => self.get_cluster_policy(&req, &id),
714            Route::PutClusterPolicy(id) => self.put_cluster_policy(&req, &id),
715            Route::DeleteClusterPolicy(id) => self.delete_cluster_policy(&req, &id),
716            Route::CreateStream(cid) => self.create_stream(&req, &cid),
717            Route::ListStreams(cid) => self.list_streams(&req, &cid),
718            Route::GetStream(cid, sid) => self.get_stream(&req, &cid, &sid),
719            Route::DeleteStream(cid, sid) => self.delete_stream(&req, &cid, &sid),
720            Route::GetVpcEndpointServiceName(id) => self.get_vpc_endpoint_service_name(&req, &id),
721            Route::TagResource(arn) => self.tag_resource(&req, &arn),
722            Route::UntagResource(arn) => self.untag_resource(&req, &arn),
723            Route::ListTagsForResource(arn) => self.list_tags_for_resource(&req, &arn),
724        };
725        // Persist only after a successful mutation, mirroring the write-through
726        // behavior of the other services' handlers.
727        if mutates && result.is_ok() {
728            self.persist().await;
729        }
730        result
731    }
732}
733
734// ---------------------------------------------------------------------------
735// Response builders
736// ---------------------------------------------------------------------------
737
738/// The shared shape returned by Create/Update/DeleteCluster.
739fn cluster_mutation_response(cluster: &Cluster) -> AwsResponse {
740    let mut out = json!({
741        "identifier": cluster.identifier,
742        "arn": cluster.arn,
743        "status": cluster.status,
744        "creationTime": epoch_secs(cluster),
745        "deletionProtectionEnabled": cluster.deletion_protection_enabled,
746    });
747    if let Some(mr) = &cluster.multi_region_properties {
748        out["multiRegionProperties"] = multi_region_json(mr);
749    }
750    out["encryptionDetails"] = encryption_json(&cluster.encryption_details);
751    if !cluster.endpoint.is_empty() {
752        out["endpoint"] = json!(cluster.endpoint);
753    }
754    AwsResponse::json_value(StatusCode::OK, out)
755}
756
757fn stream_mutation_response(stream: &Stream) -> AwsResponse {
758    AwsResponse::json_value(StatusCode::OK, stream_core_json(stream))
759}
760
761fn stream_core_json(stream: &Stream) -> Value {
762    json!({
763        "clusterIdentifier": stream.cluster_identifier,
764        "streamIdentifier": stream.stream_identifier,
765        "arn": stream.arn,
766        "status": stream.status,
767        "creationTime": stream.creation_time.timestamp() as f64
768            + stream.creation_time.timestamp_subsec_millis() as f64 / 1000.0,
769        "ordering": stream.ordering,
770        "format": stream.format,
771    })
772}
773
774fn stream_full_json(stream: &Stream) -> Value {
775    let mut out = stream_core_json(stream);
776    out["targetDefinition"] = stream.target_definition.clone();
777    if let Some(reason) = &stream.status_reason {
778        out["statusReason"] = json!(reason);
779    }
780    out
781}
782
783fn epoch_secs(cluster: &Cluster) -> f64 {
784    cluster.creation_time.timestamp() as f64
785        + cluster.creation_time.timestamp_subsec_millis() as f64 / 1000.0
786}
787
788fn encryption_json(e: &EncryptionDetails) -> Value {
789    let mut out = json!({
790        "encryptionType": e.encryption_type,
791        "encryptionStatus": e.encryption_status,
792    });
793    if let Some(arn) = &e.kms_key_arn {
794        out["kmsKeyArn"] = json!(arn);
795    }
796    out
797}
798
799fn multi_region_json(mr: &MultiRegionProperties) -> Value {
800    let mut out = json!({ "clusters": mr.clusters });
801    if let Some(w) = &mr.witness_region {
802        out["witnessRegion"] = json!(w);
803    }
804    out
805}
806
807fn tag_map_json(tags: &BTreeMap<String, String>) -> Value {
808    Value::Object(
809        tags.iter()
810            .map(|(k, v)| (k.clone(), Value::String(v.clone())))
811            .collect(),
812    )
813}
814
815// ---------------------------------------------------------------------------
816// Parsing helpers
817// ---------------------------------------------------------------------------
818
819/// Enforce a string member's `@length` (and optional `@pattern`) constraint,
820/// returning a modeled `ValidationException` on violation. Only applied when
821/// the member is present.
822fn validate_str(
823    body: &Value,
824    field: &str,
825    min: usize,
826    max: usize,
827    charset_ok: impl Fn(char) -> bool,
828) -> Result<(), AwsServiceError> {
829    if let Some(s) = body.get(field).and_then(|v| v.as_str()) {
830        if s.len() < min || s.len() > max {
831            return Err(validation(&format!(
832                "Value at '{field}' failed to satisfy constraint: Member must have length between {min} and {max}."
833            )));
834        }
835        if !s.chars().all(&charset_ok) {
836            return Err(validation(&format!(
837                "Value at '{field}' failed to satisfy constraint: Member must satisfy the required pattern."
838            )));
839        }
840    }
841    Ok(())
842}
843
844/// Parse a request body as JSON. An empty body is the empty object (many DSQL
845/// inputs have only optional members); a non-empty but malformed body is a
846/// `ValidationException` rather than a silently-accepted `{}` (which would let
847/// a garbage payload drive a real mutation).
848fn parse_body(req: &AwsRequest) -> Result<Value, AwsServiceError> {
849    if req.body.is_empty() {
850        return Ok(json!({}));
851    }
852    serde_json::from_slice(&req.body).map_err(|e| validation(&format!("Invalid request body: {e}")))
853}
854
855/// Reject an empty/whitespace `resourceArn` path label with a modeled
856/// `ValidationException` (the too-short-arn constraint variant).
857fn require_resource_arn(arn: &str) -> Result<(), AwsServiceError> {
858    if arn.trim().is_empty() {
859        return Err(validation(
860            "Value at 'resourceArn' failed to satisfy constraint: Member must not be empty.",
861        ));
862    }
863    Ok(())
864}
865
866/// `^[!-~]+$` — any printable ASCII (no spaces/control).
867fn is_printable_ascii(c: char) -> bool {
868    ('!'..='~').contains(&c)
869}
870
871/// `^[a-zA-Z0-9:/_-]+$`.
872fn is_kms_key_char(c: char) -> bool {
873    c.is_ascii_alphanumeric() || matches!(c, ':' | '/' | '_' | '-')
874}
875
876fn parse_tag_map(v: Option<&Value>) -> BTreeMap<String, String> {
877    let mut out = BTreeMap::new();
878    if let Some(Value::Object(map)) = v {
879        for (k, val) in map {
880            if let Some(s) = val.as_str() {
881                out.insert(k.clone(), s.to_string());
882            }
883        }
884    }
885    out
886}
887
888fn parse_multi_region(v: Option<&Value>) -> Option<MultiRegionProperties> {
889    let obj = v?.as_object()?;
890    Some(MultiRegionProperties {
891        witness_region: obj
892            .get("witnessRegion")
893            .and_then(|v| v.as_str())
894            .map(|s| s.to_string()),
895        clusters: obj
896            .get("clusters")
897            .and_then(|v| v.as_array())
898            .map(|a| {
899                a.iter()
900                    .filter_map(|c| c.as_str().map(String::from))
901                    .collect()
902            })
903            .unwrap_or_default(),
904    })
905}
906
907fn build_encryption(kms_key: &Option<String>) -> EncryptionDetails {
908    match kms_key {
909        Some(k) => EncryptionDetails {
910            encryption_type: "CUSTOMER_MANAGED_KMS_KEY".to_string(),
911            kms_key_arn: Some(k.clone()),
912            encryption_status: "ENABLED".to_string(),
913        },
914        None => EncryptionDetails {
915            encryption_type: "AWS_OWNED_KMS_KEY".to_string(),
916            kms_key_arn: None,
917            encryption_status: "ENABLED".to_string(),
918        },
919    }
920}
921
922/// Paginate, tolerating an opaque/undecodable `nextToken` by restarting from
923/// the first page instead of raising an undeclared `ValidationException`
924/// (these list ops only model `ResourceNotFoundException`).
925fn paginate_lenient(
926    items: &[Value],
927    next_token: Option<&str>,
928    max_results: usize,
929) -> (Vec<Value>, Option<String>) {
930    match paginate_checked(items, next_token, max_results) {
931        Ok(res) => res,
932        Err(_) => paginate_checked(items, None, max_results)
933            .expect("pagination from the start is always valid"),
934    }
935}
936
937/// Parse and validate the shared list pagination parameters. `maxResults` is
938/// range-checked (1..=100 per the model's `@range`); an out-of-range value is
939/// a `ValidationException`. The `nextToken` is returned as-is and interpreted
940/// leniently by the caller (an opaque/expired token yields an empty page
941/// rather than an undeclared error, since these list ops only model
942/// `ResourceNotFoundException`).
943fn pagination_params(req: &AwsRequest) -> Result<(usize, Option<String>), AwsServiceError> {
944    let max = match req.query_params.get("max-results") {
945        Some(raw) => {
946            let n = raw.parse::<i64>().map_err(|_| {
947                validation("Value for 'maxResults' must be an integer between 1 and 100.")
948            })?;
949            if !(1..=100).contains(&n) {
950                return Err(validation(
951                    "Value for 'maxResults' must be between 1 and 100.",
952                ));
953            }
954            n as usize
955        }
956        None => 100,
957    };
958    let token = req.query_params.get("next-token").cloned();
959    Ok((max, token))
960}
961
962/// Resolve an ARN to the mutable tag map of the cluster or stream it names.
963fn resolve_tags_mut<'a>(
964    st: &'a mut crate::state::DsqlState,
965    arn: &str,
966) -> Option<&'a mut BTreeMap<String, String>> {
967    let (cluster_id, stream_id) = parse_resource_arn(arn)?;
968    let cluster = st.clusters.get_mut(&cluster_id)?;
969    match stream_id {
970        Some(sid) => cluster.streams.get_mut(&sid).map(|s| &mut s.tags),
971        None => Some(&mut cluster.tags),
972    }
973}
974
975fn resolve_tags_ref<'a>(
976    st: &'a crate::state::DsqlState,
977    arn: &str,
978) -> Option<&'a BTreeMap<String, String>> {
979    let (cluster_id, stream_id) = parse_resource_arn(arn)?;
980    let cluster = st.clusters.get(&cluster_id)?;
981    match stream_id {
982        Some(sid) => cluster.streams.get(&sid).map(|s| &s.tags),
983        None => Some(&cluster.tags),
984    }
985}
986
987/// Extract `(clusterId, Option<streamId>)` from a DSQL ARN of the form
988/// `arn:<partition>:dsql:<region>:<acct>:cluster/<id>[/stream/<id>]`. Returns
989/// `None` for anything that isn't a structurally-valid `dsql` ARN, so a
990/// malformed or wrong-service ARN can never alias a local cluster/stream.
991fn parse_resource_arn(arn: &str) -> Option<(String, Option<String>)> {
992    // arn : partition : service : region : account : resource
993    let fields: Vec<&str> = arn.splitn(6, ':').collect();
994    if fields.len() != 6 || fields[0] != "arn" || fields[2] != "dsql" {
995        return None;
996    }
997    let parts: Vec<&str> = fields[5].split('/').collect();
998    match parts.as_slice() {
999        ["cluster", cid] => Some(((*cid).to_string(), None)),
1000        ["cluster", cid, "stream", sid] => Some(((*cid).to_string(), Some((*sid).to_string()))),
1001        _ => None,
1002    }
1003}
1004
1005/// Deterministic 6-hex-char suffix for a cluster's VPC endpoint service name.
1006fn hex6(id: &str) -> String {
1007    let mut h: u32 = 2166136261;
1008    for b in id.bytes() {
1009        h ^= b as u32;
1010        h = h.wrapping_mul(16777619);
1011    }
1012    format!("{:06x}", h & 0x00ff_ffff)
1013}
1014
1015fn percent_decode(s: &str) -> String {
1016    percent_encoding::percent_decode_str(s)
1017        .decode_utf8_lossy()
1018        .to_string()
1019}
1020
1021// ---------------------------------------------------------------------------
1022// Error builders
1023// ---------------------------------------------------------------------------
1024
1025fn validation(msg: &str) -> AwsServiceError {
1026    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationException", msg)
1027}
1028
1029fn conflict(msg: &str) -> AwsServiceError {
1030    AwsServiceError::aws_error(StatusCode::CONFLICT, "ConflictException", msg)
1031}
1032
1033fn quota_exceeded(msg: &str) -> AwsServiceError {
1034    AwsServiceError::aws_error(
1035        StatusCode::PAYMENT_REQUIRED,
1036        "ServiceQuotaExceededException",
1037        msg,
1038    )
1039}
1040
1041fn resource_not_found(msg: &str) -> AwsServiceError {
1042    AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", msg)
1043}
1044
1045fn cluster_not_found(id: &str) -> AwsServiceError {
1046    resource_not_found(&format!("Cluster not found: {id}"))
1047}
1048
1049fn stream_not_found(id: &str) -> AwsServiceError {
1050    resource_not_found(&format!("Stream not found: {id}"))
1051}
1052
1053fn resource_not_found_arn(arn: &str) -> AwsServiceError {
1054    resource_not_found(&format!("Resource not found: {arn}"))
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059    use super::*;
1060
1061    #[test]
1062    fn parse_resource_arn_accepts_cluster_and_stream() {
1063        assert_eq!(
1064            parse_resource_arn("arn:aws:dsql:us-east-1:123456789012:cluster/abc"),
1065            Some(("abc".to_string(), None))
1066        );
1067        assert_eq!(
1068            parse_resource_arn("arn:aws:dsql:us-east-1:123456789012:cluster/abc/stream/xyz"),
1069            Some(("abc".to_string(), Some("xyz".to_string())))
1070        );
1071    }
1072
1073    #[test]
1074    fn parse_resource_arn_rejects_wrong_service_or_malformed() {
1075        // wrong service
1076        assert!(parse_resource_arn("arn:aws:rds:us-east-1:123456789012:cluster/abc").is_none());
1077        // not an ARN
1078        assert!(parse_resource_arn("cluster/abc").is_none());
1079        // missing "arn" literal
1080        assert!(parse_resource_arn("xrn:aws:dsql:us-east-1:123456789012:cluster/abc").is_none());
1081        // wrong resource type
1082        assert!(parse_resource_arn("arn:aws:dsql:us-east-1:123456789012:widget/abc").is_none());
1083    }
1084
1085    #[test]
1086    fn hex6_is_six_hex_chars() {
1087        let h = hex6("abcdefghij0123456789klmnop");
1088        assert_eq!(h.len(), 6);
1089        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
1090    }
1091}