dynoxide-rs 3.0.0

An embeddable DynamoDB emulator backed by SQLite. Starts in under a millisecond, and is verified against real AWS by a public conformance suite.
Documentation
//! A request DynamoDB refuses with a constraint message echoes the member it
//! refused, in the Java model's `toString` form, which names all sixteen of a
//! value's fields and so runs to about twenty times the request's own size.
//! Dynoxide bounds the echo: a rejected request near the body limit must not
//! build an error message hundreds of megabytes long. The bound is Dynoxide's
//! own, well above any echo of an ordinary request; DynamoDB's behaviour for
//! echoes this size has not been captured.

use dynoxide::Database;
use dynoxide::actions::batch_execute_statement::{
    BatchExecuteStatementRequest, BatchStatementRequest,
};
use dynoxide::actions::batch_get_item::{BatchGetItemRequest, KeysAndAttributes};
use dynoxide::actions::batch_write_item::{BatchWriteItemRequest, PutRequest, WriteRequest};
use dynoxide::actions::execute_transaction::{ExecuteTransactionRequest, ParameterizedStatement};
use dynoxide::actions::transact_get_items::{
    TransactGet, TransactGetItem, TransactGetItemsRequest,
};
use dynoxide::actions::transact_write_items::{
    TransactPut, TransactWriteItem, TransactWriteItemsRequest,
};
use dynoxide::errors::DynoxideError;
use dynoxide::types::AttributeValue;
use std::collections::HashMap;

/// A little over the bound, so a test can tell a capped echo from an uncapped
/// one: the uncapped echoes below run to megabytes or tens of megabytes.
const BOUNDED: usize = 1_200_000;

/// The bound itself (Dynoxide's `ECHO_LIMIT`, 1 MiB). A cut echo runs to it and
/// then ends `...`, so the message is longer than this.
const LIMIT: usize = 1024 * 1024;

/// A capped echo reached the limit and was cut there: the message is longer
/// than the limit, shorter than [`BOUNDED`], and the echo ends `...` just
/// before the member it names.
fn assert_cut(message: &str) {
    assert!(
        message.len() > LIMIT,
        "the echo was cut short: {} bytes",
        message.len()
    );
    assert!(
        message.len() < BOUNDED,
        "the echo ran to {} bytes",
        message.len()
    );
    assert!(
        message.contains("...' at '"),
        "the cut echo doesn't end with ...: {}",
        &message[message.len().saturating_sub(200)..]
    );
}

fn message(err: DynoxideError) -> String {
    match err {
        DynoxideError::ValidationException(m) => m,
        other => panic!("expected a ValidationException, got {other:?}"),
    }
}

/// BatchWriteItem echoes the whole RequestItems map when a table name breaks
/// its constraints. One item of 200,000 small attributes is about 3 MB of
/// request and about 40 MB of echo.
#[test]
fn a_batch_write_echo_is_bounded() {
    let db = Database::memory().unwrap();
    let item: HashMap<String, AttributeValue> = (0..200_000)
        .map(|i| (format!("a{i}"), AttributeValue::N("1".to_string())))
        .collect();
    let mut request_items = HashMap::new();
    request_items.insert(
        "a!".to_string(),
        vec![WriteRequest {
            put_request: Some(PutRequest { item }),
            delete_request: None,
        }],
    );
    let err = db
        .batch_write_item(BatchWriteItemRequest {
            request_items,
            ..Default::default()
        })
        .unwrap_err();
    let message = message(err);
    assert!(
        message.starts_with("1 validation error detected: Value '{a!=[WriteRequest(putRequest="),
        "unexpected message start: {}",
        &message[..message.len().min(200)]
    );
    assert_cut(&message);
}

/// BatchExecuteStatement echoes the statement list when it holds more than 25.
/// Twenty-six statements of 2,000 parameters each are about 1.5 MB of request
/// and about 10 MB of echo.
#[test]
fn a_batch_statement_echo_is_bounded() {
    let db = Database::memory().unwrap();
    let statements: Vec<BatchStatementRequest> = (0..26)
        .map(|_| {
            let mut statement = BatchStatementRequest::default();
            statement.statement = "SELECT * FROM \"T\" WHERE pk = ?".to_string();
            statement.parameters = Some(
                (0..2_000)
                    .map(|_| AttributeValue::N("1".to_string()))
                    .collect(),
            );
            statement
        })
        .collect();
    let err = db
        .batch_execute_statement(BatchExecuteStatementRequest {
            statements,
            ..Default::default()
        })
        .unwrap_err();
    let message = message(err);
    assert!(
        message.starts_with("1 validation error detected: Value '[BatchStatementRequest("),
        "unexpected message start: {}",
        &message[..message.len().min(200)]
    );
    assert_cut(&message);
}

/// ExecuteTransaction echoes its statements when there are more than 100.
/// 101 statements of 2,000 number parameters each are about 1.5 MB of request
/// and about 34 MB of echo.
#[test]
fn a_transaction_statement_echo_is_bounded() {
    let db = Database::memory().unwrap();
    let statements: Vec<ParameterizedStatement> = (0..101)
        .map(|_| ParameterizedStatement {
            statement: "SELECT * FROM \"T\" WHERE pk = ?".to_string(),
            parameters: Some(
                (0..2_000)
                    .map(|_| AttributeValue::N("1".to_string()))
                    .collect(),
            ),
            ..Default::default()
        })
        .collect();
    let err = db
        .execute_transaction(ExecuteTransactionRequest {
            transact_statements: statements,
            ..Default::default()
        })
        .unwrap_err();
    let message = message(err);
    assert!(
        message.starts_with("1 validation error detected: Value '[ParameterizedStatement("),
        "unexpected message start: {}",
        &message[..message.len().min(200)]
    );
    assert_cut(&message);
}

/// BatchGetItem echoes its RequestItems map as JSON when a table name breaks
/// its constraints. That echo is no larger than the request, but it is cut at
/// the same bound: 100 keys of 20,000-character strings are about 2 MB.
#[test]
fn a_batch_get_echo_is_bounded() {
    let db = Database::memory().unwrap();
    let keys: Vec<HashMap<String, AttributeValue>> = (0..100)
        .map(|i| {
            HashMap::from([(
                "pk".to_string(),
                AttributeValue::S(format!("{i}{}", "x".repeat(20_000))),
            )])
        })
        .collect();
    let mut request_items = HashMap::new();
    request_items.insert(
        "a!".to_string(),
        KeysAndAttributes {
            keys,
            ..Default::default()
        },
    );
    let err = db
        .batch_get_item(BatchGetItemRequest {
            request_items,
            ..Default::default()
        })
        .unwrap_err();
    let message = message(err);
    assert!(
        message.starts_with("1 validation error detected: Value '{\"a!\":"),
        "unexpected message start: {}",
        &message[..message.len().min(200)]
    );
    assert_cut(&message);
}

/// A key 20,000 characters long, distinct for each `i`.
fn long_key(i: usize) -> HashMap<String, AttributeValue> {
    HashMap::from([(
        "pk".to_string(),
        AttributeValue::S(format!("{i}{}", "x".repeat(20_000))),
    )])
}

/// TransactWriteItems lists its actions when it holds more than 100. A hundred
/// and one puts of a 20,000-character attribute are about 2 MB of request.
#[test]
fn a_transaction_write_listing_is_bounded() {
    let db = Database::memory().unwrap();
    let transact_items = (0..101)
        .map(|i| TransactWriteItem {
            put: Some(TransactPut {
                table_name: "T".to_string(),
                item: long_key(i),
                ..Default::default()
            }),
            ..Default::default()
        })
        .collect();
    let err = db
        .transact_write_items(TransactWriteItemsRequest {
            transact_items,
            ..Default::default()
        })
        .unwrap_err();
    let message = message(err);
    assert!(
        message.starts_with("1 validation error detected: Value '[TransactWriteItem("),
        "unexpected message start: {}",
        &message[..message.len().min(200)]
    );
    assert_cut(&message);
}

/// TransactGetItems lists its gets when it holds more than 100, in the same
/// form.
#[test]
fn a_transaction_get_listing_is_bounded() {
    let db = Database::memory().unwrap();
    let transact_items = (0..101)
        .map(|i| TransactGetItem {
            get: TransactGet {
                table_name: "T".to_string(),
                key: long_key(i),
                ..Default::default()
            },
        })
        .collect();
    let err = db
        .transact_get_items(TransactGetItemsRequest {
            transact_items,
            ..Default::default()
        })
        .unwrap_err();
    let message = message(err);
    assert!(
        message.starts_with("1 validation error detected: Value '[TransactGetItem("),
        "unexpected message start: {}",
        &message[..message.len().min(200)]
    );
    assert_cut(&message);
}