1use aws_sdk_dynamodb::primitives::Blob;
25use aws_sdk_dynamodb::types::AttributeValue;
26use base64::Engine as _;
27use faucet_core::FaucetError;
28use serde_json::{Map, Number, Value};
29use std::collections::HashMap;
30
31type StreamsAttr = aws_sdk_dynamodbstreams::types::AttributeValue;
32
33#[derive(Debug, PartialEq, Eq)]
36struct Decimal {
37 negative: bool,
38 digits: String,
39 exp: i64,
40}
41
42fn canonical_decimal(raw: &str) -> Option<Decimal> {
43 let s = raw.trim();
44 let (negative, s) = match s.as_bytes().first()? {
45 b'-' => (true, &s[1..]),
46 b'+' => (false, &s[1..]),
47 _ => (false, s),
48 };
49 let (mantissa, exp_part) = match s.find(['e', 'E']) {
50 Some(i) => (&s[..i], Some(&s[i + 1..])),
51 None => (s, None),
52 };
53 let (int_part, frac_part) = match mantissa.find('.') {
54 Some(i) => (&mantissa[..i], &mantissa[i + 1..]),
55 None => (mantissa, ""),
56 };
57 if int_part.is_empty() && frac_part.is_empty() {
58 return None;
59 }
60 if !int_part.bytes().all(|b| b.is_ascii_digit())
61 || !frac_part.bytes().all(|b| b.is_ascii_digit())
62 {
63 return None;
64 }
65 let mut exp: i64 = match exp_part {
66 Some(e) => e.parse().ok()?,
67 None => 0,
68 };
69 exp = exp.checked_sub(frac_part.len() as i64)?;
70 let joined = format!("{int_part}{frac_part}");
71 let trimmed_leading = joined.trim_start_matches('0');
72 let digits = trimmed_leading.trim_end_matches('0');
73 exp = exp.checked_add((trimmed_leading.len() - digits.len()) as i64)?;
74 if digits.is_empty() {
75 return Some(Decimal {
76 negative: false,
77 digits: String::new(),
78 exp: 0,
79 });
80 }
81 Some(Decimal {
82 negative,
83 digits: digits.to_string(),
84 exp,
85 })
86}
87
88pub fn number_to_json(raw: &str) -> Value {
91 let Some(dec) = canonical_decimal(raw) else {
92 return Value::String(raw.to_string());
93 };
94 if dec.digits.is_empty() {
95 return Value::Number(0.into());
96 }
97 if dec.exp >= 0 && dec.digits.len() as i64 + dec.exp <= 20 {
98 let integer = format!("{}{}", dec.digits, "0".repeat(dec.exp as usize));
99 if dec.negative {
100 if let Ok(i) = format!("-{integer}").parse::<i64>() {
101 return Value::Number(i.into());
102 }
103 } else if let Ok(u) = integer.parse::<u64>() {
104 return Value::Number(u.into());
105 }
106 }
107 match faucet_core::fidelity::number_text(raw) {
108 Ok(Value::Number(n)) => Value::Number(n),
109 _ => Value::String(raw.to_string()),
110 }
111}
112
113pub fn attribute_to_json(av: &AttributeValue) -> Result<Value, FaucetError> {
117 Ok(match av {
118 AttributeValue::S(s) => Value::String(s.clone()),
119 AttributeValue::N(n) => number_to_json(n),
120 AttributeValue::Bool(b) => Value::Bool(*b),
121 AttributeValue::Null(_) => Value::Null,
122 AttributeValue::B(b) => b64(b),
123 AttributeValue::Ss(v) => Value::Array(v.iter().cloned().map(Value::String).collect()),
124 AttributeValue::Ns(v) => Value::Array(v.iter().map(|n| number_to_json(n)).collect()),
125 AttributeValue::Bs(v) => Value::Array(v.iter().map(b64).collect()),
126 AttributeValue::L(v) => Value::Array(
127 v.iter()
128 .map(attribute_to_json)
129 .collect::<Result<Vec<_>, _>>()?,
130 ),
131 AttributeValue::M(m) => item_to_json(m)?,
132 other => {
133 return Err(FaucetError::Source(format!(
134 "dynamodb: unsupported attribute value type {other:?}"
135 )));
136 }
137 })
138}
139
140pub fn item_to_json(item: &HashMap<String, AttributeValue>) -> Result<Value, FaucetError> {
142 let mut obj = Map::with_capacity(item.len());
143 for (k, v) in item {
144 obj.insert(k.clone(), attribute_to_json(v)?);
145 }
146 Ok(Value::Object(obj))
147}
148
149fn json_number_to_n(n: &Number) -> String {
151 if let Some(i) = n.as_i64() {
152 i.to_string()
153 } else if let Some(u) = n.as_u64() {
154 u.to_string()
155 } else {
156 n.to_string()
158 }
159}
160
161pub fn json_to_attribute(v: &Value) -> AttributeValue {
163 match v {
164 Value::Null => AttributeValue::Null(true),
165 Value::Bool(b) => AttributeValue::Bool(*b),
166 Value::Number(n) => AttributeValue::N(json_number_to_n(n)),
167 Value::String(s) => AttributeValue::S(s.clone()),
168 Value::Array(a) => AttributeValue::L(a.iter().map(json_to_attribute).collect()),
169 Value::Object(o) => AttributeValue::M(
170 o.iter()
171 .map(|(k, v)| (k.clone(), json_to_attribute(v)))
172 .collect(),
173 ),
174 }
175}
176
177pub fn json_to_key_attribute(
182 name: &str,
183 v: &Value,
184 scalar: crate::table::ScalarType,
185) -> Result<AttributeValue, String> {
186 use crate::table::ScalarType;
187 match (scalar, v) {
188 (ScalarType::String, Value::String(s)) => Ok(AttributeValue::S(s.clone())),
189 (ScalarType::Number, Value::Number(n)) => Ok(AttributeValue::N(json_number_to_n(n))),
190 (ScalarType::Number, Value::String(s)) if canonical_decimal(s).is_some() => {
191 Ok(AttributeValue::N(s.trim().to_string()))
192 }
193 (ScalarType::Binary, Value::String(s)) => base64::engine::general_purpose::STANDARD
194 .decode(s)
195 .map(|b| AttributeValue::B(Blob::new(b)))
196 .map_err(|e| {
197 format!("key attribute '{name}' is a binary (B) key but is not base64: {e}")
198 }),
199 (scalar, other) => Err(format!(
200 "key attribute '{name}' must be {} for the table's {} key, got {}",
201 match scalar {
202 ScalarType::String => "a string",
203 ScalarType::Number => "a number or a decimal string",
204 ScalarType::Binary => "a base64 string",
205 },
206 match scalar {
207 ScalarType::String => "S",
208 ScalarType::Number => "N",
209 ScalarType::Binary => "B",
210 },
211 json_kind(other)
212 )),
213 }
214}
215
216pub fn json_to_item(v: &Value) -> Result<HashMap<String, AttributeValue>, FaucetError> {
218 match v {
219 Value::Object(o) => Ok(o
220 .iter()
221 .map(|(k, v)| (k.clone(), json_to_attribute(v)))
222 .collect()),
223 other => Err(FaucetError::Sink(format!(
224 "dynamodb: record must be a JSON object, got {}",
225 json_kind(other)
226 ))),
227 }
228}
229
230fn json_kind(v: &Value) -> &'static str {
231 match v {
232 Value::Null => "null",
233 Value::Bool(_) => "boolean",
234 Value::Number(_) => "number",
235 Value::String(_) => "string",
236 Value::Array(_) => "array",
237 Value::Object(_) => "object",
238 }
239}
240
241pub fn streams_attribute_to_dynamodb(av: &StreamsAttr) -> Result<AttributeValue, FaucetError> {
244 Ok(match av {
245 StreamsAttr::S(s) => AttributeValue::S(s.clone()),
246 StreamsAttr::N(n) => AttributeValue::N(n.clone()),
247 StreamsAttr::Bool(b) => AttributeValue::Bool(*b),
248 StreamsAttr::Null(b) => AttributeValue::Null(*b),
249 StreamsAttr::B(b) => AttributeValue::B(Blob::new(b.as_ref().to_vec())),
250 StreamsAttr::Ss(v) => AttributeValue::Ss(v.clone()),
251 StreamsAttr::Ns(v) => AttributeValue::Ns(v.clone()),
252 StreamsAttr::Bs(v) => {
253 AttributeValue::Bs(v.iter().map(|b| Blob::new(b.as_ref().to_vec())).collect())
254 }
255 StreamsAttr::L(v) => AttributeValue::L(
256 v.iter()
257 .map(streams_attribute_to_dynamodb)
258 .collect::<Result<Vec<_>, _>>()?,
259 ),
260 StreamsAttr::M(m) => AttributeValue::M(
261 m.iter()
262 .map(|(k, v)| Ok((k.clone(), streams_attribute_to_dynamodb(v)?)))
263 .collect::<Result<HashMap<_, _>, FaucetError>>()?,
264 ),
265 other => {
266 return Err(FaucetError::Source(format!(
267 "dynamodb streams: unsupported attribute value type {other:?}"
268 )));
269 }
270 })
271}
272
273pub fn streams_item_to_json(item: &HashMap<String, StreamsAttr>) -> Result<Value, FaucetError> {
275 let mut obj = Map::with_capacity(item.len());
276 for (k, v) in item {
277 obj.insert(
278 k.clone(),
279 attribute_to_json(&streams_attribute_to_dynamodb(v)?)?,
280 );
281 }
282 Ok(Value::Object(obj))
283}
284
285fn b64(b: &Blob) -> Value {
286 Value::String(base64::engine::general_purpose::STANDARD.encode(b.as_ref()))
287}
288
289fn unb64(v: &Value) -> Result<Blob, FaucetError> {
290 let s = v
291 .as_str()
292 .ok_or_else(|| FaucetError::Source("dynamodb: typed B value must be a string".into()))?;
293 base64::engine::general_purpose::STANDARD
294 .decode(s)
295 .map(Blob::new)
296 .map_err(|e| FaucetError::Source(format!("dynamodb: invalid base64 in typed B value: {e}")))
297}
298
299fn attribute_to_typed(av: &AttributeValue) -> Value {
300 use serde_json::json;
301 match av {
302 AttributeValue::S(s) => json!({ "S": s }),
303 AttributeValue::N(n) => json!({ "N": n }),
304 AttributeValue::B(b) => json!({ "B": b64(b) }),
305 AttributeValue::Bool(b) => json!({ "BOOL": b }),
306 AttributeValue::Null(b) => json!({ "NULL": b }),
307 AttributeValue::Ss(v) => json!({ "SS": v }),
308 AttributeValue::Ns(v) => json!({ "NS": v }),
309 AttributeValue::Bs(v) => json!({ "BS": v.iter().map(b64).collect::<Vec<_>>() }),
310 AttributeValue::L(v) => {
311 json!({ "L": v.iter().map(attribute_to_typed).collect::<Vec<_>>() })
312 }
313 AttributeValue::M(m) => json!({ "M": item_to_typed_json(m) }),
314 _ => Value::Null,
315 }
316}
317
318pub fn item_to_typed_json(item: &HashMap<String, AttributeValue>) -> Value {
321 Value::Object(
322 item.iter()
323 .map(|(k, v)| (k.clone(), attribute_to_typed(v)))
324 .collect(),
325 )
326}
327
328fn strings(v: &Value) -> Result<Vec<String>, FaucetError> {
329 v.as_array()
330 .and_then(|a| a.iter().map(|s| s.as_str().map(str::to_string)).collect())
331 .ok_or_else(|| {
332 FaucetError::Source("dynamodb: typed set must be an array of strings".into())
333 })
334}
335
336fn typed_to_attribute(v: &Value) -> Result<AttributeValue, FaucetError> {
337 let bad = || FaucetError::Source(format!("dynamodb: malformed typed attribute value {v}"));
338 let obj = v.as_object().filter(|o| o.len() == 1).ok_or_else(bad)?;
339 let (tag, inner) = obj.iter().next().ok_or_else(bad)?;
340 Ok(match tag.as_str() {
341 "S" => AttributeValue::S(inner.as_str().ok_or_else(bad)?.to_string()),
342 "N" => AttributeValue::N(inner.as_str().ok_or_else(bad)?.to_string()),
343 "B" => AttributeValue::B(unb64(inner)?),
344 "BOOL" => AttributeValue::Bool(inner.as_bool().ok_or_else(bad)?),
345 "NULL" => AttributeValue::Null(inner.as_bool().ok_or_else(bad)?),
346 "SS" => AttributeValue::Ss(strings(inner)?),
347 "NS" => AttributeValue::Ns(strings(inner)?),
348 "BS" => AttributeValue::Bs(
349 inner
350 .as_array()
351 .ok_or_else(bad)?
352 .iter()
353 .map(unb64)
354 .collect::<Result<_, _>>()?,
355 ),
356 "L" => AttributeValue::L(
357 inner
358 .as_array()
359 .ok_or_else(bad)?
360 .iter()
361 .map(typed_to_attribute)
362 .collect::<Result<_, _>>()?,
363 ),
364 "M" => AttributeValue::M(typed_json_to_item(inner)?),
365 _ => return Err(bad()),
366 })
367}
368
369pub fn typed_json_to_item(v: &Value) -> Result<HashMap<String, AttributeValue>, FaucetError> {
372 let obj = v.as_object().ok_or_else(|| {
373 FaucetError::Source(format!("dynamodb: typed item must be an object, got {v}"))
374 })?;
375 obj.iter()
376 .map(|(k, v)| Ok((k.clone(), typed_to_attribute(v)?)))
377 .collect()
378}
379
380#[derive(
385 Debug,
386 Clone,
387 Copy,
388 Default,
389 PartialEq,
390 Eq,
391 serde::Serialize,
392 serde::Deserialize,
393 schemars::JsonSchema,
394)]
395#[serde(rename_all = "snake_case")]
396pub enum ValueFormat {
397 #[default]
400 Plain,
401 Typed,
403}
404
405impl ValueFormat {
406 pub fn item_to_json(
408 self,
409 item: &HashMap<String, AttributeValue>,
410 ) -> Result<Value, FaucetError> {
411 match self {
412 ValueFormat::Plain => item_to_json(item),
413 ValueFormat::Typed => try_item_to_typed_json(item),
414 }
415 }
416
417 pub fn streams_item_to_json(
419 self,
420 item: &HashMap<String, StreamsAttr>,
421 ) -> Result<Value, FaucetError> {
422 match self {
423 ValueFormat::Plain => streams_item_to_json(item),
424 ValueFormat::Typed => streams_item_to_typed_json(item),
425 }
426 }
427}
428
429pub fn try_item_to_typed_json(
432 item: &HashMap<String, AttributeValue>,
433) -> Result<Value, FaucetError> {
434 fn check(av: &AttributeValue) -> Result<(), FaucetError> {
435 match av {
436 AttributeValue::L(v) => v.iter().try_for_each(check),
437 AttributeValue::M(m) => m.values().try_for_each(check),
438 AttributeValue::S(_)
439 | AttributeValue::N(_)
440 | AttributeValue::B(_)
441 | AttributeValue::Bool(_)
442 | AttributeValue::Null(_)
443 | AttributeValue::Ss(_)
444 | AttributeValue::Ns(_)
445 | AttributeValue::Bs(_) => Ok(()),
446 other => Err(FaucetError::Source(format!(
447 "dynamodb: unsupported attribute value type {other:?}"
448 ))),
449 }
450 }
451 item.values().try_for_each(check)?;
452 Ok(item_to_typed_json(item))
453}
454
455pub fn streams_item_to_typed_json(
457 item: &HashMap<String, StreamsAttr>,
458) -> Result<Value, FaucetError> {
459 let converted = item
460 .iter()
461 .map(|(k, v)| Ok((k.clone(), streams_attribute_to_dynamodb(v)?)))
462 .collect::<Result<HashMap<_, _>, FaucetError>>()?;
463 Ok(item_to_typed_json(&converted))
464}
465
466pub fn typed_json_to_attribute(v: &Value) -> Result<AttributeValue, FaucetError> {
468 typed_to_attribute(v)
469}
470
471fn number_size(n: &str) -> usize {
474 let digits = n.bytes().filter(u8::is_ascii_digit).count();
475 (digits.div_ceil(2) + 1).min(21)
476}
477
478fn attribute_size(av: &AttributeValue) -> usize {
479 match av {
480 AttributeValue::S(s) => s.len(),
481 AttributeValue::N(n) => number_size(n),
482 AttributeValue::B(b) => b.as_ref().len(),
483 AttributeValue::Bool(_) | AttributeValue::Null(_) => 1,
484 AttributeValue::Ss(v) => v.iter().map(String::len).sum(),
485 AttributeValue::Ns(v) => v.iter().map(|n| number_size(n)).sum(),
486 AttributeValue::Bs(v) => v.iter().map(|b| b.as_ref().len()).sum(),
487 AttributeValue::L(v) => 3 + v.iter().map(|a| 1 + attribute_size(a)).sum::<usize>(),
488 AttributeValue::M(m) => {
489 3 + m
490 .iter()
491 .map(|(k, a)| 1 + k.len() + attribute_size(a))
492 .sum::<usize>()
493 }
494 _ => 0,
495 }
496}
497
498pub fn item_size(item: &HashMap<String, AttributeValue>) -> usize {
502 item.iter().map(|(k, v)| k.len() + attribute_size(v)).sum()
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use serde_json::json;
509
510 #[test]
511 fn numbers_become_json_numbers_only_when_exact() {
512 assert_eq!(number_to_json("42"), json!(42));
513 assert_eq!(number_to_json("-42"), json!(-42));
514 assert_eq!(number_to_json("+7"), json!(7));
515 assert_eq!(number_to_json("0"), json!(0));
516 assert_eq!(number_to_json("-0.000"), json!(0));
517 assert_eq!(number_to_json("1.50"), json!(1.5));
518 assert_eq!(number_to_json("1.0"), json!(1));
519 assert_eq!(number_to_json("1E+3"), json!(1000));
520 assert_eq!(number_to_json("2.5e-3"), json!(0.0025));
521 assert_eq!(number_to_json("0.1"), json!(0.1));
522 assert_eq!(number_to_json("18446744073709551615"), json!(u64::MAX));
523 assert_eq!(number_to_json("-9223372036854775808"), json!(i64::MIN));
524 assert_eq!(
526 number_to_json("12345678901234567890123"),
527 json!("12345678901234567890123")
528 );
529 assert_eq!(
530 number_to_json("-9223372036854775809"),
531 json!("-9223372036854775809")
532 );
533 assert_eq!(
534 number_to_json("3.14159265358979323846264338327950288"),
535 json!("3.14159265358979323846264338327950288")
536 );
537 assert_eq!(number_to_json("1E+30"), json!(1e30));
539 assert_eq!(number_to_json("abc"), json!("abc"));
541 assert_eq!(number_to_json(""), json!(""));
542 assert_eq!(number_to_json("."), json!("."));
543 assert_eq!(number_to_json("1.2.3"), json!("1.2.3"));
544 assert_eq!(number_to_json("1ex"), json!("1ex"));
545 assert_eq!(
546 number_to_json("1e99999999999999999999"),
547 json!("1e99999999999999999999")
548 );
549 }
550
551 #[test]
552 fn key_attributes_follow_the_declared_type() {
553 use crate::table::ScalarType;
554 assert_eq!(
555 json_to_key_attribute("k", &json!("a"), ScalarType::String).unwrap(),
556 AttributeValue::S("a".into())
557 );
558 assert_eq!(
559 json_to_key_attribute("k", &json!(7), ScalarType::Number).unwrap(),
560 AttributeValue::N("7".into())
561 );
562 let big = "123456789012345678901234567890.000000001";
563 assert_eq!(
564 json_to_key_attribute("k", &json!(big), ScalarType::Number).unwrap(),
565 AttributeValue::N(big.into())
566 );
567 let bin = json_to_key_attribute("k", &json!("AAEC"), ScalarType::Binary).unwrap();
568 assert_eq!(bin, AttributeValue::B(Blob::new(vec![0u8, 1, 2])));
569 let raw = AttributeValue::B(Blob::new(vec![0u8, 1, 2]));
570 assert_eq!(
571 json_to_key_attribute("k", &attribute_to_json(&raw).unwrap(), ScalarType::Binary)
572 .unwrap(),
573 raw
574 );
575 let e = json_to_key_attribute("k", &json!("@@"), ScalarType::Binary).unwrap_err();
576 assert!(e.contains("not base64"), "{e}");
577 let e = json_to_key_attribute("k", &json!("x1"), ScalarType::Number).unwrap_err();
578 assert!(e.contains("table's N key") && e.contains("string"), "{e}");
579 let e = json_to_key_attribute("k", &json!(1), ScalarType::String).unwrap_err();
580 assert!(
581 e.contains("a string for the table's S key, got number"),
582 "{e}"
583 );
584 let e = json_to_key_attribute("k", &json!(true), ScalarType::Binary).unwrap_err();
585 assert!(e.contains("base64 string"), "{e}");
586 }
587
588 #[test]
589 fn canonical_decimal_normalizes() {
590 let d = canonical_decimal("001.2300e2").unwrap();
591 assert_eq!((d.negative, d.digits.as_str(), d.exp), (false, "123", 0));
592 let d = canonical_decimal("-.5").unwrap();
593 assert_eq!((d.negative, d.digits.as_str(), d.exp), (true, "5", -1));
594 let d = canonical_decimal("5.").unwrap();
595 assert_eq!((d.digits.as_str(), d.exp), ("5", 0));
596 assert!(canonical_decimal("-").is_none());
597 }
598
599 #[test]
600 fn attribute_to_json_covers_every_type() {
601 let mut inner = HashMap::new();
602 inner.insert(
603 "n".to_string(),
604 AttributeValue::N("99999999999999999999999".into()),
605 );
606 let item: HashMap<String, AttributeValue> = [
607 ("s", AttributeValue::S("x".into())),
608 ("n", AttributeValue::N("3".into())),
609 ("b", AttributeValue::Bool(true)),
610 ("nul", AttributeValue::Null(true)),
611 ("bin", AttributeValue::B(Blob::new(vec![1, 2, 3]))),
612 ("ss", AttributeValue::Ss(vec!["a".into(), "b".into()])),
613 ("ns", AttributeValue::Ns(vec!["1".into(), "1.5".into()])),
614 ("bs", AttributeValue::Bs(vec![Blob::new(vec![0xff])])),
615 (
616 "l",
617 AttributeValue::L(vec![
618 AttributeValue::S("y".into()),
619 AttributeValue::N("2".into()),
620 ]),
621 ),
622 ("m", AttributeValue::M(inner)),
623 ]
624 .into_iter()
625 .map(|(k, v)| (k.to_string(), v))
626 .collect();
627 let v = item_to_json(&item).unwrap();
628 assert_eq!(
629 v,
630 json!({
631 "s": "x", "n": 3, "b": true, "nul": null, "bin": "AQID",
632 "ss": ["a", "b"], "ns": [1, 1.5], "bs": ["/w=="],
633 "l": ["y", 2], "m": {"n": "99999999999999999999999"}
634 })
635 );
636 }
637
638 #[test]
639 fn json_round_trips_through_attributes() {
640 let rec = json!({
641 "id": "a", "n": 7, "neg": -3, "big": 18446744073709551615u64, "f": 2.5,
642 "flag": false, "none": null, "list": [1, "x", {"deep": true}], "obj": {"k": "v"}
643 });
644 let item = json_to_item(&rec).unwrap();
645 assert!(matches!(&item["n"], AttributeValue::N(n) if n == "7"));
646 assert!(matches!(&item["big"], AttributeValue::N(n) if n == "18446744073709551615"));
647 assert!(matches!(&item["f"], AttributeValue::N(n) if n == "2.5"));
648 assert!(matches!(&item["none"], AttributeValue::Null(true)));
649 assert_eq!(item_to_json(&item).unwrap(), rec);
650 let err = json_to_item(&json!([1])).unwrap_err().to_string();
651 assert!(err.contains("array"), "{err}");
652 for (v, kind) in [
653 (json!(null), "null"),
654 (json!(true), "boolean"),
655 (json!(1), "number"),
656 (json!("s"), "string"),
657 ] {
658 assert!(json_to_item(&v).unwrap_err().to_string().contains(kind));
659 }
660 assert_eq!(json_kind(&json!({})), "object");
661 }
662
663 #[test]
664 fn streams_attributes_convert() {
665 let mut m = HashMap::new();
666 m.insert("x".to_string(), StreamsAttr::N("1".into()));
667 let image: HashMap<String, StreamsAttr> = [
668 ("s", StreamsAttr::S("v".into())),
669 ("n", StreamsAttr::N("12345678901234567890123".into())),
670 ("b", StreamsAttr::Bool(false)),
671 ("z", StreamsAttr::Null(true)),
672 (
673 "bin",
674 StreamsAttr::B(aws_sdk_dynamodbstreams::primitives::Blob::new(vec![1])),
675 ),
676 ("ss", StreamsAttr::Ss(vec!["a".into()])),
677 ("ns", StreamsAttr::Ns(vec!["2".into()])),
678 (
679 "bs",
680 StreamsAttr::Bs(vec![aws_sdk_dynamodbstreams::primitives::Blob::new(vec![
681 2,
682 ])]),
683 ),
684 ("l", StreamsAttr::L(vec![StreamsAttr::S("q".into())])),
685 ("m", StreamsAttr::M(m)),
686 ]
687 .into_iter()
688 .map(|(k, v)| (k.to_string(), v))
689 .collect();
690 let v = streams_item_to_json(&image).unwrap();
691 assert_eq!(
692 v,
693 json!({
694 "s": "v", "n": "12345678901234567890123", "b": false, "z": null,
695 "bin": "AQ==", "ss": ["a"], "ns": [2], "bs": ["Ag=="], "l": ["q"], "m": {"x": 1}
696 })
697 );
698 }
699
700 #[test]
701 fn typed_json_round_trips_losslessly() {
702 let mut inner = HashMap::new();
703 inner.insert("q".to_string(), AttributeValue::Bool(true));
704 let item: HashMap<String, AttributeValue> = [
705 ("s", AttributeValue::S("x".into())),
706 (
707 "n",
708 AttributeValue::N("123456789012345678901234567890".into()),
709 ),
710 ("b", AttributeValue::B(Blob::new(vec![9, 8]))),
711 ("t", AttributeValue::Bool(false)),
712 ("z", AttributeValue::Null(true)),
713 ("ss", AttributeValue::Ss(vec!["a".into()])),
714 ("ns", AttributeValue::Ns(vec!["1".into()])),
715 ("bs", AttributeValue::Bs(vec![Blob::new(vec![1])])),
716 ("l", AttributeValue::L(vec![AttributeValue::N("2".into())])),
717 ("m", AttributeValue::M(inner)),
718 ]
719 .into_iter()
720 .map(|(k, v)| (k.to_string(), v))
721 .collect();
722 let typed = item_to_typed_json(&item);
723 assert_eq!(typed["n"], json!({"N": "123456789012345678901234567890"}));
724 assert_eq!(typed["b"], json!({"B": "CQg="}));
725 assert_eq!(typed_json_to_item(&typed).unwrap(), item);
726 }
727
728 #[test]
729 fn typed_json_rejects_malformed_input() {
730 for bad in [
731 json!([1]),
732 json!({"a": "plain"}),
733 json!({"a": {"S": 1}}),
734 json!({"a": {"N": 1}}),
735 json!({"a": {"B": 1}}),
736 json!({"a": {"B": "!!"}}),
737 json!({"a": {"BOOL": "x"}}),
738 json!({"a": {"NULL": 1}}),
739 json!({"a": {"SS": [1]}}),
740 json!({"a": {"BS": "x"}}),
741 json!({"a": {"L": "x"}}),
742 json!({"a": {"M": []}}),
743 json!({"a": {"X": 1}}),
744 json!({"a": {"S": "x", "N": "1"}}),
745 ] {
746 assert!(typed_json_to_item(&bad).is_err(), "{bad}");
747 }
748 }
749
750 #[test]
751 fn item_size_follows_sizing_rules() {
752 let item = json_to_item(&json!({
753 "pk": "abcd", "n": 12345, "flag": true, "l": ["ab"], "m": {"k": "v"}
754 }))
755 .unwrap();
756 assert_eq!(item_size(&item), 6 + 5 + 5 + 7 + 7);
758 let sets: HashMap<String, AttributeValue> = [
759 ("a", AttributeValue::Ss(vec!["xy".into(), "z".into()])),
760 ("b", AttributeValue::Ns(vec!["1".into(), "22".into()])),
761 ("c", AttributeValue::Bs(vec![Blob::new(vec![0; 4])])),
762 ("d", AttributeValue::B(Blob::new(vec![0; 10]))),
763 ("e", AttributeValue::Null(true)),
764 ]
765 .into_iter()
766 .map(|(k, v)| (k.to_string(), v))
767 .collect();
768 assert_eq!(
769 item_size(&sets),
770 (1 + 3) + (1 + 4) + (1 + 4) + (1 + 10) + (1 + 1)
771 );
772 assert_eq!(number_size(&"9".repeat(60)), 21);
773 }
774
775 #[test]
776 fn value_format_selects_plain_or_typed() {
777 let item: HashMap<String, AttributeValue> = [
778 ("ss".to_string(), AttributeValue::Ss(vec!["a".into()])),
779 ("n".to_string(), AttributeValue::N("1.50".into())),
780 (
781 "l".to_string(),
782 AttributeValue::L(vec![AttributeValue::M(HashMap::from([(
783 "b".to_string(),
784 AttributeValue::B(Blob::new(vec![1])),
785 )]))]),
786 ),
787 ]
788 .into_iter()
789 .collect();
790 assert_eq!(
791 ValueFormat::Plain.item_to_json(&item).unwrap(),
792 json!({"ss": ["a"], "n": 1.5, "l": [{"b": "AQ=="}]})
793 );
794 let typed = ValueFormat::Typed.item_to_json(&item).unwrap();
795 assert_eq!(
796 typed,
797 json!({"ss": {"SS": ["a"]}, "n": {"N": "1.50"}, "l": {"L": [{"M": {"b": {"B": "AQ=="}}}]}})
798 );
799 assert_eq!(typed_json_to_item(&typed).unwrap(), item);
800 assert_eq!(
801 typed_json_to_attribute(&json!({"NS": ["1", "2"]})).unwrap(),
802 AttributeValue::Ns(vec!["1".into(), "2".into()])
803 );
804 assert!(typed_json_to_attribute(&json!("plain")).is_err());
805 assert_eq!(ValueFormat::default(), ValueFormat::Plain);
806 let f: ValueFormat = serde_json::from_value(json!("typed")).unwrap();
807 assert_eq!(f, ValueFormat::Typed);
808 }
809
810 #[test]
811 fn streams_images_convert_to_typed_json() {
812 let image: HashMap<String, StreamsAttr> = [
813 (
814 "ns",
815 StreamsAttr::Ns(vec!["12345678901234567890123".into()]),
816 ),
817 (
818 "bs",
819 StreamsAttr::Bs(vec![aws_sdk_dynamodbstreams::primitives::Blob::new(vec![
820 2,
821 ])]),
822 ),
823 ]
824 .into_iter()
825 .map(|(k, v)| (k.to_string(), v))
826 .collect();
827 assert_eq!(
828 ValueFormat::Typed.streams_item_to_json(&image).unwrap(),
829 json!({"ns": {"NS": ["12345678901234567890123"]}, "bs": {"BS": ["Ag=="]}})
830 );
831 assert_eq!(
832 ValueFormat::Plain.streams_item_to_json(&image).unwrap()["bs"],
833 json!(["Ag=="])
834 );
835 }
836}