1use serde::Serialize;
2use std::collections::HashMap;
3use std::fmt;
4
5#[derive(Debug, Clone, Default, Serialize)]
10pub struct CancellationReason {
11 #[serde(rename = "Code")]
12 pub code: String,
13 #[serde(rename = "Message", skip_serializing_if = "Option::is_none")]
14 pub message: Option<String>,
15 #[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
16 pub item: Option<HashMap<String, crate::types::AttributeValue>>,
17}
18
19#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum DynoxideError {
30 #[error("{0}")]
32 ResourceNotFoundException(String),
33
34 #[error("{0}")]
36 ResourceInUseException(String),
37
38 #[error("{0}")]
40 ValidationException(String),
41
42 #[error("{0}")]
47 KeyEmptyValueValidation(String),
48
49 #[error("{0}")]
54 EnvelopedValidation(String),
55
56 #[error("{0}")]
59 ConditionalCheckFailedException(
60 String,
61 Option<HashMap<String, crate::types::AttributeValue>>,
62 ),
63
64 #[error("{0}")]
67 TransactionCanceledException(String, Vec<CancellationReason>),
68
69 #[error("{0}")]
71 ItemCollectionSizeLimitExceededException(String),
72
73 #[error("{0}")]
75 DuplicateItemException(String),
76
77 #[error("{0}")]
79 ProvisionedThroughputExceededException(String),
80
81 #[error("{0}")]
83 SerializationException(String),
84
85 #[error("{0}")]
87 LimitExceededException(String),
88
89 #[error("{0}")]
91 AccessDeniedException(String),
92
93 #[error("{0}")]
95 IdempotentParameterMismatchException(String),
96
97 #[error("{0}")]
99 InternalServerError(String),
100
101 #[error("Conversion error: {0}")]
103 ConversionError(#[from] crate::types::ConversionError),
104
105 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
107 #[error("Internal error: {0}")]
108 SqliteError(#[from] rusqlite::Error),
109
110 #[cfg(feature = "wasm-sqlite")]
114 #[error("{0}")]
115 OpfsUnavailable(String),
116}
117
118impl From<crate::storage_backend::BackendError> for DynoxideError {
134 fn from(err: crate::storage_backend::BackendError) -> Self {
135 use crate::storage_backend::BackendError;
136 match err {
137 BackendError::Validation(msg) => DynoxideError::ValidationException(msg),
138 #[cfg(feature = "wasm-sqlite")]
139 BackendError::OpfsUnavailable(msg) => DynoxideError::OpfsUnavailable(msg),
140 other => DynoxideError::InternalServerError(other.to_string()),
141 }
142 }
143}
144
145impl DynoxideError {
146 pub fn error_type(&self) -> &'static str {
148 match self {
149 DynoxideError::ResourceNotFoundException(_) => {
150 "com.amazonaws.dynamodb.v20120810#ResourceNotFoundException"
151 }
152 DynoxideError::ResourceInUseException(_) => {
153 "com.amazonaws.dynamodb.v20120810#ResourceInUseException"
154 }
155 DynoxideError::ValidationException(_)
156 | DynoxideError::KeyEmptyValueValidation(_)
157 | DynoxideError::EnvelopedValidation(_) => {
158 "com.amazon.coral.validate#ValidationException"
159 }
160 DynoxideError::ConditionalCheckFailedException(..) => {
161 "com.amazonaws.dynamodb.v20120810#ConditionalCheckFailedException"
162 }
163 DynoxideError::TransactionCanceledException(..) => {
164 "com.amazonaws.dynamodb.v20120810#TransactionCanceledException"
165 }
166 DynoxideError::DuplicateItemException(_) => {
167 "com.amazonaws.dynamodb.v20120810#DuplicateItemException"
168 }
169 DynoxideError::ItemCollectionSizeLimitExceededException(_) => {
170 "com.amazonaws.dynamodb.v20120810#ItemCollectionSizeLimitExceededException"
171 }
172 DynoxideError::ProvisionedThroughputExceededException(_) => {
173 "com.amazonaws.dynamodb.v20120810#ProvisionedThroughputExceededException"
174 }
175 DynoxideError::SerializationException(_) => {
176 "com.amazon.coral.service#SerializationException"
177 }
178 DynoxideError::LimitExceededException(_) => {
179 "com.amazonaws.dynamodb.v20120810#LimitExceededException"
180 }
181 DynoxideError::AccessDeniedException(_) => {
182 "com.amazonaws.dynamodb.v20120810#AccessDeniedException"
183 }
184 DynoxideError::IdempotentParameterMismatchException(_) => {
185 "com.amazonaws.dynamodb.v20120810#IdempotentParameterMismatchException"
186 }
187 DynoxideError::ConversionError(_) => "com.amazon.coral.validate#ValidationException",
188 DynoxideError::InternalServerError(_) => {
189 "com.amazonaws.dynamodb.v20120810#InternalServerError"
190 }
191 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
192 DynoxideError::SqliteError(_) => "com.amazonaws.dynamodb.v20120810#InternalServerError",
193 #[cfg(feature = "wasm-sqlite")]
194 DynoxideError::OpfsUnavailable(_) => "com.dynoxide.wasm#OpfsUnavailable",
195 }
196 }
197
198 pub fn short_error_code(&self) -> &'static str {
203 match self {
204 DynoxideError::ResourceNotFoundException(_) => "ResourceNotFound",
205 DynoxideError::ResourceInUseException(_) => "ResourceInUse",
206 DynoxideError::ValidationException(_)
207 | DynoxideError::KeyEmptyValueValidation(_)
208 | DynoxideError::EnvelopedValidation(_)
209 | DynoxideError::ConversionError(_) => "ValidationError",
210 DynoxideError::ConditionalCheckFailedException(..) => "ConditionalCheckFailed",
211 DynoxideError::TransactionCanceledException(..) => "TransactionConflict",
212 DynoxideError::DuplicateItemException(_) => "DuplicateItem",
213 DynoxideError::ItemCollectionSizeLimitExceededException(_) => {
214 "ItemCollectionSizeLimitExceeded"
215 }
216 DynoxideError::ProvisionedThroughputExceededException(_) => {
217 "ProvisionedThroughputExceeded"
218 }
219 DynoxideError::AccessDeniedException(_) => "AccessDenied",
220 DynoxideError::IdempotentParameterMismatchException(_) => "IdempotentParameterMismatch",
221 DynoxideError::SerializationException(_) => "SerializationError",
222 DynoxideError::LimitExceededException(_) => "RequestLimitExceeded",
223 DynoxideError::InternalServerError(_) => "InternalServerError",
224 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
225 DynoxideError::SqliteError(_) => "InternalServerError",
226 #[cfg(feature = "wasm-sqlite")]
227 DynoxideError::OpfsUnavailable(_) => "OpfsUnavailable",
228 }
229 }
230
231 pub fn status_code(&self) -> u16 {
233 match self {
234 DynoxideError::InternalServerError(_) => 500,
235 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
236 DynoxideError::SqliteError(_) => 500,
237 _ => 400,
238 }
239 }
240
241 pub fn to_response(&self) -> ErrorResponse {
243 let item = if let DynoxideError::ConditionalCheckFailedException(_, item) = self {
244 item.clone()
245 } else {
246 None
247 };
248 ErrorResponse {
249 error_type: self.error_type().to_string(),
250 message: self.to_string(),
251 item,
252 }
253 }
254
255 pub fn to_json(&self) -> String {
261 let error_type = self.error_type();
262 let message = self.to_string();
263
264 match self {
265 DynoxideError::TransactionCanceledException(_, reasons) => {
266 let mut m = serde_json::Map::new();
267 m.insert(
268 "__type".to_string(),
269 serde_json::Value::String(error_type.to_string()),
270 );
271 m.insert("Message".to_string(), serde_json::Value::String(message));
272 if let Ok(reasons_val) = serde_json::to_value(reasons) {
273 m.insert("CancellationReasons".to_string(), reasons_val);
274 }
275 serde_json::to_string(&m).unwrap_or_default()
276 }
277 DynoxideError::SerializationException(_) => {
278 let mut m = serde_json::Map::new();
279 m.insert(
280 "__type".to_string(),
281 serde_json::Value::String(error_type.to_string()),
282 );
283 m.insert("Message".to_string(), serde_json::Value::String(message));
284 serde_json::to_string(&m).unwrap_or_default()
285 }
286 _ => {
287 let resp = self.to_response();
288 serde_json::to_string(&resp).unwrap_or_default()
289 }
290 }
291 }
292}
293
294#[derive(Debug, Serialize)]
296pub struct ErrorResponse {
297 #[serde(rename = "__type")]
298 pub error_type: String,
299 #[serde(rename = "message")]
300 pub message: String,
301 #[serde(rename = "Item", skip_serializing_if = "Option::is_none")]
302 pub item: Option<HashMap<String, crate::types::AttributeValue>>,
303}
304
305impl fmt::Display for ErrorResponse {
306 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307 write!(f, "{}", serde_json::to_string(self).unwrap_or_default())
308 }
309}
310
311pub type Result<T> = std::result::Result<T, DynoxideError>;
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn test_error_response_format() {
320 let err = DynoxideError::ResourceNotFoundException(
321 "Requested resource not found: Table: NonExistent not found".to_string(),
322 );
323 let resp = err.to_response();
324 let json = serde_json::to_string(&resp).unwrap();
325
326 assert!(json.contains("\"__type\""));
327 assert!(json.contains("ResourceNotFoundException"));
328 assert!(json.contains("NonExistent not found"));
329 }
330
331 #[test]
332 fn test_status_codes() {
333 assert_eq!(
334 DynoxideError::ResourceNotFoundException("".into()).status_code(),
335 400
336 );
337 assert_eq!(
338 DynoxideError::ResourceInUseException("".into()).status_code(),
339 400
340 );
341 assert_eq!(
342 DynoxideError::ValidationException("".into()).status_code(),
343 400
344 );
345 assert_eq!(
346 DynoxideError::ConditionalCheckFailedException("".into(), None).status_code(),
347 400
348 );
349 assert_eq!(
350 DynoxideError::TransactionCanceledException("".into(), vec![]).status_code(),
351 400
352 );
353 assert_eq!(
354 DynoxideError::InternalServerError("".into()).status_code(),
355 500
356 );
357 }
358
359 #[test]
360 fn test_key_empty_value_validation_is_wire_identical_to_validation_exception() {
361 let messages = [
364 "One or more parameter values are not valid. The AttributeValue for a key \
365 attribute cannot contain an empty string value. Key: pk",
366 "One or more parameter values are not valid. The AttributeValue for a key \
367 attribute cannot contain an empty binary value. Key: pk",
368 ];
369 for msg in messages {
370 let empty = DynoxideError::KeyEmptyValueValidation(msg.to_string());
371 let plain = DynoxideError::ValidationException(msg.to_string());
372 assert_eq!(empty.status_code(), plain.status_code());
373 assert_eq!(empty.error_type(), plain.error_type());
374 assert_eq!(empty.short_error_code(), plain.short_error_code());
375 assert_eq!(empty.to_json(), plain.to_json());
376 assert_eq!(empty.to_string(), plain.to_string());
377 }
378 }
379
380 #[test]
381 fn test_enveloped_validation_is_wire_identical_to_validation_exception() {
382 let msg = "One or more parameter values were invalid: \
385 Type mismatch for key pk expected: S actual: N";
386 let enveloped = DynoxideError::EnvelopedValidation(msg.to_string());
387 let plain = DynoxideError::ValidationException(msg.to_string());
388 assert_eq!(enveloped.status_code(), plain.status_code());
389 assert_eq!(enveloped.error_type(), plain.error_type());
390 assert_eq!(enveloped.short_error_code(), plain.short_error_code());
391 assert_eq!(enveloped.to_json(), plain.to_json());
392 assert_eq!(enveloped.to_string(), plain.to_string());
393 }
394
395 #[test]
396 fn test_error_type_strings() {
397 let err = DynoxideError::ValidationException("bad input".into());
398 assert_eq!(
399 err.error_type(),
400 "com.amazon.coral.validate#ValidationException"
401 );
402 }
403
404 #[cfg(any(feature = "native-sqlite", feature = "_has-encryption"))]
405 #[test]
406 fn test_sqlite_error_maps_to_internal() {
407 let sqlite_err = rusqlite::Error::QueryReturnedNoRows;
408 let err = DynoxideError::from(sqlite_err);
409 assert_eq!(err.status_code(), 500);
410 assert!(err.error_type().contains("InternalServerError"));
411 }
412
413 #[test]
422 fn test_backend_error_envelopes_match_native() {
423 use crate::storage_backend::BackendError;
424
425 let v: DynoxideError = BackendError::Validation("too many tags".into()).into();
427 assert_eq!(v.status_code(), 400);
428 assert_eq!(
429 v.error_type(),
430 "com.amazon.coral.validate#ValidationException"
431 );
432
433 let u: DynoxideError = BackendError::Unsupported { capability: "ttl" }.into();
436 assert_eq!(u.status_code(), 500);
437 assert!(u.error_type().contains("InternalServerError"));
438 assert!(u.to_string().contains("ttl"));
439
440 for e in [
443 BackendError::NotADatabase,
444 BackendError::Locked,
445 BackendError::Constraint("constraint".into()),
446 BackendError::Io("io".into()),
447 BackendError::Other("sqlite-wasm: boom".into()),
448 ] {
449 let d: DynoxideError = e.into();
450 assert_eq!(d.status_code(), 500);
451 assert!(d.error_type().contains("InternalServerError"));
452 }
453 }
454
455 #[test]
456 fn test_error_response_json_structure() {
457 let err = DynoxideError::ValidationException("1 validation error detected".to_string());
458 let resp = err.to_response();
459 let json: serde_json::Value = serde_json::to_value(&resp).unwrap();
460
461 assert!(json.get("__type").is_some());
462 assert!(json.get("message").is_some());
463 assert_eq!(
464 json["__type"],
465 "com.amazon.coral.validate#ValidationException"
466 );
467 assert_eq!(json["message"], "1 validation error detected");
468 }
469
470 #[test]
471 fn test_short_error_codes() {
472 assert_eq!(
473 DynoxideError::ResourceNotFoundException("".into()).short_error_code(),
474 "ResourceNotFound"
475 );
476 assert_eq!(
477 DynoxideError::ValidationException("".into()).short_error_code(),
478 "ValidationError"
479 );
480 assert_eq!(
481 DynoxideError::ConditionalCheckFailedException("".into(), None).short_error_code(),
482 "ConditionalCheckFailed"
483 );
484 assert_eq!(
485 DynoxideError::DuplicateItemException("".into()).short_error_code(),
486 "DuplicateItem"
487 );
488 assert_eq!(
489 DynoxideError::InternalServerError("".into()).short_error_code(),
490 "InternalServerError"
491 );
492 }
493
494 #[test]
495 fn test_transaction_cancelled_json_has_cancellation_reasons() {
496 let reasons = vec![
497 CancellationReason {
498 code: "ConditionalCheckFailed".to_string(),
499 message: Some("The conditional request failed".to_string()),
500 item: None,
501 },
502 CancellationReason {
503 code: "None".to_string(),
504 message: None,
505 item: None,
506 },
507 ];
508 let err = DynoxideError::TransactionCanceledException(
509 "Transaction cancelled, please refer cancellation reasons for specific reasons [ConditionalCheckFailed, None]".to_string(),
510 reasons,
511 );
512 let json_str = err.to_json();
513 let json: serde_json::Value = serde_json::from_str(&json_str).unwrap();
514
515 assert!(json.get("CancellationReasons").is_some());
517 let reasons = json["CancellationReasons"].as_array().unwrap();
518 assert_eq!(reasons.len(), 2);
519 assert_eq!(reasons[0]["Code"], "ConditionalCheckFailed");
520 assert_eq!(reasons[1]["Code"], "None");
521
522 assert!(json.get("Message").is_some());
524 assert!(json.get("message").is_none());
525 }
526
527 #[test]
528 fn test_backend_error_maps_to_internal() {
529 use crate::storage_backend::BackendError;
530 let err: DynoxideError = BackendError::Locked.into();
531 assert_eq!(err.status_code(), 500);
532 assert!(err.error_type().contains("InternalServerError"));
533 assert!(err.to_string().contains("locked"));
534 }
535}