Skip to content

HTTP handler

Eugene Lazutkin edited this page Jul 17, 2026 · 9 revisions

HTTP handler

See also (AWS JS SDK v3): any of the SDK Command pages this handler wraps (see linked sub-pages). Vocabulary: Concepts.

Using a framework? For Koa, Express, Fetch-API runtimes (Cloudflare Workers / Deno / Bun / Hono), or AWS Lambda, use the matching adapter package instead of this node:http handler. The wire contract (routes, query params, envelope, errors) is identical. See the Framework adapters list on the home page.

dynamodb-toolkit/handler ships a (req, res) => function that wires the REST core parsers/builders to a node:http server. Drop it into createServer and you have a working REST API in front of an Adapter. Design-level companions: URL schema design (this route pack and two alternatives), Pagination (offset vs cursor on the wire), Mass operation semantics (the mass-route contract).

import {createServer} from 'node:http';
import {createHandler} from 'dynamodb-toolkit/handler';

const handler = createHandler(adapter, {
  sortableIndices: {name: '-t-name-index'}
});
createServer(handler).listen(3000);

Standard route pack

Method URL Adapter call Response
GET / getList + envelope 200 with {data, offset, limit, total?, links?}
GET /?cursor[=token] getPage + envelope 200 with {data, limit, cursor?} — cursor paging; bare ?cursor = first page
GET /?format=jsonl getList / getPage 200 NDJSON — one item per line, no envelope; in cursor mode the next-page token rides the x-cursor header
POST / post 204; 409 if key exists
DELETE /?<op>-<field>=… / /?search=… deleteListByParams 200 with {processed, skipped?, failed?, conflicts?, cursor?}
GET /-by-keys?keys=…&fields=… getByKeys 200 with plain array (missing dropped)
DELETE /-by-keys?keys=… deleteByKeys 200 with {processed}
PUT /-load putItems (body: array) 200 with {processed}
PUT /-clone / /-move cloneListByParams / moveListByParams (body: overlay) 200 with {processed, skipped?, failed?, conflicts?, cursor?}
PUT /-clone-by-keys?keys=… / /-move-by-keys?keys=… cloneByKeys / moveByKeys 200 with {processed}
GET /:key getByKey 200 with item; 404 on miss
PUT /:key?force=true put 204; 409 without force on missing
PATCH /:key patch (body: {field, _delete, _separator, _arrayOps}) 204
DELETE /:key delete 204 (idempotent)
PUT /:key/-clone / /:key/-move single-item clone / move 204; 404 on miss

/:key URL-decodes the segment. The default keyFromPath maps it to {[adapter.keyFields[0]]: rawKey} — pass a custom keyFromPath for composite keys. The -by-names route spellings and the names query param remain as legacy aliases of -by-keys / keys.

Unscoped-delete guard. DELETE / with no scoping — no filter, no search, and an empty exampleFromContext — is rejected with 400 UnscopedMassDelete unless the request carries ?confirm=true. Disable via policy.confirmMassDelete: false.

Resumable mass ops on the wire. The mass routes (DELETE /, PUT /-clone, PUT /-move) accept ?max-items=N (soft per-request cap, page-boundary enforced) and ?resume=<token> (the cursor from a prior response). The optional mass-result keys (skipped / failed / conflicts / cursor) appear only when non-empty — a clean run still answers {processed: N}.

Query parameters

  • ?fields=name,climate — projection (passed to addProjection)
  • ?<op>-<field>=<value> — structured filter clause. Op prefixes: eq, ne, lt, le, gt, ge, in, btw, beg, ct, ex, nx. Multiple clauses AND together. Field + op must be in the adapter's filterable allowlist; unlisted rejections produce BadFilterField / BadFilterOp. Multi-value ops (in, btw) use first-character delimiter with , fallback: ?in-status=available,rented or ?btw-year=|2020|2024.
  • ?search=tooine — free-form substring search over searchable mirror columns (case-insensitive).
  • ?sort=name / ?sort=-name — ascending / descending; resolved to a GSI via sortableIndices
  • ?offset=N&limit=N — offset pagination
  • ?cursor[=token] — cursor pagination on GET /: constant cost per page, no total, no links; keep paging while the response carries cursor. Malformed tokens → 400 BadCursor
  • ?format=jsonl — NDJSON list output on GET / (json is the default; anything else → 400 BadFormat)
  • ?keys=A,B,C — for the /-by-keys routes (names is the legacy alias)
  • ?max-items=N / ?resume=<token> — resumable mass ops on the mass routes
  • ?confirm=yes|true|1|on — explicit opt-in for an unscoped DELETE /
  • ?consistent=yes|true|1|on — strong consistency
  • ?force=yes|true|1|on — overwrite without existence check on put / clone / move

Wire casing: multi-word query params are kebab-case (max-items); envelope keys are camelCase.

Body shapes

  • POST / — JSON object: the new item.
  • PUT /:key — JSON object: replacement fields (URL key merged in automatically).
  • PATCH /:key — JSON object: partial update; meta keys (_delete, _separator, _arrayOps) are extracted as patch options.
  • PUT /-load — JSON array: items to bulk-load.
  • PUT /-clone / /-move / /:key/-clone / /:key/-move — JSON object: overlay merged into each cloned/moved item.
  • PUT /-clone-by-keys / /-move-by-keys — JSON object overlay; keys come from ?keys=… query.
  • DELETE /-by-keys — keys from ?keys=…; if absent, accepts a JSON array body.

Options

interface HandlerOptions {
  policy?: RestPolicyOverrides; // partial policy; envelope / statusCodes merge key-by-key
  sortableIndices?: Record<string, string>;
  keyFromPath?: (rawKey: string, adapter: Adapter) => Record<string, unknown>;
  exampleFromContext?: (query: Record<string, string>, body: unknown) => Record<string, unknown>;
  maxBodyBytes?: number;
}
  • policy — overrides the REST core defaults (envelope keys, status codes, prefixes, paging defaults). See REST core for the full shape.
  • sortableIndices{<sort field name>: <GSI index name>}. Resolves ?sort=name to {index: '-t-name-index', descending: false}. Without an entry, ?sort=... is ignored. ('-t-name-index' here is author convention for the indexed GSI name; the toolkit only uses the name as a literal.)
  • keyFromPath(rawKey, adapter) — convert the URL :key segment into a key object. rawKey is the URL-decoded segment string; adapter gives access to adapter.keyFields so composite-key implementations know which fields to populate. Return a full key object — every entry in adapter.keyFields must be a property. Default: (raw, adapter) => ({[adapter.keyFields[0]]: raw}). Override for composite keys (e.g. split "tenant:user" on :), or to coerce types (numeric partition keys, UUID validation).
  • exampleFromContext(query, body) — build the example argument passed to the Adapter's prepareListInput(example, index) hook. query is the parsed URL query object (Record<string, string>), body is the parsed request body (unknown — typically a JSON object). Return a partial item that the hook will use to seed a KeyConditionExpression (e.g. {tenant: query.tenant} for a per-tenant list). Default returns {} — useful when prepareListInput derives everything from index alone.
  • maxBodyBytes — maximum request-body size in bytes. Bodies exceeding this are rejected with HTTP 413 PayloadTooLarge. Defaults to 1 MB (1048576). Defends against memory-exhaustion DoS from unbounded streaming bodies. Raise for consumers that legitimately send larger payloads (bulk /-load routes are a common reason); lower for tighter REST endpoints.

Error mapping

Unhandled errors flow through the policy's mapErrorStatus and errorBody. Default response shape: {code, message}. See REST core for the SDK error → HTTP status table.

If a handler throws an Error with a numeric .status between 400 and 599, that status is honored verbatim instead of being mapped (useful for the body parser's 400 BadJsonBody, the 405 MethodNotAllowed for unsupported routes, etc.).

Pagination links

When the result has total, the envelope includes links.prev / links.next (relative URLs against the current request). At edges, the corresponding link is null.

To customize URL construction (e.g., absolute URLs with a base path), set a custom policy.envelope.links key or build the response manually using paginationLinks + buildEnvelope from REST core.

Example: full server

import {createServer} from 'node:http';
import {DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {DynamoDBDocumentClient} from '@aws-sdk/lib-dynamodb';
import {Adapter} from 'dynamodb-toolkit';
import {createHandler} from 'dynamodb-toolkit/handler';

const client = new DynamoDBClient({region: 'us-east-1'});
const docClient = DynamoDBDocumentClient.from(client, {marshallOptions: {removeUndefinedValues: true}});

const adapter = new Adapter({
  client: docClient,
  table: 'planets',
  keyFields: ['name'],
  searchable: {name: 1, climate: 1, terrain: 1},
  hooks: {
    prepare(item, isPatch) {
      const out = {...item};
      for (const k of Object.keys(this.searchable)) {
        if (k in item) out['-search-' + k] = String(item[k]).toLowerCase();
      }
      if (!isPatch) out['-t'] = 1;
      return out;
    },
    revive(item) {
      const out = {};
      for (const k of Object.keys(item)) if (!k.startsWith('-')) out[k] = item[k];
      return out;
    },
    prepareListInput(_, index) {
      const idx = index || '-t-name-index';
      return {
        IndexName: idx,
        KeyConditionExpression: '#t = :t',
        ExpressionAttributeNames: {'#t': '-t'},
        ExpressionAttributeValues: {':t': 1}
      };
    }
  }
});

const handler = createHandler(adapter, {sortableIndices: {name: '-t-name-index'}});
createServer(handler).listen(3000);

Other frameworks

Ready-made bindings for Express, Koa, Fetch runtimes (Cloudflare Workers, Deno Deploy, Bun.serve, Hono), and AWS Lambda ship as subpath exports with the same wire contract — see Framework adapters. You can also skip the route pack entirely and call Adapter methods from hand-written routes — see Plain Express routes. For anything else, wire your framework's request to the same rest-core parsers and builders; the handler implementation in src/handler/handler.js serves as a reference.

Clone this wiki locally