Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion crates/server/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ pub(crate) async fn handle_request(
// --- Pre-auth body validation ---
// Real DynamoDB validates request format (empty body, invalid JSON) before
// authentication. Malformed requests get 400 regardless of auth state.
let input: Value = match serde_json::from_slice(&body) {
let mut input: Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(e) => {
return error_response(
Expand All @@ -88,6 +88,16 @@ pub(crate) async fn handle_request(
);
}
};
// Any build that compiles the MongoDB backend crate (the MongoDB binary,
// or a build of the whole workspace) gets serde_json's `preserve_order`
// feature through `bson`, so there a parsed object keeps the order the
// client wrote its members in; other builds sort them by name. The typed
// input deserializers stop at the first member that fails, so on such a
// build a body with two faults got an error chosen by the client's member
// order. DynamoDB answered each two-fault Query and Scan body measured on
// 2026-11-03 alike in both orders. Sorting gives every build the sorted
// order; without `preserve_order` the call does nothing.
input.sort_all_objects();

// --- Auth segment ---
let auth_start = std::time::Instant::now();
Expand Down
110 changes: 110 additions & 0 deletions tests/test_request_member_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Copyright 2026 ExtendDB contributors
# SPDX-License-Identifier: Apache-2.0

"""The order of a request body's members does not change the error it gets.

Each body below has two faults that ExtendDB's typed input deserializer
catches in different members. A server that reads the members in the order
the client wrote them answers the two orders with different errors; the
MongoDB build did, because ``bson`` turns on ``serde_json``'s
``preserve_order`` feature for the whole binary. Amazon DynamoDB answered
both orders of each body with the same error (us-east-1, 2026-11-03).

The bodies are posted as written, signed with the credentials the client
signs with, because botocore refuses these values before it sends them.

Dual-target against Amazon DynamoDB and ExtendDB.
"""

from __future__ import annotations

import json
from typing import Any

import pytest
import requests
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest

from conftest import scoped_table

P = {"S": "p0"}


@pytest.fixture(scope="module")
def hash_table(dynamodb_client):
"""Hash-only table for the module, deleted on teardown."""
with scoped_table(dynamodb_client) as name:
yield name


def _answer(client, endpoint_url: str | None, operation: str, body: dict[str, Any]):
"""POST ``body`` with its members in insertion order and return the HTTP
status, the error code and the message."""
url = endpoint_url or client.meta.endpoint_url
payload = json.dumps(body).encode("utf-8")
headers = {
"X-Amz-Target": f"DynamoDB_20120810.{operation}",
"Content-Type": "application/x-amz-json-1.0",
}
request = AWSRequest(method="POST", url=url, data=payload, headers=headers)
creds = client._request_signer._credentials # noqa: SLF001 - test helper
SigV4Auth(creds.get_frozen_credentials(), "dynamodb", client.meta.region_name).add_auth(
request
)
# The certificate `extenddb init` writes is self-signed; the service's is not.
resp = requests.post(
url, data=payload, headers=dict(request.headers), verify=endpoint_url is None, timeout=30
)
doc = resp.json()
return (
resp.status_code,
doc.get("__type", "").rsplit("#", 1)[-1],
doc.get("message", doc.get("Message", "")),
)


@pytest.mark.parametrize(
"limit, value",
[
pytest.param(0.0, {}, id="float_limit_empty_value"),
pytest.param("0", {}, id="string_limit_empty_value"),
],
)
def test_query_limit_and_value_faults_answer_alike_in_either_order(
dynamodb_client, endpoint_url, hash_table, limit, value
):
values = {":p": P, ":a": value}
head = {"TableName": hash_table, "KeyConditionExpression": "pk = :p"}
limit_first = _answer(
dynamodb_client,
endpoint_url,
"Query",
{**head, "Limit": limit, "ExpressionAttributeValues": values},
)
values_first = _answer(
dynamodb_client,
endpoint_url,
"Query",
{**head, "ExpressionAttributeValues": values, "Limit": limit},
)
assert limit_first[0] == 400, limit_first
assert values_first == limit_first


def test_two_invalid_values_answer_alike_in_either_order(dynamodb_client, endpoint_url, hash_table):
head = {"TableName": hash_table, "FilterExpression": "pk IN (:p, :a, :b)"}
b_first = _answer(
dynamodb_client,
endpoint_url,
"Scan",
{**head, "ExpressionAttributeValues": {":p": P, ":b": {}, ":a": {"X": "1"}}},
)
a_first = _answer(
dynamodb_client,
endpoint_url,
"Scan",
{**head, "ExpressionAttributeValues": {":p": P, ":a": {"X": "1"}, ":b": {}}},
)
assert b_first[0] == 400, b_first
assert a_first == b_first
Loading