Skip to content

Commit bdfc57e

Browse files
committed
New version: 3.1.2.
- HEAD→GET promote - body-always-parsed invariant - byte-accurate body cap - Tier-A rest-core extractions
1 parent 317e50f commit bdfc57e

24 files changed

Lines changed: 646 additions & 75 deletions

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,12 @@ The handler ships a standard route pack — `GET / POST /`, `GET PUT PATCH DELET
8585

8686
The bundled `dynamodb-toolkit/handler` is a pure `node:http` handler. Framework-specific bindings live in separate packages so the core stays zero-dep — each adapter is a thin wrapper that translates its framework's request/response shape into the toolkit's `rest-core` parsers + standard route pack. The wire contract (routes, query parameters, envelope keys, error mapping) is identical across all four.
8787

88-
| Package | Runtime / framework | Notes |
89-
| --- | --- | --- |
90-
| [`dynamodb-toolkit-koa`](https://www.npmjs.com/package/dynamodb-toolkit-koa) | [Koa](https://koajs.com/) 2.x | Middleware; `koa` as peer dep |
91-
| [`dynamodb-toolkit-express`](https://www.npmjs.com/package/dynamodb-toolkit-express) | [Express](https://expressjs.com/) 4.x / 5.x | Middleware / Router; `express` as peer dep |
92-
| [`dynamodb-toolkit-fetch`](https://www.npmjs.com/package/dynamodb-toolkit-fetch) | Fetch API — `(Request) => Promise<Response>` | Zero-framework; runs on Cloudflare Workers, Deno Deploy, Bun.serve, Hono, Node native fetch server |
93-
| [`dynamodb-toolkit-lambda`](https://www.npmjs.com/package/dynamodb-toolkit-lambda) | AWS Lambda handler | Four event shapes (API Gateway REST / HTTP, Function URL, ALB); ships local-debug bridges for running the handler on real HTTP without `sam local` |
88+
| Package | Runtime / framework | Notes |
89+
| ------------------------------------------------------------------------------------ | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
90+
| [`dynamodb-toolkit-koa`](https://www.npmjs.com/package/dynamodb-toolkit-koa) | [Koa](https://koajs.com/) 2.x | Middleware; `koa` as peer dep |
91+
| [`dynamodb-toolkit-express`](https://www.npmjs.com/package/dynamodb-toolkit-express) | [Express](https://expressjs.com/) 4.x / 5.x | Middleware / Router; `express` as peer dep |
92+
| [`dynamodb-toolkit-fetch`](https://www.npmjs.com/package/dynamodb-toolkit-fetch) | Fetch API — `(Request) => Promise<Response>` | Zero-framework; runs on Cloudflare Workers, Deno Deploy, Bun.serve, Hono, Node native fetch server |
93+
| [`dynamodb-toolkit-lambda`](https://www.npmjs.com/package/dynamodb-toolkit-lambda) | AWS Lambda handler | Four event shapes (API Gateway REST / HTTP, Function URL, ALB); ships local-debug bridges for running the handler on real HTTP without `sam local` |
9494

9595
## Sub-exports
9696

llms-full.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,12 +258,17 @@ Framework-agnostic helpers. Each parser accepts the raw query value (string or a
258258
- `parseNames(input, {maxItems?})` → `string[]`. `maxItems` default `1000`.
259259
- `parsePaging(input, {defaultLimit?, maxLimit?, maxOffset?})` → `{offset, limit}` (clamped). `maxOffset` default `100_000` — caps `?offset=` to prevent DoS via `?offset=1e15` driving `paginateList` into astronomical skip-page SDK calls.
260260
- `parseFlag(input)` → `boolean` — accepts `yes` / `true` / `1` / `on` (case-insensitive)
261+
- `coerceStringQuery(query)` → `Record<string, string>` — filters framework query bag to string values only (drops nested objects / numbers; picks first string from arrays). Null-prototype accumulator defends against `?constructor=…` / `?__proto__=…` shadowing.
261262

262263
### 6.2 Builders
263264

264265
- `buildEnvelope(result, {keys?, links?})` → wraps `{data, offset, limit, total?}` with configurable key names; optionally embeds `links`.
265266
- `buildErrorBody(err, {includeDebug?, errorId?})` → `{code, message, errorId?, stack?}`
266267
- `paginationLinks(offset, limit, total, urlBuilder?)` → `{prev: string | null, next: string | null}` — `urlBuilder` is `({offset, limit}) => string`.
268+
- `buildListOptions(query, policy)` → `ListOptionsBase` — composes `parseFields` / `parseFilter` / `parsePaging` / `parseFlag` under policy's pagination caps. Returns `{offset, limit, consistent, needTotal, fields?, filter?}` ready for `adapter.getAll`.
269+
- `resolveSort(query, sortableIndices?)` → `{index, descending}` — resolves `?sort=-name` to `{index: 'name-gsi', descending: true}` via the passed GSI mapping. Unmapped / missing fields → `{index: undefined, descending: false}`.
270+
- `stripMount(pathname, mountPath?)` → `string | null` — returns the pathname tail when the request is under `mountPath`, else `null`. Normalizes trailing slash on `mountPath` so `'/planets/'` and `'/planets'` behave identically. Empty / missing `mountPath` means "mount at root."
271+
- `validateWriteBody(body, {allowEmpty?, allowArray?}?)` → `body` — throws `{status: 400, code: 'BadBody'}` when `body` is non-object (or null/array without the matching option). Use on write-shaped routes where `{...body, ...key}` would silently accept `null` (writes key-only) or arrays (writes `{"0": …}`).
267272

268273
### 6.3 Policy
269274

@@ -304,6 +309,14 @@ const handler = createHandler(adapter, {
304309
createServer(handler).listen(3000);
305310
```
306311

312+
**HEAD → GET auto-promote.** `matchRoute` treats `HEAD` as `GET` for dispatch, annotates the result with `head: true`, and the bundled handler returns headers + `Content-Length` with an empty body. Consumers get the expected REST semantics on every resource endpoint without extra configuration.
313+
314+
**Body-always-parsed invariant.** For clone-all / move-all routes (`PUT /-clone`, `PUT /-move`), the parsed body flows into `exampleFromContext(query, body)` alongside the query — consumers can key their tenant/ownership scope on either. (Earlier versions passed `null` on these routes.)
315+
316+
**Body cap measures bytes, not UTF-16 code units.** As of 3.1.2, the built-in body reader accumulates `Buffer` chunks and enforces `maxBodyBytes` in bytes. Multi-byte payloads (CJK, emoji) are now bounded by the advertised cap; earlier versions let ~2-4× through because `String.length` counts code units.
317+
318+
**`readJsonBody(req, maxBodyBytes, {destroy?})`.** Exported helper for adapter-style code that needs the same Node-stream reader as the bundled handler. Pass `{destroy: false}` when the framework needs the socket alive to write the 413 response (Koa, Express — both hand body serialization to the framework response object).
319+
307320
### 7.1 Standard route pack
308321

309322
| Method | URL | Adapter call |

llms.txt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ await adapter.delete({name: 'Alderaan'});
3939
- `dynamodb-toolkit/batch` — `applyBatch`, `applyTransaction`, `explainTransactionCancellation`, `getBatch`, `getTransaction`, `backoff`, `TRANSACTION_LIMIT`
4040
- `dynamodb-toolkit/mass` — `paginateList`, `iterateList`, `iterateItems`, `readList`, `readListByKeys`, `readOrderedListByKeys`, `writeList`, `deleteList`, `deleteListByKeys`, `copyList`, `moveList`, `getTotal`
4141
- `dynamodb-toolkit/paths` — `getPath`, `setPath`, `deletePath`, `applyPatch`, `normalizeFields`, `subsetObject`
42-
- `dynamodb-toolkit/rest-core` — `parseFields`, `parseSort`, `parseFilter`, `parsePatch`, `parseNames`, `parsePaging`, `parseFlag`, `buildEnvelope`, `buildErrorBody`, `paginationLinks`, `defaultPolicy`, `mapErrorStatus`, `mergePolicy`
43-
- `dynamodb-toolkit/handler` — `createHandler`, `matchRoute`
42+
- `dynamodb-toolkit/rest-core` — `parseFields`, `parseSort`, `parseFilter`, `parsePatch`, `parseNames`, `parsePaging`, `parseFlag`, `coerceStringQuery`, `buildEnvelope`, `buildErrorBody`, `paginationLinks`, `buildListOptions`, `resolveSort`, `stripMount`, `validateWriteBody`, `defaultPolicy`, `mapErrorStatus`, `mergePolicy`
43+
- `dynamodb-toolkit/handler` — `createHandler`, `matchRoute`, `readJsonBody`
4444

4545
## Adapter constructor options
4646

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dynamodb-toolkit",
3-
"version": "3.1.1",
3+
"version": "3.1.2",
44
"description": "Zero-dependency DynamoDB toolkit (AWS SDK v3) — Adapter with hooks, expression builders, batch/transaction chunking, mass operations, REST handler.",
55
"type": "module",
66
"main": "./src/index.js",

src/handler/handler.js

Lines changed: 36 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -16,41 +16,20 @@ import {
1616
} from '../rest-core/index.js';
1717

1818
import {matchRoute} from './match-route.js';
19+
import {readJsonBody} from './read-json-body.js';
20+
import {Buffer} from 'node:buffer';
1921

20-
const readJsonBody = (req, maxBodyBytes) =>
21-
new Promise((resolve, reject) => {
22-
let body = '';
23-
let size = 0;
24-
let aborted = false;
25-
req.setEncoding?.('utf8');
26-
req.on('data', chunk => {
27-
if (aborted) return;
28-
const s = typeof chunk === 'string' ? chunk : chunk.toString('utf8');
29-
size += s.length;
30-
if (size > maxBodyBytes) {
31-
aborted = true;
32-
reject(Object.assign(new Error(`Request body exceeds ${maxBodyBytes} bytes`), {status: 413, code: 'PayloadTooLarge'}));
33-
req.destroy?.();
34-
return;
35-
}
36-
body += s;
37-
});
38-
req.on('end', () => {
39-
if (aborted) return;
40-
if (!body) return resolve(null);
41-
try {
42-
resolve(JSON.parse(body));
43-
} catch (err) {
44-
reject(Object.assign(err, {status: 400, code: 'BadJsonBody'}));
45-
}
46-
});
47-
req.on('error', reject);
48-
});
49-
50-
const sendJson = (res, status, body) => {
22+
const sendJson = (req, res, status, body) => {
5123
res.statusCode = status;
5224
res.setHeader('Content-Type', 'application/json; charset=utf-8');
53-
res.end(body == null ? '' : JSON.stringify(body));
25+
const serialized = body == null ? '' : JSON.stringify(body);
26+
if (req.method === 'HEAD') {
27+
// HEAD mirrors GET's headers + Content-Length with an empty body.
28+
res.setHeader('Content-Length', String(Buffer.byteLength(serialized, 'utf8')));
29+
res.end();
30+
return;
31+
}
32+
res.end(serialized);
5433
};
5534

5635
const sendNoContent = (res, status = 204) => {
@@ -100,9 +79,9 @@ export const createHandler = (adapter, options = {}) => {
10079
return {index: sortableIndices[sort.field], descending: sort.direction === 'desc'};
10180
};
10281

103-
const sendError = (res, err) => {
82+
const sendError = (req, res, err) => {
10483
const status = err?.status && err.status >= 400 && err.status < 600 ? err.status : mapErrorStatus(err, policy.statusCodes);
105-
sendJson(res, status, policy.errorBody(err));
84+
sendJson(req, res, status, policy.errorBody(err));
10685
};
10786

10887
// --- collection-level handlers ---
@@ -124,7 +103,7 @@ export const createHandler = (adapter, options = {}) => {
124103
const links = paginationLinks(result.offset, result.limit, result.total, urlBuilder);
125104
const envelopeOpts = {keys: policy.envelope};
126105
if (links.prev || links.next) envelopeOpts.links = links;
127-
sendJson(res, 200, buildEnvelope(result, envelopeOpts));
106+
sendJson(req, res, 200, buildEnvelope(result, envelopeOpts));
128107
};
129108

130109
const handlePost = async (req, res) => {
@@ -133,26 +112,26 @@ export const createHandler = (adapter, options = {}) => {
133112
sendNoContent(res);
134113
};
135114

136-
const handleDeleteAll = async (_req, res, query) => {
115+
const handleDeleteAll = async (req, res, query) => {
137116
const opts = buildListOptions(query);
138117
const {index} = resolveSort(query);
139118
const example = exampleFromContext(query, null);
140119
// For deleteAll we need the params built like getAll, but route through deleteAllByParams
141120
// by re-using the Adapter's internal list-params machinery via getAll-style options
142121
const params = await adapter._buildListParams(opts, false, example, index);
143122
const r = await adapter.deleteAllByParams(params);
144-
sendJson(res, 200, {processed: r.processed});
123+
sendJson(req, res, 200, {processed: r.processed});
145124
};
146125

147126
// --- /-by-names handlers ---
148127

149-
const handleGetByNames = async (_req, res, query) => {
128+
const handleGetByNames = async (req, res, query) => {
150129
const names = parseNames(query.names);
151130
const fields = parseFields(query.fields);
152131
const consistent = parseFlag(query.consistent);
153132
const keys = names.map(name => keyFromPath(name, adapter));
154133
const items = await adapter.getByKeys(keys, fields, {consistent});
155-
sendJson(res, 200, items);
134+
sendJson(req, res, 200, items);
156135
};
157136

158137
const handleDeleteByNames = async (req, res, query) => {
@@ -164,7 +143,7 @@ export const createHandler = (adapter, options = {}) => {
164143
}
165144
const keys = names.map(name => keyFromPath(name, adapter));
166145
const r = await adapter.deleteByKeys(keys);
167-
sendJson(res, 200, {processed: r.processed});
146+
sendJson(req, res, 200, {processed: r.processed});
168147
};
169148

170149
const handleCloneByNames = async (req, res, query) => {
@@ -175,7 +154,7 @@ export const createHandler = (adapter, options = {}) => {
175154
const overlay = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
176155
const keys = names.map(name => keyFromPath(name, adapter));
177156
const r = await adapter.cloneByKeys(keys, item => ({...item, ...overlay}));
178-
sendJson(res, 200, {processed: r.processed});
157+
sendJson(req, res, 200, {processed: r.processed});
179158
};
180159

181160
const handleMoveByNames = async (req, res, query) => {
@@ -186,52 +165,54 @@ export const createHandler = (adapter, options = {}) => {
186165
const overlay = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
187166
const keys = names.map(name => keyFromPath(name, adapter));
188167
const r = await adapter.moveByKeys(keys, item => ({...item, ...overlay}));
189-
sendJson(res, 200, {processed: r.processed});
168+
sendJson(req, res, 200, {processed: r.processed});
190169
};
191170

192171
const handleLoad = async (req, res) => {
193172
const body = await readJsonBody(req, maxBodyBytes);
194173
if (!Array.isArray(body)) {
195-
return sendError(res, Object.assign(new Error('Body must be an array of items'), {status: 400, code: 'BadLoadBody'}));
174+
return sendError(req, res, Object.assign(new Error('Body must be an array of items'), {status: 400, code: 'BadLoadBody'}));
196175
}
197176
const r = await adapter.putAll(body);
198-
sendJson(res, 200, {processed: r.processed});
177+
sendJson(req, res, 200, {processed: r.processed});
199178
};
200179

201180
const handleCloneAll = async (req, res, query) => {
202181
const body = await readJsonBody(req, maxBodyBytes);
203182
const overlay = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
204183
const opts = buildListOptions(query);
205184
const {index} = resolveSort(query);
206-
const example = exampleFromContext(query, null);
185+
// Body-always-parsed invariant: pass the parsed body (not null) to
186+
// exampleFromContext so consumers can derive scope from both query + body.
187+
const example = exampleFromContext(query, body);
207188
const params = await adapter._buildListParams(opts, false, example, index);
208189
const r = await adapter.cloneAllByParams(params, item => ({...item, ...overlay}));
209-
sendJson(res, 200, {processed: r.processed});
190+
sendJson(req, res, 200, {processed: r.processed});
210191
};
211192

212193
const handleMoveAll = async (req, res, query) => {
213194
const body = await readJsonBody(req, maxBodyBytes);
214195
const overlay = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
215196
const opts = buildListOptions(query);
216197
const {index} = resolveSort(query);
217-
const example = exampleFromContext(query, null);
198+
const example = exampleFromContext(query, body);
218199
const params = await adapter._buildListParams(opts, false, example, index);
219200
const r = await adapter.moveAllByParams(params, item => ({...item, ...overlay}));
220-
sendJson(res, 200, {processed: r.processed});
201+
sendJson(req, res, 200, {processed: r.processed});
221202
};
222203

223204
// --- item-level handlers ---
224205

225-
const handleItemGet = async (_req, res, key, query) => {
206+
const handleItemGet = async (req, res, key, query) => {
226207
const fields = parseFields(query.fields);
227208
const consistent = parseFlag(query.consistent);
228209
const item = await adapter.getByKey(key, fields, {consistent});
229210
if (item === undefined) return sendNoContent(res, policy.statusCodes.miss);
230-
sendJson(res, 200, item);
211+
sendJson(req, res, 200, item);
231212
};
232213

233214
const handleItemPut = async (req, res, key, query) => {
234-
const body = await readJsonBody(req, maxBodyBytes);
215+
const body = /** @type {Record<string, unknown> | null | undefined} */ (await readJsonBody(req, maxBodyBytes));
235216
const force = parseFlag(query.force);
236217
// Merge URL key into body so the user need not repeat it
237218
const merged = {...body, ...key};
@@ -240,7 +221,7 @@ export const createHandler = (adapter, options = {}) => {
240221
};
241222

242223
const handleItemPatch = async (req, res, key) => {
243-
const body = await readJsonBody(req, maxBodyBytes);
224+
const body = /** @type {Record<string, unknown> | null | undefined} */ (await readJsonBody(req, maxBodyBytes));
244225
const {patch, options} = parsePatch(body, {metaPrefix: policy.metaPrefix});
245226
await adapter.patch(key, patch, options);
246227
sendNoContent(res);
@@ -273,6 +254,7 @@ export const createHandler = (adapter, options = {}) => {
273254
try {
274255
const url = requestUrl(req);
275256
const query = Object.fromEntries(url.searchParams);
257+
// matchRoute promotes HEAD → GET internally; route.method is effective.
276258
const route = matchRoute(req.method, url.pathname, policy.methodPrefix);
277259

278260
switch (route.kind) {
@@ -305,9 +287,9 @@ export const createHandler = (adapter, options = {}) => {
305287
break;
306288
}
307289
}
308-
return sendError(res, Object.assign(new Error('Method not allowed for this route'), {status: 405, code: 'MethodNotAllowed'}));
290+
return sendError(req, res, Object.assign(new Error('Method not allowed for this route'), {status: 405, code: 'MethodNotAllowed'}));
309291
} catch (err) {
310-
sendError(res, err);
292+
sendError(req, res, err);
311293
}
312294
};
313295
};

src/handler/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@
55

66
export {createHandler, type HandlerOptions, type RequestHandler} from './handler.js';
77
export {matchRoute, type MatchedRoute} from './match-route.js';
8+
export {readJsonBody, type ReadJsonBodyOptions} from './read-json-body.js';

src/handler/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22

33
export {createHandler} from './handler.js';
44
export {matchRoute} from './match-route.js';
5+
export {readJsonBody} from './read-json-body.js';

src/handler/match-route.d.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,32 @@
1-
/** Discriminated union returned by {@link matchRoute}. */
1+
/**
2+
* Discriminated union returned by {@link matchRoute}.
3+
*
4+
* `method` is the *effective* method after HEAD→GET promotion — HEAD requests
5+
* dispatch through the GET handler. `head: true` signals the original was a
6+
* HEAD; callers should suppress the response body but keep headers + status.
7+
*/
28
export type MatchedRoute =
39
/** Root URL (`/`) for a given method. */
4-
| {kind: 'root'; method: string}
10+
| {kind: 'root'; method: string; head: boolean}
511
/** Method URL on the collection (`/-by-names`, `/-clone`, …). */
6-
| {kind: 'collectionMethod'; name: string; method: string}
12+
| {kind: 'collectionMethod'; name: string; method: string; head: boolean}
713
/** Single item URL (`/<key>`). */
8-
| {kind: 'item'; key: string; method: string}
14+
| {kind: 'item'; key: string; method: string; head: boolean}
915
/** Method URL on a single item (`/<key>/-clone`). */
10-
| {kind: 'itemMethod'; key: string; name: string; method: string}
16+
| {kind: 'itemMethod'; key: string; name: string; method: string; head: boolean}
1117
/** No standard route matched (deeper nesting, unexpected prefix). */
12-
| {kind: 'unknown'; method: string};
18+
| {kind: 'unknown'; method: string; head: boolean};
1319

1420
/**
1521
* Match an HTTP method + URL pathname against the standard route shapes.
1622
* URL-decodes key segments. Configurable method prefix.
1723
*
24+
* `HEAD` requests are matched as `GET` (so they dispatch through the same
25+
* read-side handler) and annotated with `head: true` on the result so the
26+
* caller can skip body writes in the response — the REST convention is that
27+
* HEAD returns the same headers + `Content-Length` as the equivalent GET
28+
* but an empty body.
29+
*
1830
* @param method Request method (e.g. `'GET'`).
1931
* @param path URL pathname (no query string).
2032
* @param methodPrefix Character(s) that mark method URLs. Default `'-'`.

0 commit comments

Comments
 (0)