#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
pub(crate) fn deserialize<T: serde::de::DeserializeOwned>(body: &str) -> crate::Result<T> {
serde_json::from_str(body).map_err(|e| {
let msg = e.to_string();
if let Some(classified) = classify_marked_serde_error(&msg) {
return classified;
}
if msg.contains("missing field")
|| msg.contains("unknown variant")
|| msg.contains("invalid type: null")
{
crate::DynoxideError::ValidationException(msg)
} else if msg.contains("empty AttributeValue") {
crate::DynoxideError::ValidationException(
"Supplied AttributeValue is empty, must contain exactly one of the supported datatypes".to_string(),
)
} else if msg.contains("Supplied AttributeValue") {
crate::DynoxideError::ValidationException(strip_position(&msg).to_string())
} else {
crate::DynoxideError::SerializationException(map_serde_to_dynamodb_message(&msg, body))
}
})
}
#[cfg(any(
feature = "http-server",
feature = "mcp-server",
feature = "wasm-sqlite",
test
))]
pub(crate) fn classify_marked_serde_error(msg: &str) -> Option<crate::DynoxideError> {
if let Some(stripped) = msg.strip_prefix(REQUEST_VALIDATION_MARKER) {
return Some(crate::DynoxideError::EnvelopedValidation(
strip_position(stripped).to_string(),
));
}
if let Some(stripped) = msg.strip_prefix(VALIDATION_MARKER) {
return Some(crate::DynoxideError::ValidationException(
strip_position(stripped).to_string(),
));
}
None
}
pub(crate) const VALIDATION_MARKER: &str = "VALIDATION:";
pub(crate) const REQUEST_VALIDATION_MARKER: &str = "VALIDATION_REQUEST:";
fn strip_position(msg: &str) -> &str {
if let Some(idx) = msg.rfind(" at line ") {
if msg[idx..].contains("column") {
return &msg[..idx];
}
}
msg
}
pub(crate) fn clean_serde_message(msg: &str) -> &str {
let clean = strip_position(msg);
clean
.strip_prefix(REQUEST_VALIDATION_MARKER)
.or_else(|| clean.strip_prefix(VALIDATION_MARKER))
.unwrap_or(clean)
}
#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
fn map_serde_to_dynamodb_message(msg: &str, body: &str) -> String {
if let Some(rest) = msg.strip_prefix("invalid type: ") {
let (source_part, target_part) = match rest.split_once(", expected ") {
Some((s, t)) => (s, t),
None => return msg.to_string(),
};
let target = target_part
.split(" at line ")
.next()
.unwrap_or(target_part)
.trim();
return map_type_mismatch(source_part.trim(), target);
}
if msg.contains("expected struct") && msg.starts_with("invalid length ") {
if let Some(rest) = msg.split("expected struct ").nth(1) {
let struct_name = rest.split(' ').next().unwrap_or("Unknown");
if let Some(dynamo_class) = map_struct_to_dynamo_class(struct_name) {
return format!("Unrecognized collection type class {dynamo_class}");
}
}
return "Start of structure or map found where not expected".to_string();
}
if msg.starts_with("expected string for ") {
return infer_type_conversion_error(msg, body, "String");
}
if msg.starts_with("expected value at line ") {
return infer_type_conversion_error(msg, body, "String");
}
msg.to_string()
}
#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
fn map_type_mismatch(source: &str, target: &str) -> String {
let target_is_string = target == "a string";
let target_is_bool = target == "a boolean";
let target_is_sequence = target == "a sequence";
let target_is_integer = target == "i64" || target == "u64";
let target_is_struct = target.starts_with("struct ");
let target_is_map = target.starts_with("a map") || target.starts_with("map");
let is_integer = source.starts_with("integer ");
let is_float = source.starts_with("floating point ");
let is_bool_true = source == "boolean `true`";
let is_bool_false = source == "boolean `false`";
let _is_bool = is_bool_true || is_bool_false;
let is_string = source.starts_with("string ");
let is_sequence = source == "sequence";
let is_map = source == "map";
if target_is_sequence {
if is_map {
return "Start of structure or map found where not expected".to_string();
}
return "Unexpected field type".to_string();
}
if target_is_string {
if is_bool_true {
return "TRUE_VALUE cannot be converted to String".to_string();
}
if is_bool_false {
return "FALSE_VALUE cannot be converted to String".to_string();
}
if is_float {
return "DECIMAL_VALUE cannot be converted to String".to_string();
}
if is_integer {
return "NUMBER_VALUE cannot be converted to String".to_string();
}
if is_sequence {
return "Unrecognized collection type class java.lang.String".to_string();
}
if is_map {
return "Start of structure or map found where not expected".to_string();
}
}
if target_is_bool {
if is_string {
return "Unexpected token received from parser".to_string();
}
if is_float {
return "DECIMAL_VALUE cannot be converted to Boolean".to_string();
}
if is_integer {
return "NUMBER_VALUE cannot be converted to Boolean".to_string();
}
if is_sequence {
return "Unrecognized collection type class java.lang.Boolean".to_string();
}
if is_map {
return "Start of structure or map found where not expected".to_string();
}
}
if target_is_integer {
if is_string {
return "STRING_VALUE cannot be converted to Long".to_string();
}
if is_bool_true {
return "TRUE_VALUE cannot be converted to Long".to_string();
}
if is_bool_false {
return "FALSE_VALUE cannot be converted to Long".to_string();
}
if is_sequence {
return "Unrecognized collection type class java.lang.Long".to_string();
}
if is_map {
return "Start of structure or map found where not expected".to_string();
}
}
if target_is_struct || target_is_map {
if is_sequence {
if let Some(struct_name) = target.strip_prefix("struct ") {
let name = struct_name.split(' ').next().unwrap_or("Unknown");
if let Some(dynamo_class) = map_struct_to_dynamo_class(name) {
return format!("Unrecognized collection type class {dynamo_class}");
}
}
}
if is_map && target_is_struct {
return "Start of structure or map found where not expected".to_string();
}
if !is_map && !is_sequence {
return "Unexpected field type".to_string();
}
}
source
.split(" at line ")
.next()
.unwrap_or(source)
.to_string()
}
#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
fn infer_type_conversion_error(msg: &str, body: &str, target_type: &str) -> String {
if let Some(col_str) = msg.rsplit("column ").next() {
if let Ok(col) = col_str.trim().parse::<usize>() {
if col > 0 && col <= body.len() {
let ch = body.as_bytes()[col - 1];
return match ch {
b't' => format!("TRUE_VALUE cannot be converted to {target_type}"),
b'f' => format!("FALSE_VALUE cannot be converted to {target_type}"),
b'0'..=b'9' | b'-' => {
format!("NUMBER_VALUE cannot be converted to {target_type}")
}
_ => format!("TRUE_VALUE cannot be converted to {target_type}"),
};
}
}
}
format!("TRUE_VALUE cannot be converted to {target_type}")
}
#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
fn map_struct_to_dynamo_class(struct_name: &str) -> Option<&'static str> {
match struct_name {
"ProvisionedThroughput" | "ProvisionedThroughputRaw" => {
Some("com.amazonaws.dynamodb.v20120810.ProvisionedThroughput")
}
"Projection" | "ProjectionRaw" => Some("com.amazonaws.dynamodb.v20120810.Projection"),
"KeySchemaElement" | "KeySchemaElementRaw" => {
Some("com.amazonaws.dynamodb.v20120810.KeySchemaElement")
}
"AttributeDefinition" | "AttributeDefinitionRaw" => {
Some("com.amazonaws.dynamodb.v20120810.AttributeDefinition")
}
"LocalSecondaryIndex" | "LocalSecondaryIndexRaw" => {
Some("com.amazonaws.dynamodb.v20120810.LocalSecondaryIndex")
}
"GlobalSecondaryIndex" | "GlobalSecondaryIndexRaw" => {
Some("com.amazonaws.dynamodb.v20120810.GlobalSecondaryIndex")
}
"DeleteGsiAction" | "DeleteGsiActionRaw" => {
Some("com.amazonaws.dynamodb.v20120810.DeleteGlobalSecondaryIndexAction")
}
"CreateGsiAction" | "CreateGsiActionRaw" => {
Some("com.amazonaws.dynamodb.v20120810.CreateGlobalSecondaryIndexAction")
}
"UpdateGsiAction" | "UpdateGsiActionRaw" => {
Some("com.amazonaws.dynamodb.v20120810.UpdateGlobalSecondaryIndexAction")
}
"GlobalSecondaryIndexUpdate" | "GlobalSecondaryIndexUpdateRaw" => {
Some("com.amazonaws.dynamodb.v20120810.GlobalSecondaryIndexUpdate")
}
"Tag" | "TagRaw" => Some("com.amazonaws.dynamodb.v20120810.Tag"),
_ => None,
}
}
#[cfg(feature = "http-server")]
pub(crate) fn serialize<T: serde::Serialize>(val: &T) -> crate::Result<String> {
serde_json::to_string(val).map_err(|e| crate::DynoxideError::InternalServerError(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_request_marker_maps_to_enveloped_validation() {
let err = serde_json::from_str::<crate::types::AttributeValue>(r#"{"NULL": false}"#)
.map_err(|e| e.to_string())
.unwrap_err();
assert!(
err.starts_with("VALIDATION_REQUEST:"),
"marker missing: {err}"
);
let decoded: crate::Result<crate::types::AttributeValue> =
deserialize(r#"{"NULL": false}"#);
match decoded.unwrap_err() {
crate::DynoxideError::EnvelopedValidation(msg) => {
assert_eq!(
msg,
"One or more parameter values were invalid: \
Null attribute value types must have the value of true"
);
}
other => panic!("expected EnvelopedValidation, got {other:?}"),
}
}
#[test]
fn test_bare_marker_maps_to_validation_exception() {
let decoded: crate::Result<crate::types::AttributeValue> =
deserialize(r#"{"S": "a", "N": "1"}"#);
match decoded.unwrap_err() {
crate::DynoxideError::ValidationException(msg) => {
assert_eq!(
msg,
"Supplied AttributeValue has more than one datatypes set, \
must contain exactly one of the supported datatypes"
);
}
other => panic!("expected ValidationException, got {other:?}"),
}
}
#[test]
fn test_clean_serde_message_strips_both_markers_and_position() {
assert_eq!(
clean_serde_message("VALIDATION_REQUEST:msg at line 1 column 42"),
"msg"
);
assert_eq!(
clean_serde_message("VALIDATION:msg at line 1 column 42"),
"msg"
);
assert_eq!(clean_serde_message("VALIDATION:msg"), "msg");
assert_eq!(
clean_serde_message("plain msg at line 3 column 7"),
"plain msg"
);
assert_eq!(clean_serde_message("look at line 9"), "look at line 9");
assert_eq!(clean_serde_message("plain msg"), "plain msg");
}
#[test]
fn test_strip_position_unchanged() {
assert_eq!(strip_position("msg at line 1 column 2"), "msg");
assert_eq!(strip_position("msg"), "msg");
assert_eq!(strip_position("look at line 9"), "look at line 9");
}
}