Skip to content

Framework adapters

Eugene Lazutkin edited this page Jul 16, 2026 · 3 revisions

Framework adapters

The toolkit ships its REST route pack for four HTTP surfaces as subpath exports. Every adapter is a thin port over the same internal engine — identical routes, envelopes, status codes, and error mapping — translated to its framework's request/response shape. Frameworks are duck-typed at runtime, so the core keeps zero runtime dependencies.

Sub-export Factory Serves Details
dynamodb-toolkit/express createExpressAdapter Express 4.x / 5.x middleware Express adapter
dynamodb-toolkit/koa createKoaAdapter Koa 2.x / 3.x middleware Koa adapter
dynamodb-toolkit/fetch createFetchAdapter (Request) => Promise<Response> — Cloudflare Workers, Deno Deploy, Bun.serve, Hono, Node Fetch adapter
dynamodb-toolkit/lambda createLambdaAdapter AWS Lambda — API Gateway REST / HTTP, Function URL, ALB Lambda adapter

All four take the same two arguments: the Adapter instance doing the DynamoDB work, and an options bag documented below. The bundled node:http handler (HTTP handler) speaks the same wire contract; the former standalone dynamodb-toolkit-{koa,express,fetch,lambda} packages are superseded by these subpaths.

Routes

Routes are rooted at the adapter's mount point. All requests and responses use JSON; the envelope keys, status codes, and the - method-prefix are configurable via options.policy (see REST core).

Method Path Adapter call Response
GET / getList(opts, example, index) 200 — list envelope
POST / post(body) 204 — no body
DELETE / deleteListByParams(params) 200 {processed: N}
GET /-by-names getByKeys(keys, fields, opts) 200 — array of items
DELETE /-by-names deleteByKeys(keys) 200 {processed: N}
PUT /-load putItems(items) 200 {processed: N}
PUT /-clone cloneListByParams(params, mapFn) 200 {processed: N}
PUT /-move moveListByParams(params, mapFn) 200 {processed: N}
PUT /-clone-by-names cloneByKeys(keys, mapFn) 200 {processed: N}
PUT /-move-by-names moveByKeys(keys, mapFn) 200 {processed: N}
GET /:key getByKey(key, fields, opts) 200 item, or 404 on miss
PUT /:key put({...body, ...key}, {force}) 204 — no body
PATCH /:key patch(key, patch, patchOptions) 204 — no body
DELETE /:key delete(key) 204 — no body
PUT /:key/-clone clone(key, mapFn, {force}) 204 / 404
PUT /:key/-move move(key, mapFn, {force}) 204 / 404

Notes shared by all four adapters:

  • HEAD /:key auto-promotes to GET /:key via matchRoute.
  • A known route shape with an unsupported method returns 405 Method Not Allowed with a JSON error body.
  • Miss handling differs by adapter: Express and Koa hand unknown route shapes back to the middleware chain (next()); the Fetch adapter answers an empty 404 or runs its onMiss hook; the Lambda adapter answers an empty 404.
  • DELETE /-by-names, PUT /-clone-by-names, and PUT /-move-by-names accept the key list either as ?names=a,b,c or as a JSON array body when the query is absent. The -clone / -move variants also accept an object body as an overlay merged into every processed item; sending both requires ?names=… in the query plus the overlay object in the body.
  • Write routes (POST /, PUT /:key, PATCH /:key) reject non-object bodies with 400 BadBody; malformed JSON is 400 BadJsonBody; over-cap bodies are 413 PayloadTooLarge.
  • Query parameters for list routes (paging, projection, filtering, sorting) are parsed by REST core — see that page for the accepted grammar and DoS caps.

Shared options

Every factory accepts (adapter, options?) with these options (each adapter's .d.ts carries the full JSDoc):

  • policy — partial overrides for the REST policy (envelope keys, status codes, limits, metaPrefix, methodPrefix), merged with the default. Reference: REST core.
  • sortableIndices — map from public sort-field name to the index that provides that ordering; ?sort=name resolves to {index: sortableIndices.name, descending: false} and is passed to the Adapter's list call.
  • keyFromPath(rawKey, adapter) — convert the URL :key segment into a key object. Runs on every keyed route. Default: (raw, adp) => ({[adp.keyFields[0].name]: raw}). See Composite keys from the URL below.
  • exampleFromContext(context) — build the example object passed to Adapter.prepareListInput. Runs on GET /, DELETE /, PUT /-clone, and PUT /-move. The context is an options bag {query, body, adapter, framework, ...} where framework is the discriminator ('express' | 'koa' | 'fetch' | 'lambda') and the remaining fields are framework-specific (req on Express, ctx on Koa, request on Fetch, event + context on Lambda) — one tenant-scoping callback can serve all four adapters by branching on framework. See Per-tenant filtering below.
  • maxBodyBytes — byte cap for request bodies, default 1048576 (1 MiB). Enforcement details differ per adapter (pre-parsed bodies on Express/Koa bypass it; Fetch enforces pre-stream and mid-stream; Lambda enforces on decoded bytes) — see the per-adapter pages.

Adapter-specific options: mountPath (Fetch, Lambda) strips a path prefix before route matching; onMiss (Fetch only) composes the adapter into a parent router. Documented on Fetch adapter and Lambda adapter.

Composite keys from the URL

The default keyFromPath covers single-field keys. For composite keys, parse the :key segment yourself — the callback is framework-independent, so these patterns work with every adapter:

// Two-part keys with a URL-safe delimiter
keyFromPath: raw => {
  const [customerId, orderId] = raw.split(':');
  if (!customerId || !orderId) {
    throw Object.assign(new Error('Key must be "customerId:orderId"'), {status: 400, code: 'BadKey'});
  }
  return {customerId, orderId};
}
// Numeric partition keys with validation
keyFromPath: (raw, a) => {
  const id = Number(raw);
  if (!Number.isFinite(id) || !Number.isInteger(id) || id < 0) {
    throw Object.assign(new Error('Numeric positive integer key expected'), {status: 400});
  }
  return {[a.keyFields[0].name]: id};
}

Thrown errors with a status field map straight onto the wire, so key-format validation lives naturally inside the callback. Pick a delimiter that won't appear in key values: : and | are URL-safe without encoding; / interferes with path segments; , interferes with ?names= parsing.

PUT /:key merges the URL key into the body, so keyFromPath must return all key fields. /-by-names?names=… runs each comma-separated entry through the same callback.

Per-tenant filtering

Constraining list operations to the current tenant is what exampleFromContext is for: it feeds the example argument of Adapter.prepareListInput(example, index), which the Adapter's consumer-supplied hook turns into a key condition.

GET /?status=active
     -> exampleFromContext({query, body: null, adapter, framework: 'express', req})
     -> returns {tenantId: 'acme', status: 'active'}
     -> Adapter.getList(opts, example, index)
     -> Adapter.prepareListInput(example, index)   <- consumer-supplied hook
     -> {IndexName: 'by-tenant', KeyConditionExpression: ..., ExpressionAttributeValues: {':t': 'acme'}}
     -> DynamoDB Query

Auth-derived tenant on Express (the other adapters differ only in the context fields):

app.use(auth); // sets req.user.tenantId

app.use(
  '/items',
  createExpressAdapter(items, {
    exampleFromContext: ({query, framework, req}) => ({
      tenantId: req.user.tenantId,
      status: query.status || 'active'
    })
  })
);

The same callback shared across adapters branches on the discriminator:

const exampleFromContext = ctx => {
  switch (ctx.framework) {
    case 'express':
      return {tenantId: ctx.req.user.tenantId};
    case 'koa':
      return {tenantId: ctx.ctx.state.user.tenantId};
    case 'lambda':
      return {tenantId: ctx.event.requestContext.authorizer?.lambda?.tenantId};
    case 'fetch':
      return {tenantId: ctx.request.headers.get('x-tenant-id')};
  }
};

body is the parsed request body on PUT /-clone / PUT /-move and null on GET / / DELETE /. Use exampleFromContext for key-condition scoping (which GSI, which partition); post-query field predicates belong to the filter grammar parsed by REST core — the two compose.

See also

Clone this wiki locally