use crate::errors::{DynoxideError, Result};
use crate::types::{
AttributeDefinition, AttributeValue, GlobalSecondaryIndex, Item, KeySchemaElement, KeyType,
ScalarAttributeType,
};
#[derive(Copy, Clone, Debug)]
pub enum TableNameContext {
CreateTable,
ReadWrite,
}
pub fn validate_table_name(name: &str) -> Result<()> {
let errors = table_name_constraint_errors(Some(name), TableNameContext::ReadWrite);
if errors.is_empty() {
return Ok(());
}
let count = errors.len();
let msg = format!(
"{count} validation error{} detected: {}",
if count == 1 { "" } else { "s" },
errors.join("; ")
);
Err(DynoxideError::ValidationException(msg))
}
pub fn table_name_constraint_errors(
table_name: Option<&str>,
context: TableNameContext,
) -> Vec<String> {
let mut errors = Vec::new();
match table_name {
None => {
errors.push(
"Value null at 'tableName' failed to satisfy constraint: \
Member must not be null"
.to_string(),
);
}
Some(name) => match context {
TableNameContext::CreateTable => {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
{
errors.push(format!(
"Value '{}' at 'tableName' failed to satisfy constraint: \
Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
name
));
}
if name.len() < 3 {
errors.push(format!(
"Value '{}' at 'tableName' failed to satisfy constraint: \
Member must have length greater than or equal to 3",
name
));
}
if name.len() > 255 {
errors.push(format!(
"Value '{}' at 'tableName' failed to satisfy constraint: \
Member must have length less than or equal to 255",
name
));
}
}
TableNameContext::ReadWrite => {
if name.is_empty() {
errors.push(
"Value '' at 'tableName' failed to satisfy constraint: \
Member must have length greater than or equal to 1"
.to_string(),
);
} else if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
{
errors.push(format!(
"Value '{}' at 'tableName' failed to satisfy constraint: \
Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
name
));
}
if name.len() > 255 {
errors.push(format!(
"Value '{}' at 'tableName' failed to satisfy constraint: \
Member must have length less than or equal to 255",
name
));
}
}
},
}
errors
}
pub(crate) const EMPTY_TABLE_NAME_REJECTION: &str = "Value at 'TableName' failed to satisfy \
constraint: Member must have length greater than or equal to 1";
fn short_table_name_error(name: &str) -> String {
format!(
"Value '{name}' at 'tableName' failed to satisfy constraint: \
Member must have length greater than or equal to 3"
)
}
fn long_table_name_error(name: &str) -> String {
format!(
"Value '{name}' at 'tableName' failed to satisfy constraint: \
Member must have length less than or equal to 255"
)
}
fn table_name_pattern_error(name: &str) -> String {
format!(
"Value '{name}' at 'tableName' failed to satisfy constraint: \
Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+"
)
}
pub(crate) fn first_table_name_error(name: &str) -> Option<String> {
if name.len() < 3 {
Some(short_table_name_error(name))
} else if name.len() > 255 {
Some(long_table_name_error(name))
} else if !table_name_pattern_holds(name) {
Some(table_name_pattern_error(name))
} else {
None
}
}
pub(crate) fn item_table_name_errors(table_name: Option<&str>) -> Vec<String> {
match table_name {
None => table_name_constraint_errors(None, TableNameContext::ReadWrite),
Some("") => vec![EMPTY_TABLE_NAME_REJECTION.to_string()],
Some(name) => first_table_name_error(name).into_iter().collect(),
}
}
pub(crate) fn validate_item_table_name(name: &str) -> Result<()> {
match format_validation_errors(&item_table_name_errors(Some(name))) {
Some(msg) => Err(DynoxideError::ValidationException(msg)),
None => Ok(()),
}
}
pub(crate) fn update_item_table_name_errors(table_name: Option<&str>) -> Vec<String> {
let name = match table_name {
None => return table_name_constraint_errors(None, TableNameContext::ReadWrite),
Some("") => return vec![EMPTY_TABLE_NAME_REJECTION.to_string()],
Some(name) => name,
};
let mut errors = Vec::new();
if !table_name_pattern_holds(name) {
errors.push(table_name_pattern_error(name));
}
if name.len() < 3 {
errors.push(short_table_name_error(name));
} else if name.len() > 255 {
errors.push(long_table_name_error(name));
}
errors
}
pub(crate) fn scan_table_name_errors(name: &str) -> Vec<String> {
if name.is_empty() {
return table_name_constraint_errors(Some(name), TableNameContext::ReadWrite);
}
let mut errors = Vec::new();
if name.len() < 3 {
errors.push(short_table_name_error(name));
} else if name.len() > 255 {
errors.push(long_table_name_error(name));
}
if !table_name_pattern_holds(name) {
errors.push(table_name_pattern_error(name));
}
errors
}
pub(crate) fn validate_scan_table_name(name: &str) -> Result<()> {
match format_validation_errors(&scan_table_name_errors(name)) {
Some(msg) => Err(DynoxideError::ValidationException(msg)),
None => Ok(()),
}
}
pub(crate) fn return_values_on_condition_check_failure_rejection(
value: Option<&str>,
path: &str,
) -> Option<String> {
let value = value?;
if ["ALL_OLD", "NONE"].contains(&value) {
return None;
}
Some(format!(
"Value '{value}' at '{path}' failed to satisfy constraint: \
Member must satisfy enum value set: [ALL_OLD, NONE]"
))
}
pub(crate) fn conditional_operator_misuse(
operator: Option<&str>,
conditions: usize,
) -> Option<&'static str> {
let operator = operator?;
match conditions {
0 => Some("ConditionalOperator cannot be used without Filter or Expected"),
1 => Some(
"ConditionalOperator can only be used when Filter or Expected has two or more \
elements",
),
_ if !["AND", "OR"].contains(&operator) => {
Some("Failed to satisfy constraint: Member must satisfy enum value set: [ALL, OR]")
}
_ => None,
}
}
pub(crate) fn legacy_condition_count(conditions: Option<&serde_json::Value>) -> usize {
conditions
.and_then(|value| value.as_object())
.map_or(0, |object| object.len())
}
pub fn format_validation_errors(errors: &[String]) -> Option<String> {
if errors.is_empty() {
return None;
}
if let [single] = errors {
return Some(envelope_message(single));
}
Some(format!(
"{} validation errors detected: {}",
errors.len(),
errors.join("; ")
))
}
pub(crate) fn envelope_message(msg: &str) -> String {
format!("1 validation error detected: {msg}")
}
pub(crate) fn return_consumed_capacity_rejection(mode: Option<&str>) -> Option<String> {
let mode = mode?;
if ["INDEXES", "TOTAL", "NONE"].contains(&mode) {
return None;
}
Some(format!(
"Value '{mode}' at 'returnConsumedCapacity' failed to satisfy constraint: \
Member must satisfy enum value set: [INDEXES, TOTAL, NONE]"
))
}
#[cfg(feature = "mcp-server")]
pub(crate) fn reject_bad_capacity_mode(mode: Option<&str>) -> Result<()> {
match return_consumed_capacity_rejection(mode) {
Some(msg) => Err(DynoxideError::ValidationException(envelope_message(&msg))),
None => Ok(()),
}
}
pub(crate) const RETURN_VALUES_REJECTION: &str = "Failed to satisfy constraint: Member must \
satisfy enum value set: [ALL_OLD, UPDATED_OLD, ALL_NEW, UPDATE_NEW, NONE]";
pub(crate) fn return_values_value_rejection(value: &str) -> String {
format!(
"Value '{value}' at 'returnValues' failed to satisfy constraint: \
Member must satisfy enum value set: [ALL_NEW, UPDATED_OLD, ALL_OLD, NONE, UPDATED_NEW]"
)
}
pub(crate) fn conditional_operator_rejection(operator: Option<&str>) -> Option<String> {
let operator = operator?;
if ["OR", "AND"].contains(&operator) {
return None;
}
Some(format!(
"Value '{operator}' at 'conditionalOperator' failed to satisfy constraint: \
Member must satisfy enum value set: [OR, AND]"
))
}
pub(crate) fn return_item_collection_metrics_rejection(mode: Option<&str>) -> Option<String> {
let mode = mode?;
if ["SIZE", "NONE"].contains(&mode) {
return None;
}
Some(format!(
"Value '{mode}' at 'returnItemCollectionMetrics' failed to satisfy constraint: \
Member must satisfy enum value set: [SIZE, NONE]"
))
}
fn table_name_pattern_holds(name: &str) -> bool {
!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
}
pub(crate) fn member_table_name_is_valid(name: &str) -> bool {
(3..=255).contains(&name.len()) && table_name_pattern_holds(name)
}
pub(crate) fn member_table_name_errors(name: &str, path: &str) -> Vec<String> {
let mut errors = Vec::new();
if name.len() < 3 {
errors.push(format!(
"Value '{name}' at '{path}' failed to satisfy constraint: \
Member must have length greater than or equal to 3"
));
} else if name.len() > 255 {
errors.push(format!(
"Value '{name}' at '{path}' failed to satisfy constraint: \
Member must have length less than or equal to 255"
));
}
if !table_name_pattern_holds(name) {
errors.push(format!(
"Value '{name}' at '{path}' failed to satisfy constraint: \
Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+"
));
}
errors
}
pub(crate) const REQUEST_ITEMS_KEY_CONSTRAINT: &str = "Map keys must satisfy constraint: \
[Member must have length less than or equal to 255, Member must have length greater \
than or equal to 3, Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+]";
pub(crate) fn envelope_request_validation(err: DynoxideError) -> DynoxideError {
match err {
DynoxideError::EnvelopedValidation(msg) => {
DynoxideError::ValidationException(envelope_message(&msg))
}
other => other,
}
}
pub(crate) fn single_write_layer(expected: Option<&serde_json::Value>) -> ValueLayer {
if crate::types::current_value_layer() == ValueLayer::Older
|| expected.is_some_and(crate::types::reads_on_older_layer)
{
ValueLayer::Older
} else {
ValueLayer::Current
}
}
pub(crate) fn resolve_for_layer(layer: ValueLayer, err: DynoxideError) -> DynoxideError {
match (layer, err) {
(ValueLayer::Older, DynoxideError::EnvelopedValidation(msg)) => {
DynoxideError::ValidationException(msg)
}
(_, err) => envelope_request_validation(err),
}
}
pub(crate) fn tag_for_envelope(err: DynoxideError) -> DynoxideError {
match err {
DynoxideError::ValidationException(msg) => DynoxideError::EnvelopedValidation(msg),
other => other,
}
}
#[cfg(any(
feature = "http-server",
feature = "mcp-server",
feature = "wasm-sqlite",
test
))]
pub(crate) fn strip_request_validation_tag(err: DynoxideError) -> DynoxideError {
match err {
DynoxideError::EnvelopedValidation(msg) => DynoxideError::ValidationException(msg),
other => other,
}
}
#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
pub(crate) fn resolve_request_validation_tag(operation: &str, err: DynoxideError) -> DynoxideError {
match operation {
"PutItem" | "UpdateItem" | "DeleteItem" => envelope_request_validation(err),
_ => strip_request_validation_tag(err),
}
}
#[derive(Debug)]
pub(crate) enum ClassifiedValidationError {
Bare(DynoxideError),
Enveloped(String),
}
impl ClassifiedValidationError {
pub(crate) fn bare(message: impl Into<String>) -> Self {
Self::Bare(DynoxideError::ValidationException(message.into()))
}
pub(crate) fn enveloped(message: impl Into<String>) -> Self {
Self::Enveloped(message.into())
}
pub(crate) fn into_tagged(self) -> DynoxideError {
match self {
Self::Enveloped(msg) => DynoxideError::EnvelopedValidation(msg),
Self::Bare(err) => err,
}
}
}
impl From<ClassifiedValidationError> for DynoxideError {
fn from(e: ClassifiedValidationError) -> Self {
match e {
ClassifiedValidationError::Enveloped(msg) => DynoxideError::ValidationException(msg),
ClassifiedValidationError::Bare(err) => err,
}
}
}
impl From<DynoxideError> for ClassifiedValidationError {
fn from(error: DynoxideError) -> Self {
Self::Bare(error)
}
}
pub fn validate_key_schema(key_schema: &[KeySchemaElement]) -> Result<()> {
if key_schema.is_empty() || key_schema.len() > 2 {
return Err(DynoxideError::ValidationException(
"1 validation error detected: Value null at 'keySchema' failed to satisfy constraint: \
Member must have length less than or equal to 2"
.to_string(),
));
}
if key_schema[0].key_type != KeyType::HASH {
return Err(DynoxideError::ValidationException(
"Invalid KeySchema: The first KeySchemaElement is not a HASH key type".to_string(),
));
}
if key_schema.len() == 2 && key_schema[0].attribute_name == key_schema[1].attribute_name {
return Err(DynoxideError::ValidationException(
"Both the Hash Key and the Range Key element in the KeySchema have the same name"
.to_string(),
));
}
if key_schema.len() == 2 && key_schema[1].key_type != KeyType::RANGE {
return Err(DynoxideError::ValidationException(
"Invalid KeySchema: The second KeySchemaElement is not a RANGE key type".to_string(),
));
}
Ok(())
}
pub fn validate_attribute_definitions(defs: &[AttributeDefinition]) -> Result<()> {
if defs.is_empty() {
return Err(DynoxideError::ValidationException(
"1 validation error detected: Value null at 'attributeDefinitions' failed to satisfy \
constraint: Member must have length greater than or equal to 1"
.to_string(),
));
}
for def in defs {
match def.attribute_type {
ScalarAttributeType::S | ScalarAttributeType::N | ScalarAttributeType::B => {}
}
}
Ok(())
}
pub(crate) fn java_hash_order<'a>(names: &[&'a str], sized_to_count: bool) -> Vec<&'a str> {
let mut seen = std::collections::HashSet::with_capacity(names.len());
let unique: Vec<&str> = names
.iter()
.copied()
.filter(|name| seen.insert(*name))
.collect();
let initial = if sized_to_count {
unique.len().max(1).next_power_of_two()
} else {
16
};
let capacity = java_table_capacity(initial, unique.len());
let mut ordered: Vec<(usize, usize, &str)> = unique
.iter()
.enumerate()
.map(|(added, name)| (java_hash_bucket(name, capacity), added, *name))
.collect();
ordered.sort_unstable();
ordered.into_iter().map(|(_, _, name)| name).collect()
}
pub(crate) fn java_table_capacity(initial: usize, len: usize) -> usize {
let mut capacity = initial;
while len * 4 > capacity * 3 {
capacity *= 2;
}
capacity
}
pub(crate) fn java_hash_bucket(name: &str, capacity: usize) -> usize {
let h = name.encode_utf16().fold(0u32, |h, unit| {
h.wrapping_mul(31).wrapping_add(u32::from(unit))
});
((h ^ (h >> 16)) as usize) & (capacity - 1)
}
pub(crate) fn undefined_index_key_error(
key_schema: &[KeySchemaElement],
definitions: &[AttributeDefinition],
) -> Option<String> {
let defined: Vec<&str> = definitions
.iter()
.map(|d| d.attribute_name.as_str())
.collect();
let keys: Vec<&str> = key_schema
.iter()
.map(|k| k.attribute_name.as_str())
.collect();
if keys.iter().all(|k| defined.contains(k)) {
return None;
}
let key_count = java_hash_order(&keys, true).len();
if defined.len() < key_count {
return Some("Invalid KeySchema: Some index key attribute have no definition".to_string());
}
Some(format!(
"One or more parameter values were invalid: Some index key attributes are not defined \
in AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
java_hash_order(&keys, true).join(", "),
java_hash_order(&defined, true).join(", ")
))
}
pub fn validate_key_attributes_in_definitions(
key_schema: &[KeySchemaElement],
definitions: &[AttributeDefinition],
) -> Result<()> {
for key_elem in key_schema {
let found = definitions
.iter()
.any(|def| def.attribute_name == key_elem.attribute_name);
if !found {
return Err(DynoxideError::ValidationException(format!(
"One or more parameter values were invalid: Some index key attributes are not \
defined in AttributeDefinitions. Keys: [{}], AttributeDefinitions: [{}]",
key_elem.attribute_name,
definitions
.iter()
.map(|d| d.attribute_name.as_str())
.collect::<Vec<_>>()
.join(", ")
)));
}
}
Ok(())
}
pub fn check_gsi_key_schema(key_schema: &[KeySchemaElement]) -> std::result::Result<(), String> {
const MAX_PER_KIND: usize = 4;
let Some(first) = key_schema.first() else {
return Err(
"1 validation error detected: Value null at 'keySchema' failed to satisfy \
constraint: Member must have length less than or equal to 2"
.to_string(),
);
};
if first.key_type != KeyType::HASH {
return Err(
"Invalid KeySchema: The first KeySchemaElement is not a HASH key type".to_string(),
);
}
if let [hash, range] = key_schema
&& range.key_type == KeyType::RANGE
&& hash.attribute_name == range.attribute_name
{
return Err(
"Both the Hash Key and the Range Key element in the KeySchema have the same name"
.to_string(),
);
}
for (i, element) in key_schema.iter().enumerate() {
if key_schema[..i]
.iter()
.any(|earlier| earlier.attribute_name == element.attribute_name)
{
return Err(
"The KeySchema contains multiple key attributes with the same name".to_string(),
);
}
}
if key_schema
.windows(2)
.any(|w| w[0].key_type == KeyType::RANGE && w[1].key_type == KeyType::HASH)
{
return Err(
"One or more parameter values were invalid: All HASH key attributes must precede \
RANGE key attributes in the KeySchema"
.to_string(),
);
}
for (key_type, name) in [(KeyType::HASH, "HASH"), (KeyType::RANGE, "RANGE")] {
if key_schema.iter().filter(|k| k.key_type == key_type).count() > MAX_PER_KIND {
return Err(format!(
"One or more parameter values were invalid: The KeySchema exceeds the maximum \
allowed number of {name} key attributes"
));
}
}
Ok(())
}
pub fn validate_gsi(
gsi: &GlobalSecondaryIndex,
request_definitions: &[AttributeDefinition],
) -> Result<()> {
if gsi.index_name.len() < 3 || gsi.index_name.len() > 255 {
return Err(DynoxideError::ValidationException(format!(
"1 validation error detected: Value '{}' at 'globalSecondaryIndexes.1.member.indexName' \
failed to satisfy constraint: Member must have length greater than or equal to 3",
gsi.index_name
)));
}
if !gsi
.index_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
{
return Err(DynoxideError::ValidationException(format!(
"1 validation error detected: Value '{}' at 'globalSecondaryIndexes.1.member.indexName' \
failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
gsi.index_name
)));
}
check_gsi_key_schema(&gsi.key_schema).map_err(DynoxideError::ValidationException)?;
validate_projection(&gsi.projection, &gsi.index_name)?;
if let Some(message) = undefined_index_key_error(&gsi.key_schema, request_definitions) {
return Err(DynoxideError::ValidationException(message));
}
Ok(())
}
pub fn validate_vector_index(
vix: &crate::types::VectorIndex,
request_definitions: &[AttributeDefinition],
member_path: &str,
) -> Result<()> {
if vix.index_name.len() < 3 {
return Err(DynoxideError::ValidationException(format!(
"1 validation error detected: Value '{}' at '{member_path}.indexName' \
failed to satisfy constraint: Member must have length greater than or equal to 3",
vix.index_name
)));
}
if vix.index_name.len() > 255 {
return Err(DynoxideError::ValidationException(format!(
"1 validation error detected: Value '{}' at '{member_path}.indexName' \
failed to satisfy constraint: Member must have length less than or equal to 255",
vix.index_name
)));
}
if !vix
.index_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
{
return Err(DynoxideError::ValidationException(format!(
"1 validation error detected: Value '{}' at '{member_path}.indexName' \
failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
vix.index_name
)));
}
match vix.distance_function.as_str() {
"COSINE" | "DOT_PRODUCT" | "EUCLIDEAN" => {}
other => {
return Err(DynoxideError::ValidationException(format!(
"1 validation error detected: Value '{other}' at \
'{member_path}.distanceFunction' failed to satisfy constraint: \
Member must satisfy enum value set: [COSINE, DOT_PRODUCT, EUCLIDEAN]"
)));
}
}
if vix.dimensions < 1 || vix.dimensions > 4096 {
return Err(DynoxideError::ValidationException(
"One or more parameter values were invalid: Number of dimensions must be between \
1 and 4096 inclusive."
.to_string(),
));
}
validate_projection(&vix.projection, &vix.index_name)?;
if let Some(ref schema) = vix.search_schema {
for (elem_idx, elem) in schema.iter().enumerate() {
let elem_position = elem_idx + 1;
match elem.search_schema_element_type.as_str() {
"HASH" | "INLINE_FILTER" => {}
other => {
return Err(DynoxideError::ValidationException(format!(
"1 validation error detected: Value '{other}' at \
'{member_path}.searchSchema.{elem_position}.member.searchSchemaElementType' \
failed to satisfy constraint: Member must satisfy enum value set: \
[HASH, INLINE_FILTER]"
)));
}
}
if !request_definitions
.iter()
.any(|d| d.attribute_name == elem.attribute_name)
{
return Err(DynoxideError::ValidationException(
"One or more parameter values were invalid: One element in SearchSchema \
is not defined in attribute definitions"
.to_string(),
));
}
}
let hash_count = schema
.iter()
.filter(|e| e.search_schema_element_type == "HASH")
.count();
if hash_count > 1 {
return Err(DynoxideError::ValidationException(format!(
"One or more parameter values were invalid: Value '{hash_count}' at \
'SearchSchema' failed to satisfy constraint: Member must have HASH count \
less than or equal to 1"
)));
}
let mut seen = std::collections::HashSet::new();
for elem in schema {
if !seen.insert(elem.attribute_name.as_str()) {
return Err(DynoxideError::ValidationException(
"One or more parameter values were invalid: SearchSchema contains a \
duplicate AttributeName"
.to_string(),
));
}
}
}
Ok(())
}
pub fn validate_projection(projection: &crate::types::Projection, _index_name: &str) -> Result<()> {
match &projection.projection_type {
None => {
return Err(DynoxideError::ValidationException(
"One or more parameter values were invalid: Unknown ProjectionType: null"
.to_string(),
));
}
Some(pt) => {
if let Some(ref nka) = projection.non_key_attributes {
match pt {
crate::types::ProjectionType::ALL => {
return Err(DynoxideError::ValidationException(
"One or more parameter values were invalid: \
ProjectionType is ALL, but NonKeyAttributes is specified"
.to_string(),
));
}
crate::types::ProjectionType::KEYS_ONLY => {
return Err(DynoxideError::ValidationException(
"One or more parameter values were invalid: \
ProjectionType is KEYS_ONLY, but NonKeyAttributes is specified"
.to_string(),
));
}
crate::types::ProjectionType::INCLUDE => {
if nka.is_empty() {
return Err(DynoxideError::ValidationException(
"One or more parameter values were invalid: \
NonKeyAttributes must not be empty"
.to_string(),
));
}
}
}
}
}
}
Ok(())
}
pub fn partition_key_name(key_schema: &[KeySchemaElement]) -> Option<&str> {
key_schema
.iter()
.find(|k| k.key_type == KeyType::HASH)
.map(|k| k.attribute_name.as_str())
}
const MAX_NESTING_DEPTH: usize = 32;
pub(crate) const NESTING_LIMIT_MESSAGE: &str = "Nesting Levels have exceeded supported limits: Attributes in the item have nested levels beyond supported limit";
pub(crate) const UPDATED_ITEM_NESTING_MESSAGE: &str =
"Nesting Levels have exceeded supported limits";
pub(crate) fn validate_updated_item_nesting(item: &Item) -> Result<()> {
if item
.values()
.any(|value| validate_nesting_depth(value).is_err())
{
return Err(DynoxideError::ValidationException(
UPDATED_ITEM_NESTING_MESSAGE.to_string(),
));
}
Ok(())
}
pub fn validate_item_attribute_values(item: &Item) -> crate::Result<()> {
validate_item_attribute_values_classified(item, ValueLayer::Older).map_err(Into::into)
}
pub(crate) fn validate_item_attribute_values_classified(
item: &Item,
layer: ValueLayer,
) -> std::result::Result<(), ClassifiedValidationError> {
for value in item.values() {
validate_attribute_value(value, 0, layer).map_err(|e| match e {
ClassifiedValidationError::Enveloped(msg)
if msg == NESTING_LIMIT_MESSAGE && nesting_depth(value) > SECOND_NESTING_LIMIT =>
{
ClassifiedValidationError::bare(msg)
}
other => other,
})?;
}
Ok(())
}
pub(crate) fn validate_value_classified(
value: &AttributeValue,
layer: ValueLayer,
) -> std::result::Result<(), ClassifiedValidationError> {
validate_attribute_value(value, 0, layer)
}
pub(crate) fn find_set_error(value: &AttributeValue, layer: ValueLayer) -> Option<String> {
const INVALID: &str = "One or more parameter values were invalid: ";
match value {
AttributeValue::SS(set) if set.is_empty() => {
Some(format!("{INVALID}An string set may not be empty"))
}
AttributeValue::NS(set) if set.is_empty() => {
Some(format!("{INVALID}An number set may not be empty"))
}
AttributeValue::BS(set) if set.is_empty() => {
Some(format!("{INVALID}Binary sets should not be empty"))
}
AttributeValue::L(list) => list.iter().find_map(|v| find_set_error(v, layer)),
AttributeValue::M(map) => map.values().find_map(|v| find_set_error(v, layer)),
_ => duplicate_set_message(value, layer),
}
}
pub(crate) fn find_duplicate_set(value: &AttributeValue, layer: ValueLayer) -> Option<String> {
match value {
AttributeValue::L(list) => list.iter().find_map(|v| find_duplicate_set(v, layer)),
AttributeValue::M(map) => map.values().find_map(|v| find_duplicate_set(v, layer)),
_ => duplicate_set_message(value, layer),
}
}
pub(crate) const SECOND_NESTING_LIMIT: usize = 62;
pub(crate) fn nesting_depth(value: &AttributeValue) -> usize {
match value {
AttributeValue::L(list) => 1 + list.iter().map(nesting_depth).max().unwrap_or(0),
AttributeValue::M(map) => 1 + map.values().map(nesting_depth).max().unwrap_or(0),
_ => 0,
}
}
pub(crate) fn validate_parameter_value(value: &AttributeValue) -> crate::Result<()> {
validate_attribute_value_within(value, 0, None, ValueLayer::Older).map_err(Into::into)
}
fn validate_attribute_value(
value: &AttributeValue,
depth: usize,
layer: ValueLayer,
) -> std::result::Result<(), ClassifiedValidationError> {
validate_attribute_value_within(value, depth, Some(MAX_NESTING_DEPTH), layer)
}
fn validate_attribute_value_within(
value: &AttributeValue,
depth: usize,
nesting_limit: Option<usize>,
layer: ValueLayer,
) -> std::result::Result<(), ClassifiedValidationError> {
let number = |n: &str| {
layer
.check_number(n)
.map_err(|e| layer.classify_number_error(e))
};
if nesting_limit.is_some_and(|limit| depth >= limit) {
return Err(ClassifiedValidationError::enveloped(NESTING_LIMIT_MESSAGE));
}
match value {
AttributeValue::SS(set) if set.is_empty() => Err(ClassifiedValidationError::enveloped(
"One or more parameter values were invalid: An string set may not be empty",
)),
AttributeValue::NS(set) if set.is_empty() => Err(ClassifiedValidationError::enveloped(
"One or more parameter values were invalid: An number set may not be empty",
)),
AttributeValue::BS(set) if set.is_empty() => Err(ClassifiedValidationError::enveloped(
"One or more parameter values were invalid: Binary sets should not be empty",
)),
AttributeValue::SS(_) | AttributeValue::BS(_) => {
match duplicate_set_message(value, layer) {
Some(message) => Err(ClassifiedValidationError::enveloped(message)),
None => Ok(()),
}
}
AttributeValue::NS(set) => {
for n in set {
number(n)?;
}
match duplicate_set_message(value, layer) {
Some(message) => Err(ClassifiedValidationError::enveloped(message)),
None => Ok(()),
}
}
AttributeValue::N(n) => {
number(n)?;
Ok(())
}
AttributeValue::L(list) => {
for v in list {
validate_attribute_value_within(v, depth + 1, nesting_limit, layer)?;
}
Ok(())
}
AttributeValue::M(map) => {
for v in map.values() {
validate_attribute_value_within(v, depth + 1, nesting_limit, layer)?;
}
Ok(())
}
_ => Ok(()),
}
}
pub fn validate_nesting_depth(value: &AttributeValue) -> Result<()> {
check_nesting_depth(value, 0)
}
fn check_nesting_depth(value: &AttributeValue, depth: usize) -> Result<()> {
if depth >= MAX_NESTING_DEPTH {
return Err(DynoxideError::ValidationException(
NESTING_LIMIT_MESSAGE.to_string(),
));
}
match value {
AttributeValue::L(list) => list
.iter()
.try_for_each(|v| check_nesting_depth(v, depth + 1)),
AttributeValue::M(map) => map
.values()
.try_for_each(|v| check_nesting_depth(v, depth + 1)),
_ => Ok(()),
}
}
pub fn validate_key_attribute_values(key: &Item) -> Result<()> {
validate_key_values(key, ValueLayer::Older).map_err(Into::into)
}
pub(crate) fn validate_key_values(
key: &Item,
layer: ValueLayer,
) -> std::result::Result<(), ClassifiedValidationError> {
for value in key.values() {
validate_key_attr_value(value, layer)?;
}
Ok(())
}
fn validate_key_attr_value(
value: &AttributeValue,
layer: ValueLayer,
) -> std::result::Result<(), ClassifiedValidationError> {
match value {
AttributeValue::SS(set) if set.is_empty() => {
return Err(ClassifiedValidationError::bare(
"One or more parameter values were invalid: An string set may not be empty",
));
}
AttributeValue::NS(set) if set.is_empty() => {
return Err(ClassifiedValidationError::bare(
"One or more parameter values were invalid: An number set may not be empty",
));
}
AttributeValue::BS(set) if set.is_empty() => {
return Err(ClassifiedValidationError::bare(
"One or more parameter values were invalid: Binary sets should not be empty",
));
}
AttributeValue::SS(_) | AttributeValue::BS(_) => {
if let Some(message) = duplicate_set_message(value, layer) {
return Err(ClassifiedValidationError::enveloped(message));
}
}
AttributeValue::NS(set) => {
for n in set {
layer
.check_number(n)
.map_err(|e| layer.classify_number_error(e))?;
}
if let Some(message) = duplicate_set_message(value, layer) {
return Err(ClassifiedValidationError::enveloped(message));
}
}
AttributeValue::N(n) => {
layer
.check_number(n)
.map_err(|e| layer.classify_number_error(e))?;
}
_ => {}
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ValueLayer {
Current,
Older,
}
impl ValueLayer {
#[cfg(any(feature = "http-server", feature = "wasm-sqlite", test))]
pub(crate) fn for_operation(operation: &str) -> Self {
match operation {
"BatchWriteItem"
| "TransactWriteItems"
| "TransactGetItems"
| "Scan"
| "ExecuteStatement"
| "BatchExecuteStatement"
| "ExecuteTransaction" => Self::Older,
_ => Self::Current,
}
}
pub(crate) fn check_number(self, n: &str) -> Result<()> {
match self {
Self::Current => crate::types::validate_current_number(n),
Self::Older => crate::types::validate_parameter_number(n),
}
}
pub(crate) fn classify_number_error(self, e: DynoxideError) -> ClassifiedValidationError {
match (self, e) {
(Self::Current, DynoxideError::ValidationException(m)) => {
ClassifiedValidationError::enveloped(m)
}
(_, e) => ClassifiedValidationError::Bare(e),
}
}
}
pub(crate) fn duplicate_set_message(value: &AttributeValue, layer: ValueLayer) -> Option<String> {
const INVALID: &str = "One or more parameter values were invalid: ";
fn current_order<T: Ord + Clone>(members: &[T]) -> Option<Vec<T>> {
let mut read = std::collections::BTreeSet::new();
for (i, member) in members.iter().enumerate() {
if !read.insert(member.clone()) {
let mut out: Vec<T> = read.into_iter().collect();
out.push(member.clone());
out.extend(members[i + 1..].iter().cloned());
return Some(out);
}
}
None
}
fn has_repeat<T: std::hash::Hash + Eq>(members: impl IntoIterator<Item = T>) -> bool {
let mut seen = std::collections::HashSet::new();
members.into_iter().any(|m| !seen.insert(m))
}
use base64::Engine;
let b64 = |b: &Vec<u8>| base64::engine::general_purpose::STANDARD.encode(b);
match (value, layer) {
(AttributeValue::SS(set), ValueLayer::Current) => current_order(set).map(|ordered| {
let quoted: Vec<String> = ordered
.iter()
.map(|s| serde_json::to_string(s).unwrap_or_default())
.collect();
format!(
"{INVALID}Input collection [{}] contains duplicates.",
quoted.join(", ")
)
}),
(AttributeValue::SS(set), ValueLayer::Older) => has_repeat(set.iter()).then(|| {
format!(
"{INVALID}Input collection [{}] contains duplicates.",
set.join(", ")
)
}),
(AttributeValue::BS(set), ValueLayer::Current) => current_order(set).map(|ordered| {
let quoted: Vec<String> = ordered.iter().map(|b| format!("\"{}\"", b64(b))).collect();
format!(
"{INVALID}Input collection [{}]of type BS contains duplicates.",
quoted.join(",")
)
}),
(AttributeValue::BS(set), ValueLayer::Older) => has_repeat(set.iter()).then(|| {
let shown: Vec<String> = set.iter().map(b64).collect();
format!(
"{INVALID}Input collection [{}]of type BS contains duplicates.",
shown.join(", ")
)
}),
(AttributeValue::NS(set), _) => {
has_repeat(set.iter().map(|n| crate::types::normalize_dynamo_number(n))).then(|| {
match layer {
ValueLayer::Current => {
format!("{INVALID}Input collection contains duplicates.")
}
ValueLayer::Older => "Input collection contains duplicates".to_string(),
}
})
}
_ => None,
}
}
pub fn normalize_item_sets(item: &mut Item) {
for value in item.values_mut() {
normalize_attribute_sets(value);
}
}
fn normalize_attribute_sets(value: &mut AttributeValue) {
match value {
AttributeValue::N(n) => {
*n = crate::types::normalize_dynamo_number(n);
}
AttributeValue::SS(set) => {
let mut seen = std::collections::HashSet::new();
set.retain(|s| seen.insert(s.clone()));
crate::set_order::sort_string_set(set);
}
AttributeValue::NS(set) => {
let mut seen = std::collections::HashSet::new();
set.retain(|n| seen.insert(normalize_number_for_dedup(n)));
for n in set.iter_mut() {
*n = crate::types::normalize_dynamo_number(n);
}
crate::set_order::sort_number_set(set);
}
AttributeValue::BS(set) => {
let mut seen = std::collections::HashSet::new();
set.retain(|b| seen.insert(b.clone()));
crate::set_order::sort_binary_set(set);
}
AttributeValue::L(list) => {
for v in list.iter_mut() {
normalize_attribute_sets(v);
}
}
AttributeValue::M(map) => {
for v in map.values_mut() {
normalize_attribute_sets(v);
}
}
_ => {}
}
}
fn normalize_number_for_dedup(n: &str) -> String {
let trimmed = n.trim();
let negative = trimmed.starts_with('-');
let abs_str = if negative { &trimmed[1..] } else { trimmed };
let (digits, exponent) = crate::types::parse_number_parts(abs_str);
if digits.is_empty() {
return "0".to_string();
}
let mantissa: String = digits.iter().map(|&d| (b'0' + d) as char).collect();
let sign = if negative { "-" } else { "" };
format!("{sign}{mantissa}E{exponent}")
}
pub fn validate_lsi(
lsi: &crate::types::LocalSecondaryIndex,
table_key_schema: &[KeySchemaElement],
all_definitions: &[AttributeDefinition],
) -> Result<()> {
if lsi.index_name.len() < 3 || lsi.index_name.len() > 255 {
return Err(DynoxideError::ValidationException(format!(
"1 validation error detected: Value '{}' at 'localSecondaryIndexes.1.member.indexName' \
failed to satisfy constraint: Member must have length greater than or equal to 3",
lsi.index_name
)));
}
if !lsi
.index_name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
{
return Err(DynoxideError::ValidationException(format!(
"1 validation error detected: Value '{}' at 'localSecondaryIndexes.1.member.indexName' \
failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z0-9_.-]+",
lsi.index_name
)));
}
validate_key_schema(&lsi.key_schema)?;
validate_projection(&lsi.projection, &lsi.index_name)?;
let lsi_pk = lsi
.key_schema
.iter()
.find(|k| k.key_type == KeyType::HASH)
.map(|k| k.attribute_name.as_str());
let lsi_sk = lsi
.key_schema
.iter()
.find(|k| k.key_type == KeyType::RANGE)
.map(|k| k.attribute_name.as_str());
let table_pk = partition_key_name(table_key_schema);
let table_sk = sort_key_name(table_key_schema);
if lsi_pk != table_pk {
return Err(DynoxideError::ValidationException(
"One or more parameter values were invalid: Table KeySchema: The AttributeValue for a key attribute for the table must match the AttributeValue definition".to_string(),
));
}
if lsi_sk.is_some() && lsi_sk == table_sk {
return Err(DynoxideError::ValidationException(
"One or more parameter values were invalid: Index KeySchema: The index KeySchema must not be the same as the table KeySchema".to_string(),
));
}
validate_key_attributes_in_definitions(&lsi.key_schema, all_definitions)?;
Ok(())
}
pub fn sort_key_name(key_schema: &[KeySchemaElement]) -> Option<&str> {
key_schema
.iter()
.find(|k| k.key_type == KeyType::RANGE)
.map(|k| k.attribute_name.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_empty_gsi_key_schema_is_refused_by_the_shape_check() {
assert_eq!(
check_gsi_key_schema(&[]),
Err(
"1 validation error detected: Value null at 'keySchema' failed to satisfy \
constraint: Member must have length less than or equal to 2"
.to_string()
)
);
}
#[test]
fn java_hash_order_matches_the_captured_lists() {
assert_eq!(
java_hash_order(&["PK", "h", "a", "unused"], true),
["h", "a", "unused", "PK"]
);
assert_eq!(
java_hash_order(&["PK", "a", "b", "c", "d", "u1"], true),
["a", "b", "PK", "c", "d", "u1"]
);
assert_eq!(java_hash_order(&["h", "a", "zz"], true), ["h", "zz", "a"]);
assert_eq!(
java_hash_order(&["pk", "sk", "gpk", "gsk"], false),
["gpk", "sk", "pk", "gsk"]
);
assert_eq!(
java_hash_order(&["orderId", "customerId", "createdAt", "status"], false),
["createdAt", "orderId", "customerId", "status"]
);
}
fn hash_key(name: &str) -> KeySchemaElement {
KeySchemaElement {
attribute_name: name.to_string(),
key_type: KeyType::HASH,
}
}
fn range_key(name: &str) -> KeySchemaElement {
KeySchemaElement {
attribute_name: name.to_string(),
key_type: KeyType::RANGE,
}
}
fn attr_def(name: &str, attr_type: ScalarAttributeType) -> AttributeDefinition {
AttributeDefinition {
attribute_name: name.to_string(),
attribute_type: attr_type,
}
}
#[test]
fn test_valid_table_name() {
assert!(validate_table_name("MyTable").is_ok());
assert!(validate_table_name("my-table.v2").is_ok());
assert!(validate_table_name("a_b").is_ok());
}
#[test]
fn test_short_table_name_accepted_for_read_write() {
assert!(validate_table_name("ab").is_ok());
assert!(validate_table_name("a").is_ok());
}
#[test]
fn test_empty_table_name_rejected_for_read_write() {
let err = validate_table_name("").unwrap_err().to_string();
assert!(err.contains("Member must have length greater than or equal to 1"));
assert!(!err.contains("greater than or equal to 3"));
}
#[test]
fn test_invalid_table_name_bad_chars() {
assert!(validate_table_name("my table").is_err());
assert!(validate_table_name("my@table").is_err());
}
#[test]
fn test_create_table_context_keeps_min_length_3() {
let errs = table_name_constraint_errors(Some("ab"), TableNameContext::CreateTable);
assert!(
errs.iter()
.any(|e| e.contains("Member must have length greater than or equal to 3"))
);
}
#[test]
fn test_valid_key_schema() {
let schema = vec![hash_key("pk")];
assert!(validate_key_schema(&schema).is_ok());
let schema = vec![hash_key("pk"), range_key("sk")];
assert!(validate_key_schema(&schema).is_ok());
}
#[test]
fn test_invalid_key_schema_empty() {
assert!(validate_key_schema(&[]).is_err());
}
#[test]
fn test_invalid_key_schema_no_hash() {
let schema = vec![range_key("sk")];
assert!(validate_key_schema(&schema).is_err());
}
#[test]
fn test_valid_key_attributes_in_definitions() {
let schema = vec![hash_key("pk"), range_key("sk")];
let defs = vec![
attr_def("pk", ScalarAttributeType::S),
attr_def("sk", ScalarAttributeType::N),
];
assert!(validate_key_attributes_in_definitions(&schema, &defs).is_ok());
}
#[test]
fn test_missing_key_attribute_in_definitions() {
let schema = vec![hash_key("pk"), range_key("sk")];
let defs = vec![attr_def("pk", ScalarAttributeType::S)];
assert!(validate_key_attributes_in_definitions(&schema, &defs).is_err());
}
#[test]
fn test_partition_key_name() {
let schema = vec![hash_key("pk"), range_key("sk")];
assert_eq!(partition_key_name(&schema), Some("pk"));
}
#[test]
fn test_sort_key_name() {
let schema = vec![hash_key("pk"), range_key("sk")];
assert_eq!(sort_key_name(&schema), Some("sk"));
let schema = vec![hash_key("pk")];
assert_eq!(sort_key_name(&schema), None);
}
#[test]
fn test_envelope_request_validation_wraps_tagged_error() {
let msg = "Value '' at 'expressionAttributeNames' failed to satisfy constraint: \
Map value must satisfy constraint";
let err = envelope_request_validation(DynoxideError::EnvelopedValidation(msg.to_string()));
match err {
DynoxideError::ValidationException(m) => {
assert_eq!(m, format!("1 validation error detected: {msg}"));
}
other => panic!("expected ValidationException, got {other:?}"),
}
}
#[test]
fn test_envelope_request_validation_passes_other_errors_through() {
let plain = envelope_request_validation(DynoxideError::ValidationException("msg".into()));
assert!(matches!(
&plain,
DynoxideError::ValidationException(m) if m == "msg"
));
let key_empty =
envelope_request_validation(DynoxideError::KeyEmptyValueValidation("msg".into()));
assert!(matches!(
&key_empty,
DynoxideError::KeyEmptyValueValidation(m) if m == "msg"
));
let not_found =
envelope_request_validation(DynoxideError::ResourceNotFoundException("msg".into()));
assert!(matches!(
¬_found,
DynoxideError::ResourceNotFoundException(m) if m == "msg"
));
}
#[test]
fn test_strip_request_validation_tag_untags_without_envelope() {
let err = strip_request_validation_tag(DynoxideError::EnvelopedValidation("msg".into()));
assert!(matches!(
&err,
DynoxideError::ValidationException(m) if m == "msg"
));
}
#[test]
fn test_strip_request_validation_tag_passes_other_errors_through() {
let plain = strip_request_validation_tag(DynoxideError::ValidationException("msg".into()));
assert!(matches!(
&plain,
DynoxideError::ValidationException(m) if m == "msg"
));
let not_found =
strip_request_validation_tag(DynoxideError::ResourceNotFoundException("msg".into()));
assert!(matches!(
¬_found,
DynoxideError::ResourceNotFoundException(m) if m == "msg"
));
}
#[test]
fn normalising_an_item_sorts_its_sets_in_place_at_every_depth() {
let mut item: Item = [
(
"top".to_string(),
AttributeValue::NS(vec!["3".into(), "-1".into(), "2".into()]),
),
(
"m".to_string(),
AttributeValue::M(
[(
"ss".to_string(),
AttributeValue::SS(vec!["b".into(), "a".into(), "c".into()]),
)]
.into(),
),
),
(
"l".to_string(),
AttributeValue::L(vec![
AttributeValue::NS(vec!["10".into(), "2".into(), "1e2".into()]),
AttributeValue::BS(vec![vec![2], vec![0xff], vec![1]]),
]),
),
]
.into();
normalize_item_sets(&mut item);
assert_eq!(
item["top"],
AttributeValue::NS(vec!["-1".into(), "2".into(), "3".into()])
);
let AttributeValue::M(m) = &item["m"] else {
panic!("m")
};
assert_eq!(
m["ss"],
AttributeValue::SS(vec!["a".into(), "b".into(), "c".into()])
);
let AttributeValue::L(l) = &item["l"] else {
panic!("l")
};
assert_eq!(
l[0],
AttributeValue::NS(vec!["2".into(), "10".into(), "100".into()])
);
assert_eq!(l[1], AttributeValue::BS(vec![vec![1], vec![2], vec![0xff]]));
}
}