1use std::sync::Arc;
10
11use async_trait::async_trait;
12use http::StatusCode;
13use serde_json::{json, Value};
14
15use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
16
17use crate::state::{DynamoTable, SharedDynamoDbState};
18
19pub struct DynamoDbStreamsService {
20 state: SharedDynamoDbState,
21}
22
23impl DynamoDbStreamsService {
24 pub fn new(state: SharedDynamoDbState) -> Self {
25 Self { state }
26 }
27}
28
29#[async_trait]
30impl AwsService for DynamoDbStreamsService {
31 fn service_name(&self) -> &str {
32 "dynamodbstreams"
33 }
34
35 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
36 let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
37 match req.action.as_str() {
38 "ListStreams" => self.list_streams(&req, &body),
39 "DescribeStream" => self.describe_stream(&req, &body),
40 "GetShardIterator" => self.get_shard_iterator(&req, &body),
41 "GetRecords" => self.get_records(&req, &body),
42 _ => Err(AwsServiceError::action_not_implemented(
43 "dynamodbstreams",
44 &req.action,
45 )),
46 }
47 }
48
49 fn supported_actions(&self) -> &[&str] {
50 &[
51 "ListStreams",
52 "DescribeStream",
53 "GetShardIterator",
54 "GetRecords",
55 ]
56 }
57}
58
59impl DynamoDbStreamsService {
60 fn list_streams(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
61 let table_filter = body["TableName"].as_str();
62 let accounts = self.state.read();
63 let state = match accounts.get(&req.account_id) {
64 Some(s) => s,
65 None => return Ok(AwsResponse::ok_json(json!({ "Streams": [] }))),
66 };
67 let mut streams = Vec::new();
68 for table in state.tables.values() {
69 if let Some(name) = table_filter {
70 if table.name != name {
71 continue;
72 }
73 }
74 if !table.stream_enabled {
75 continue;
76 }
77 let Some(arn) = table.stream_arn.as_ref() else {
78 continue;
79 };
80 let label = stream_label(arn);
81 streams.push(json!({
82 "StreamArn": arn,
83 "TableName": table.name,
84 "StreamLabel": label,
85 }));
86 }
87 Ok(AwsResponse::ok_json(json!({ "Streams": streams })))
88 }
89
90 fn describe_stream(
91 &self,
92 req: &AwsRequest,
93 body: &Value,
94 ) -> Result<AwsResponse, AwsServiceError> {
95 let stream_arn = require_string(body, "StreamArn")?;
96 let accounts = self.state.read();
97 let state = accounts
98 .get(&req.account_id)
99 .ok_or_else(|| not_found("Stream", &stream_arn))?;
100 let table = state
101 .tables
102 .values()
103 .find(|t| t.stream_arn.as_deref() == Some(stream_arn.as_str()))
104 .ok_or_else(|| not_found("Stream", &stream_arn))?;
105
106 let view_type = table
107 .stream_view_type
108 .clone()
109 .unwrap_or_else(|| "NEW_AND_OLD_IMAGES".to_string());
110 let label = stream_label(&stream_arn);
111 let key_schema: Vec<Value> = table
112 .key_schema
113 .iter()
114 .map(|k| {
115 json!({
116 "AttributeName": k.attribute_name,
117 "KeyType": k.key_type,
118 })
119 })
120 .collect();
121
122 let body = json!({
123 "StreamDescription": {
124 "StreamArn": stream_arn,
125 "StreamLabel": label,
126 "StreamStatus": "ENABLED",
127 "StreamViewType": view_type,
128 "CreationRequestDateTime": table.created_at.timestamp() as f64,
129 "TableName": table.name,
130 "KeySchema": key_schema,
131 "Shards": [{
132 "ShardId": "shardId-00000000000000000000-00000001",
133 "SequenceNumberRange": {
134 "StartingSequenceNumber": "0",
135 },
136 }],
137 }
138 });
139 Ok(AwsResponse::ok_json(body))
140 }
141
142 fn get_shard_iterator(
143 &self,
144 req: &AwsRequest,
145 body: &Value,
146 ) -> Result<AwsResponse, AwsServiceError> {
147 let stream_arn = require_string(body, "StreamArn")?;
148 let shard_id = require_string(body, "ShardId")?;
149 let iterator_type = require_string(body, "ShardIteratorType")?;
150
151 let accounts = self.state.read();
152 let state = accounts
153 .get(&req.account_id)
154 .ok_or_else(|| not_found("Stream", &stream_arn))?;
155 let table = state
156 .tables
157 .values()
158 .find(|t| t.stream_arn.as_deref() == Some(stream_arn.as_str()))
159 .ok_or_else(|| not_found("Stream", &stream_arn))?;
160
161 let records = table.stream_records.read();
169 let after_seq: String = match iterator_type.as_str() {
170 "TRIM_HORIZON" => "0".to_string(),
173 "LATEST" => records
176 .iter()
177 .map(|r| r.dynamodb.sequence_number.clone())
178 .max_by(|a, b| cmp_seq(a, b))
179 .unwrap_or_else(|| "0".to_string()),
180 "AT_SEQUENCE_NUMBER" => {
185 let seq = require_string(body, "SequenceNumber")?;
186 if !records.iter().any(|r| r.dynamodb.sequence_number == seq) {
187 return Err(trimmed_data_access(&seq));
188 }
189 exclusive_before(&seq)
190 }
191 "AFTER_SEQUENCE_NUMBER" => {
193 let seq = require_string(body, "SequenceNumber")?;
194 if !records.iter().any(|r| r.dynamodb.sequence_number == seq) {
195 return Err(trimmed_data_access(&seq));
196 }
197 seq
198 }
199 other => {
200 return Err(invalid_argument(&format!(
201 "Unsupported ShardIteratorType: {other}",
202 )))
203 }
204 };
205
206 let token = format!("{stream_arn}|{shard_id}|{after_seq}");
207 Ok(AwsResponse::ok_json(json!({ "ShardIterator": token })))
208 }
209
210 fn get_records(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
211 let iterator = require_string(body, "ShardIterator")?;
212 let limit = body["Limit"].as_u64().unwrap_or(1000) as usize;
213
214 let parts: Vec<&str> = iterator.splitn(3, '|').collect();
215 if parts.len() != 3 {
216 return Err(invalid_argument("ShardIterator is invalid"));
217 }
218 let stream_arn = parts[0].to_string();
219 let shard_id = parts[1].to_string();
220 let after_seq = parts[2].to_string();
225
226 let accounts = self.state.read();
227 let state = accounts
228 .get(&req.account_id)
229 .ok_or_else(|| not_found("Stream", &stream_arn))?;
230 let table = state
231 .tables
232 .values()
233 .find(|t| t.stream_arn.as_deref() == Some(stream_arn.as_str()))
234 .ok_or_else(|| not_found("Stream", &stream_arn))?;
235
236 let records = table.stream_records.read();
241 let selected: Vec<&crate::state::StreamRecord> = records
242 .iter()
243 .filter(|r| {
244 cmp_seq(&r.dynamodb.sequence_number, &after_seq) == std::cmp::Ordering::Greater
245 })
246 .take(limit)
247 .collect();
248
249 let next_seq = selected
250 .last()
251 .map(|r| r.dynamodb.sequence_number.clone())
252 .unwrap_or(after_seq);
253 let records_json: Vec<Value> = selected
254 .iter()
255 .map(|r| stream_record_to_json(r, table))
256 .collect();
257
258 let next_token = format!("{stream_arn}|{shard_id}|{next_seq}");
259 Ok(AwsResponse::ok_json(json!({
260 "Records": records_json,
261 "NextShardIterator": next_token,
262 })))
263 }
264}
265
266fn stream_record_to_json(r: &crate::state::StreamRecord, table: &DynamoTable) -> Value {
267 let mut dynamodb = json!({
268 "ApproximateCreationDateTime": r.timestamp.timestamp() as f64,
269 "Keys": &r.dynamodb.keys,
270 "SequenceNumber": r.dynamodb.sequence_number,
271 "SizeBytes": r.dynamodb.size_bytes,
272 "StreamViewType": r.dynamodb.stream_view_type,
273 });
274 if let Some(ni) = r.dynamodb.new_image.as_ref() {
275 dynamodb["NewImage"] = json!(ni);
276 }
277 if let Some(oi) = r.dynamodb.old_image.as_ref() {
278 dynamodb["OldImage"] = json!(oi);
279 }
280 let mut out = json!({
281 "eventID": r.event_id,
282 "eventName": r.event_name,
283 "eventVersion": r.event_version,
284 "eventSource": r.event_source,
285 "awsRegion": r.aws_region,
286 "eventSourceARN": table.stream_arn.clone().unwrap_or_default(),
287 "dynamodb": dynamodb,
288 });
289 if let Some(ui) = r.user_identity.as_ref() {
290 out["userIdentity"] = json!({
294 "PrincipalId": ui.principal_id,
295 "Type": ui.identity_type,
296 });
297 }
298 out
299}
300
301fn stream_label(stream_arn: &str) -> String {
302 stream_arn.rsplit('/').next().unwrap_or("").to_string()
303}
304
305pub fn cmp_seq(a: &str, b: &str) -> std::cmp::Ordering {
314 match (a.parse::<u128>(), b.parse::<u128>()) {
315 (Ok(x), Ok(y)) => x.cmp(&y),
316 _ => a.cmp(b),
318 }
319}
320
321fn exclusive_before(seq: &str) -> String {
326 match seq.parse::<u128>() {
327 Ok(n) if n > 0 => {
328 format!("{:0width$}", n - 1, width = seq.len())
331 }
332 _ => "0".to_string(),
333 }
334}
335
336fn require_string(body: &Value, field: &str) -> Result<String, AwsServiceError> {
337 body[field]
338 .as_str()
339 .map(|s| s.to_string())
340 .ok_or_else(|| invalid_argument(&format!("{field} is required")))
341}
342
343fn invalid_argument(msg: &str) -> AwsServiceError {
344 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationException", msg)
345}
346
347fn trimmed_data_access(seq: &str) -> AwsServiceError {
351 AwsServiceError::aws_error(
352 StatusCode::BAD_REQUEST,
353 "TrimmedDataAccessException",
354 format!("Attempted access to a trimmed data: sequence number {seq} is no longer available in the stream"),
355 )
356}
357
358fn not_found(kind: &str, target: &str) -> AwsServiceError {
359 AwsServiceError::aws_error(
360 StatusCode::BAD_REQUEST,
361 "ResourceNotFoundException",
362 format!("{kind} not found: {target}"),
363 )
364}
365
366pub fn shared(state: SharedDynamoDbState) -> Arc<dyn AwsService> {
367 Arc::new(DynamoDbStreamsService::new(state))
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::state::{DynamoDbStreamRecord, DynamoTable, ProvisionedThroughput, StreamRecord};
374 use bytes::Bytes;
375 use chrono::Utc;
376 use http::{HeaderMap, Method};
377 use parking_lot::RwLock;
378 use std::collections::{BTreeMap, HashMap};
379 use std::sync::Arc;
380
381 fn make_state() -> SharedDynamoDbState {
382 Arc::new(RwLock::new(
383 fakecloud_core::multi_account::MultiAccountState::new("123456789012", "us-east-1", ""),
384 ))
385 }
386
387 fn req(action: &str, body: Value) -> AwsRequest {
388 AwsRequest {
389 service: "dynamodbstreams".into(),
390 action: action.into(),
391 region: "us-east-1".into(),
392 account_id: "123456789012".into(),
393 request_id: "r".into(),
394 headers: HeaderMap::new(),
395 query_params: HashMap::new(),
396 body: Bytes::from(serde_json::to_vec(&body).unwrap()),
397 body_stream: parking_lot::Mutex::new(None),
398 path_segments: vec![],
399 raw_path: "/".into(),
400 raw_query: String::new(),
401 method: Method::POST,
402 is_query_protocol: false,
403 access_key_id: None,
404 principal: None,
405 }
406 }
407
408 fn seed_table(state: &SharedDynamoDbState) -> String {
409 let mut accts = state.write();
410 let s = accts.get_or_create("123456789012");
411 let arn =
412 "arn:aws:dynamodb:us-east-1:123456789012:table/widgets/stream/2026-05-03T00:00:00.000"
413 .to_string();
414 let table = DynamoTable {
415 name: "widgets".to_string(),
416 arn: "arn:aws:dynamodb:us-east-1:123456789012:table/widgets".to_string(),
417 table_id: "id".to_string(),
418 key_schema: Vec::new(),
419 attribute_definitions: Vec::new(),
420 provisioned_throughput: ProvisionedThroughput {
421 read_capacity_units: 0,
422 write_capacity_units: 0,
423 },
424 items: Vec::new(),
425 gsi: Vec::new(),
426 lsi: Vec::new(),
427 tags: BTreeMap::new(),
428 created_at: Utc::now(),
429 status: "ACTIVE".to_string(),
430 item_count: 0,
431 size_bytes: 0,
432 billing_mode: "PAY_PER_REQUEST".to_string(),
433 ttl_attribute: None,
434 ttl_enabled: false,
435 resource_policy: None,
436 pitr_enabled: false,
437 kinesis_destinations: Vec::new(),
438 contributor_insights_status: "DISABLED".to_string(),
439 contributor_insights_counters: BTreeMap::new(),
440 stream_enabled: true,
441 stream_view_type: Some("NEW_AND_OLD_IMAGES".to_string()),
442 stream_arn: Some(arn.clone()),
443 stream_records: Arc::new(RwLock::new(Vec::new())),
444 sse_type: None,
445 sse_kms_key_arn: None,
446 deletion_protection_enabled: false,
447 on_demand_throughput: None,
448 table_class: "STANDARD".to_string(),
449 };
450 let rec = StreamRecord {
451 event_id: "e1".into(),
452 event_name: "INSERT".into(),
453 event_version: "1.1".into(),
454 event_source: "aws:dynamodb".into(),
455 aws_region: "us-east-1".into(),
456 event_source_arn: arn.clone(),
457 timestamp: Utc::now(),
458 dynamodb: DynamoDbStreamRecord {
459 keys: HashMap::new(),
460 new_image: Some(HashMap::new()),
461 old_image: None,
462 sequence_number: "1".into(),
463 size_bytes: 16,
464 stream_view_type: "NEW_AND_OLD_IMAGES".into(),
465 },
466 user_identity: None,
467 };
468 table.stream_records.write().push(rec);
469 s.tables.insert("widgets".to_string(), table);
470 arn
471 }
472
473 #[tokio::test]
474 async fn list_streams_returns_enabled_streams() {
475 let state = make_state();
476 let arn = seed_table(&state);
477 let svc = DynamoDbStreamsService::new(state);
478 let resp = svc.handle(req("ListStreams", json!({}))).await.unwrap();
479 let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
480 let streams = body["Streams"].as_array().unwrap();
481 assert_eq!(streams.len(), 1);
482 assert_eq!(streams[0]["StreamArn"].as_str().unwrap(), arn);
483 }
484
485 #[tokio::test]
486 async fn describe_stream_returns_shard() {
487 let state = make_state();
488 let arn = seed_table(&state);
489 let svc = DynamoDbStreamsService::new(state);
490 let resp = svc
491 .handle(req("DescribeStream", json!({"StreamArn": arn})))
492 .await
493 .unwrap();
494 let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
495 let desc = &body["StreamDescription"];
496 assert_eq!(desc["StreamStatus"].as_str().unwrap(), "ENABLED");
497 assert_eq!(desc["Shards"].as_array().unwrap().len(), 1);
498 }
499
500 #[tokio::test]
501 async fn get_records_round_trip_via_shard_iterator() {
502 let state = make_state();
503 let arn = seed_table(&state);
504 let svc = DynamoDbStreamsService::new(state);
505 let it_resp = svc
506 .handle(req(
507 "GetShardIterator",
508 json!({
509 "StreamArn": arn,
510 "ShardId": "shardId-00000000000000000000-00000001",
511 "ShardIteratorType": "TRIM_HORIZON",
512 }),
513 ))
514 .await
515 .unwrap();
516 let it_body: Value = serde_json::from_slice(it_resp.body.expect_bytes()).unwrap();
517 let iterator = it_body["ShardIterator"].as_str().unwrap().to_string();
518 let resp = svc
519 .handle(req("GetRecords", json!({"ShardIterator": iterator})))
520 .await
521 .unwrap();
522 let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
523 let recs = body["Records"].as_array().unwrap();
524 assert_eq!(recs.len(), 1);
525 assert_eq!(recs[0]["eventName"].as_str().unwrap(), "INSERT");
526 }
527
528 fn push_record(state: &SharedDynamoDbState, seq: &str, age_hours: i64, event_id: &str) {
529 let mut accts = state.write();
530 let s = accts.get_or_create("123456789012");
531 let table = s.tables.get_mut("widgets").unwrap();
532 let rec = StreamRecord {
533 event_id: event_id.into(),
534 event_name: "INSERT".into(),
535 event_version: "1.1".into(),
536 event_source: "aws:dynamodb".into(),
537 aws_region: "us-east-1".into(),
538 event_source_arn: table.stream_arn.clone().unwrap(),
539 timestamp: Utc::now() - chrono::Duration::hours(age_hours),
540 dynamodb: DynamoDbStreamRecord {
541 keys: HashMap::new(),
542 new_image: Some(HashMap::new()),
543 old_image: None,
544 sequence_number: seq.into(),
545 size_bytes: 16,
546 stream_view_type: "NEW_AND_OLD_IMAGES".into(),
547 },
548 user_identity: None,
549 };
550 table.stream_records.write().push(rec);
551 }
552
553 fn trim_front(state: &SharedDynamoDbState, n: usize) {
554 let accts = state.read();
555 let s = accts.get("123456789012").unwrap();
556 let table = s.tables.get("widgets").unwrap();
557 let mut recs = table.stream_records.write();
558 for _ in 0..n {
559 if !recs.is_empty() {
560 recs.remove(0);
561 }
562 }
563 }
564
565 #[tokio::test]
570 async fn iterator_survives_front_trim_without_skip_or_replay() {
571 let state = make_state();
572 let arn = seed_table(&state); {
575 let accts = state.read();
576 let s = accts.get("123456789012").unwrap();
577 s.tables
578 .get("widgets")
579 .unwrap()
580 .stream_records
581 .write()
582 .clear();
583 }
584 for i in 1..=5u64 {
585 let age = if i <= 2 { 30 } else { 0 };
587 push_record(&state, &format!("{i:021}"), age, &format!("e{i}"));
588 }
589 let svc = DynamoDbStreamsService::new(state.clone());
590
591 let it_resp = svc
593 .handle(req(
594 "GetShardIterator",
595 json!({
596 "StreamArn": arn,
597 "ShardId": "shardId-00000000000000000000-00000001",
598 "ShardIteratorType": "TRIM_HORIZON",
599 }),
600 ))
601 .await
602 .unwrap();
603 let it: Value = serde_json::from_slice(it_resp.body.expect_bytes()).unwrap();
604 let iterator = it["ShardIterator"].as_str().unwrap().to_string();
605
606 let r1 = svc
607 .handle(req(
608 "GetRecords",
609 json!({"ShardIterator": iterator, "Limit": 3}),
610 ))
611 .await
612 .unwrap();
613 let b1: Value = serde_json::from_slice(r1.body.expect_bytes()).unwrap();
614 let recs1 = b1["Records"].as_array().unwrap();
615 assert_eq!(recs1.len(), 3);
616 assert_eq!(recs1[0]["eventID"].as_str().unwrap(), "e1");
617 assert_eq!(recs1[2]["eventID"].as_str().unwrap(), "e3");
618 let next = b1["NextShardIterator"].as_str().unwrap().to_string();
619
620 trim_front(&state, 2);
624
625 let r2 = svc
626 .handle(req("GetRecords", json!({"ShardIterator": next})))
627 .await
628 .unwrap();
629 let b2: Value = serde_json::from_slice(r2.body.expect_bytes()).unwrap();
630 let recs2 = b2["Records"].as_array().unwrap();
631 assert_eq!(
632 recs2.len(),
633 2,
634 "must return exactly the un-consumed records after a front trim"
635 );
636 assert_eq!(recs2[0]["eventID"].as_str().unwrap(), "e4");
637 assert_eq!(recs2[1]["eventID"].as_str().unwrap(), "e5");
638 }
639
640 #[tokio::test]
644 async fn get_shard_iterator_absent_sequence_is_trimmed_data_access() {
645 let state = make_state();
646 let arn = seed_table(&state); let svc = DynamoDbStreamsService::new(state);
648 for iter_type in ["AT_SEQUENCE_NUMBER", "AFTER_SEQUENCE_NUMBER"] {
649 let err = svc
650 .handle(req(
651 "GetShardIterator",
652 json!({
653 "StreamArn": arn,
654 "ShardId": "shardId-00000000000000000000-00000001",
655 "ShardIteratorType": iter_type,
656 "SequenceNumber": "999999999",
657 }),
658 ))
659 .await
660 .err()
661 .expect("absent sequence must be rejected");
662 assert!(
663 format!("{err:?}").contains("TrimmedDataAccessException"),
664 "{iter_type}: {err:?}"
665 );
666 }
667 }
668
669 #[tokio::test]
670 async fn describe_stream_unknown_arn_404s() {
671 let state = make_state();
672 let _ = seed_table(&state);
673 let svc = DynamoDbStreamsService::new(state);
674 let err = svc
675 .handle(req(
676 "DescribeStream",
677 json!({"StreamArn": "arn:aws:dynamodb:us-east-1:123456789012:table/missing/stream/x"}),
678 ))
679 .await
680 .err()
681 .expect("expected ResourceNotFound");
682 assert!(format!("{:?}", err).contains("ResourceNotFoundException"));
683 }
684}