use serde::Serialize;
use std::collections::HashMap;
use std::fmt;
#[derive(Debug, Clone, Default, Serialize)]
pub struct CancellationReason {
#[serde(rename = "Code")]
pub code: String,
#[serde(rename = "Message", skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
pub item: Option<HashMap<String, crate::types::AttributeValue>>,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum DynoxideError {
#[error("{0}")]
ResourceNotFoundException(String),
#[error("{0}")]
ResourceInUseException(String),
#[error("{0}")]
ValidationException(String),
#[error("{0}")]
KeyEmptyValueValidation(String),
#[error("{0}")]
ConditionalCheckFailedException(
String,
Option<HashMap<String, crate::types::AttributeValue>>,
),
#[error("{0}")]
TransactionCanceledException(String, Vec<CancellationReason>),
#[error("{0}")]
ItemCollectionSizeLimitExceededException(String),
#[error("{0}")]
DuplicateItemException(String),
#[error("{0}")]
ProvisionedThroughputExceededException(String),
#[error("{0}")]
SerializationException(String),
#[error("{0}")]
LimitExceededException(String),
#[error("{0}")]
AccessDeniedException(String),
#[error("{0}")]
IdempotentParameterMismatchException(String),
#[error("{0}")]
InternalServerError(String),
#[error("Conversion error: {0}")]
ConversionError(#[from] crate::types::ConversionError),
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
#[error("Internal error: {0}")]
SqliteError(#[from] rusqlite::Error),
#[cfg(feature = "wasm-sqlite")]
#[error("{0}")]
OpfsUnavailable(String),
}
impl From<crate::storage_backend::BackendError> for DynoxideError {
fn from(err: crate::storage_backend::BackendError) -> Self {
use crate::storage_backend::BackendError;
match err {
BackendError::Validation(msg) => DynoxideError::ValidationException(msg),
#[cfg(feature = "wasm-sqlite")]
BackendError::OpfsUnavailable(msg) => DynoxideError::OpfsUnavailable(msg),
other => DynoxideError::InternalServerError(other.to_string()),
}
}
}
impl DynoxideError {
pub fn error_type(&self) -> &'static str {
match self {
DynoxideError::ResourceNotFoundException(_) => {
"com.amazonaws.dynamodb.v20120810#ResourceNotFoundException"
}
DynoxideError::ResourceInUseException(_) => {
"com.amazonaws.dynamodb.v20120810#ResourceInUseException"
}
DynoxideError::ValidationException(_) | DynoxideError::KeyEmptyValueValidation(_) => {
"com.amazon.coral.validate#ValidationException"
}
DynoxideError::ConditionalCheckFailedException(..) => {
"com.amazonaws.dynamodb.v20120810#ConditionalCheckFailedException"
}
DynoxideError::TransactionCanceledException(..) => {
"com.amazonaws.dynamodb.v20120810#TransactionCanceledException"
}
DynoxideError::DuplicateItemException(_) => {
"com.amazonaws.dynamodb.v20120810#DuplicateItemException"
}
DynoxideError::ItemCollectionSizeLimitExceededException(_) => {
"com.amazonaws.dynamodb.v20120810#ItemCollectionSizeLimitExceededException"
}
DynoxideError::ProvisionedThroughputExceededException(_) => {
"com.amazonaws.dynamodb.v20120810#ProvisionedThroughputExceededException"
}
DynoxideError::SerializationException(_) => {
"com.amazon.coral.service#SerializationException"
}
DynoxideError::LimitExceededException(_) => {
"com.amazonaws.dynamodb.v20120810#LimitExceededException"
}
DynoxideError::AccessDeniedException(_) => {
"com.amazonaws.dynamodb.v20120810#AccessDeniedException"
}
DynoxideError::IdempotentParameterMismatchException(_) => {
"com.amazonaws.dynamodb.v20120810#IdempotentParameterMismatchException"
}
DynoxideError::ConversionError(_) => "com.amazon.coral.validate#ValidationException",
DynoxideError::InternalServerError(_) => {
"com.amazonaws.dynamodb.v20120810#InternalServerError"
}
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
DynoxideError::SqliteError(_) => "com.amazonaws.dynamodb.v20120810#InternalServerError",
#[cfg(feature = "wasm-sqlite")]
DynoxideError::OpfsUnavailable(_) => "com.dynoxide.wasm#OpfsUnavailable",
}
}
pub fn short_error_code(&self) -> &'static str {
match self {
DynoxideError::ResourceNotFoundException(_) => "ResourceNotFound",
DynoxideError::ResourceInUseException(_) => "ResourceInUse",
DynoxideError::ValidationException(_)
| DynoxideError::KeyEmptyValueValidation(_)
| DynoxideError::ConversionError(_) => "ValidationError",
DynoxideError::ConditionalCheckFailedException(..) => "ConditionalCheckFailed",
DynoxideError::TransactionCanceledException(..) => "TransactionConflict",
DynoxideError::DuplicateItemException(_) => "DuplicateItem",
DynoxideError::ItemCollectionSizeLimitExceededException(_) => {
"ItemCollectionSizeLimitExceeded"
}
DynoxideError::ProvisionedThroughputExceededException(_) => {
"ProvisionedThroughputExceeded"
}
DynoxideError::AccessDeniedException(_) => "AccessDenied",
DynoxideError::IdempotentParameterMismatchException(_) => "IdempotentParameterMismatch",
DynoxideError::SerializationException(_) => "SerializationError",
DynoxideError::LimitExceededException(_) => "RequestLimitExceeded",
DynoxideError::InternalServerError(_) => "InternalServerError",
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
DynoxideError::SqliteError(_) => "InternalServerError",
#[cfg(feature = "wasm-sqlite")]
DynoxideError::OpfsUnavailable(_) => "OpfsUnavailable",
}
}
pub fn status_code(&self) -> u16 {
match self {
DynoxideError::InternalServerError(_) => 500,
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
DynoxideError::SqliteError(_) => 500,
_ => 400,
}
}
pub fn to_response(&self) -> ErrorResponse {
let item = if let DynoxideError::ConditionalCheckFailedException(_, item) = self {
item.clone()
} else {
None
};
ErrorResponse {
error_type: self.error_type().to_string(),
message: self.to_string(),
item,
}
}
pub fn to_json(&self) -> String {
let error_type = self.error_type();
let message = self.to_string();
match self {
DynoxideError::TransactionCanceledException(_, reasons) => {
let mut m = serde_json::Map::new();
m.insert(
"__type".to_string(),
serde_json::Value::String(error_type.to_string()),
);
m.insert("Message".to_string(), serde_json::Value::String(message));
if let Ok(reasons_val) = serde_json::to_value(reasons) {
m.insert("CancellationReasons".to_string(), reasons_val);
}
serde_json::to_string(&m).unwrap_or_default()
}
DynoxideError::SerializationException(_) => {
let mut m = serde_json::Map::new();
m.insert(
"__type".to_string(),
serde_json::Value::String(error_type.to_string()),
);
m.insert("Message".to_string(), serde_json::Value::String(message));
serde_json::to_string(&m).unwrap_or_default()
}
_ => {
let resp = self.to_response();
serde_json::to_string(&resp).unwrap_or_default()
}
}
}
}
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
#[serde(rename = "__type")]
pub error_type: String,
#[serde(rename = "message")]
pub message: String,
#[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
pub item: Option<HashMap<String, crate::types::AttributeValue>>,
}
impl fmt::Display for ErrorResponse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", serde_json::to_string(self).unwrap_or_default())
}
}
pub type Result<T> = std::result::Result<T, DynoxideError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_response_format() {
let err = DynoxideError::ResourceNotFoundException(
"Requested resource not found: Table: NonExistent not found".to_string(),
);
let resp = err.to_response();
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"__type\""));
assert!(json.contains("ResourceNotFoundException"));
assert!(json.contains("NonExistent not found"));
}
#[test]
fn test_status_codes() {
assert_eq!(
DynoxideError::ResourceNotFoundException("".into()).status_code(),
400
);
assert_eq!(
DynoxideError::ResourceInUseException("".into()).status_code(),
400
);
assert_eq!(
DynoxideError::ValidationException("".into()).status_code(),
400
);
assert_eq!(
DynoxideError::ConditionalCheckFailedException("".into(), None).status_code(),
400
);
assert_eq!(
DynoxideError::TransactionCanceledException("".into(), vec![]).status_code(),
400
);
assert_eq!(
DynoxideError::InternalServerError("".into()).status_code(),
500
);
}
#[test]
fn test_key_empty_value_validation_is_wire_identical_to_validation_exception() {
let messages = [
"One or more parameter values are not valid. The AttributeValue for a key \
attribute cannot contain an empty string value. Key: pk",
"One or more parameter values are not valid. The AttributeValue for a key \
attribute cannot contain an empty binary value. Key: pk",
];
for msg in messages {
let empty = DynoxideError::KeyEmptyValueValidation(msg.to_string());
let plain = DynoxideError::ValidationException(msg.to_string());
assert_eq!(empty.status_code(), plain.status_code());
assert_eq!(empty.error_type(), plain.error_type());
assert_eq!(empty.short_error_code(), plain.short_error_code());
assert_eq!(empty.to_json(), plain.to_json());
assert_eq!(empty.to_string(), plain.to_string());
}
}
#[test]
fn test_error_type_strings() {
let err = DynoxideError::ValidationException("bad input".into());
assert_eq!(
err.error_type(),
"com.amazon.coral.validate#ValidationException"
);
}
#[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
#[test]
fn test_sqlite_error_maps_to_internal() {
let sqlite_err = rusqlite::Error::QueryReturnedNoRows;
let err = DynoxideError::from(sqlite_err);
assert_eq!(err.status_code(), 500);
assert!(err.error_type().contains("InternalServerError"));
}
#[test]
fn test_backend_error_envelopes_match_native() {
use crate::storage_backend::BackendError;
let v: DynoxideError = BackendError::Validation("too many tags".into()).into();
assert_eq!(v.status_code(), 400);
assert_eq!(
v.error_type(),
"com.amazon.coral.validate#ValidationException"
);
let u: DynoxideError = BackendError::Unsupported { capability: "ttl" }.into();
assert_eq!(u.status_code(), 500);
assert!(u.error_type().contains("InternalServerError"));
assert!(u.to_string().contains("ttl"));
for e in [
BackendError::NotADatabase,
BackendError::Locked,
BackendError::Constraint("constraint".into()),
BackendError::Io("io".into()),
BackendError::Other("sqlite-wasm: boom".into()),
] {
let d: DynoxideError = e.into();
assert_eq!(d.status_code(), 500);
assert!(d.error_type().contains("InternalServerError"));
}
}
#[test]
fn test_error_response_json_structure() {
let err = DynoxideError::ValidationException("1 validation error detected".to_string());
let resp = err.to_response();
let json: serde_json::Value = serde_json::to_value(&resp).unwrap();
assert!(json.get("__type").is_some());
assert!(json.get("message").is_some());
assert_eq!(
json["__type"],
"com.amazon.coral.validate#ValidationException"
);
assert_eq!(json["message"], "1 validation error detected");
}
#[test]
fn test_short_error_codes() {
assert_eq!(
DynoxideError::ResourceNotFoundException("".into()).short_error_code(),
"ResourceNotFound"
);
assert_eq!(
DynoxideError::ValidationException("".into()).short_error_code(),
"ValidationError"
);
assert_eq!(
DynoxideError::ConditionalCheckFailedException("".into(), None).short_error_code(),
"ConditionalCheckFailed"
);
assert_eq!(
DynoxideError::DuplicateItemException("".into()).short_error_code(),
"DuplicateItem"
);
assert_eq!(
DynoxideError::InternalServerError("".into()).short_error_code(),
"InternalServerError"
);
}
#[test]
fn test_transaction_cancelled_json_has_cancellation_reasons() {
let reasons = vec![
CancellationReason {
code: "ConditionalCheckFailed".to_string(),
message: Some("The conditional request failed".to_string()),
item: None,
},
CancellationReason {
code: "None".to_string(),
message: None,
item: None,
},
];
let err = DynoxideError::TransactionCanceledException(
"Transaction cancelled, please refer cancellation reasons for specific reasons [ConditionalCheckFailed, None]".to_string(),
reasons,
);
let json_str = err.to_json();
let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
assert!(json.get("CancellationReasons").is_some());
let reasons = json["CancellationReasons"].as_array().unwrap();
assert_eq!(reasons.len(), 2);
assert_eq!(reasons[0]["Code"], "ConditionalCheckFailed");
assert_eq!(reasons[1]["Code"], "None");
assert!(json.get("Message").is_some());
assert!(json.get("message").is_none());
}
#[test]
fn test_backend_error_maps_to_internal() {
use crate::storage_backend::BackendError;
let err: DynoxideError = BackendError::Locked.into();
assert_eq!(err.status_code(), 500);
assert!(err.error_type().contains("InternalServerError"));
assert!(err.to_string().contains("locked"));
}
}