-
Notifications
You must be signed in to change notification settings - Fork 0
Koa adapter
A Koa 2/3 middleware that serves the standard REST route pack for an Adapter instance:
import {createKoaAdapter} from 'dynamodb-toolkit/koa';Shared behavior — the route pack, common options (policy, sortableIndices, keyFromPath, exampleFromContext, maxBodyBytes), response envelopes, and error mapping — is documented on Framework adapters. This page covers what is specific to Koa: mounting, body parsing, middleware composition, and error-handling integration.
Prerequisites:
- A DynamoDB table (or DynamoDB Local running at
http://localhost:8000for experimentation). - Node 20+ (or Bun / Deno with Koa 3).
- A Koa app — this adapter plugs into any existing Koa middleware chain.
Install:
npm install dynamodb-toolkit koa @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodbOptional, but recommended for most apps:
npm install koa-mount koa-bodyparser-
koa-mountlets you mount the adapter at a sub-path (e.g./planets). Without it, the adapter matches against the fullctx.path, which only works for single-collection apps. -
koa-bodyparser(or@koa/bodyparser) pre-parses JSON bodies so every middleware seesctx.request.body. The adapter uses it when present and falls back to streaming the raw request otherwise.
A complete working example:
// server.js
import Koa from 'koa';
import mount from 'koa-mount';
import bodyParser from 'koa-bodyparser';
import {DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {DynamoDBDocumentClient} from '@aws-sdk/lib-dynamodb';
import {Adapter} from 'dynamodb-toolkit';
import {createKoaAdapter} from 'dynamodb-toolkit/koa';
const ddbClient = DynamoDBDocumentClient.from(
new DynamoDBClient({region: 'us-east-1'}),
{marshallOptions: {removeUndefinedValues: true}}
);
const planets = new Adapter({
client: ddbClient,
table: 'planets',
keyFields: ['name']
});
const app = new Koa();
app.use(bodyParser());
app.use(mount('/planets', createKoaAdapter(planets)));
app.listen(3000, () => console.log('Listening on http://localhost:3000'));Try it:
# Create
curl -X POST http://localhost:3000/planets/ \
-H 'content-type: application/json' \
-d '{"name":"earth","mass":5.97,"climate":"temperate"}'
# Read one
curl http://localhost:3000/planets/earth
# Partial update (PATCH merges, does not replace)
curl -X PATCH http://localhost:3000/planets/earth \
-H 'content-type: application/json' \
-d '{"population":8.2e9}'
# List with paging
curl 'http://localhost:3000/planets/?offset=0&limit=25'
# Delete
curl -X DELETE http://localhost:3000/planets/plutoThe full route pack is documented on Framework adapters.
If you only serve one collection, you can skip koa-mount and use the adapter as a top-level middleware:
const app = new Koa();
app.use(bodyParser());
app.use(createKoaAdapter(planets));
app.listen(3000);
// GET /earth, POST /, PATCH /earth -- all hit the adapter directly.The adapter matches routes against ctx.path. Without koa-mount there's no prefix to strip, so a top-level mount means URLs don't carry a collection prefix.
The adapter reads ctx.req directly when ctx.request.body is undefined, enforcing a 1 MiB cap by default (configurable via the maxBodyBytes option). Apps that don't already have a body parser don't need to add one just for this adapter.
const app = new Koa();
app.use(createKoaAdapter(planets, {maxBodyBytes: 256 * 1024})); // 256 KiB cap
app.listen(3000);See the Body parsing section below for when each mode is preferable.
The adapter matches routes against ctx.path. Any strategy that gets the right path into ctx.path before the adapter runs will work — the idiomatic choice is koa-mount.
The adapter's matchRoute call treats the path as rooted at the collection:
-
/— list / create / bulk-delete -
/-by-names— collection method -
/earth— item -
/earth/-clone— item method
If you mount the adapter top-level with app.use(createKoaAdapter(planets)), a request for /planets/earth doesn't match — the adapter sees /planets/earth as a two-segment path (unknown shape) and hands back to next(). That's usually not what you want.
koa-mount rewrites ctx.path so the adapter sees /earth:
import mount from 'koa-mount';
app.use(mount('/planets', createKoaAdapter(planets)));
// GET /planets/earth -> adapter sees GET /earthMount as many adapters as you need. Each gets its own prefix and Adapter instance:
const planets = new Adapter({client, table: 'planets', keyFields: ['name']});
const moons = new Adapter({client, table: 'moons', keyFields: ['planet', 'name']});
const probes = new Adapter({client, table: 'probes', keyFields: ['id']});
const app = new Koa();
app.use(bodyParser());
app.use(mount('/planets', createKoaAdapter(planets)));
app.use(mount('/moons', createKoaAdapter(moons, {
keyFromPath: raw => {
const [planet, name] = raw.split(':');
return {planet, name};
}
})));
app.use(mount('/probes', createKoaAdapter(probes)));
app.listen(3000);Because the adapter hands back to next() on unknown routes, mount order doesn't matter within an app that doesn't share prefixes.
@koa/router is the other common Koa routing tool. It works too, but requires careful path handling:
import Router from '@koa/router';
import mount from 'koa-mount';
const router = new Router();
// `@koa/router` alone doesn't strip the mount prefix from `ctx.path`.
// Nest the adapter under `koa-mount` inside the router if you want that behavior:
router.use('/planets', mount('/', createKoaAdapter(planets)));
// ... or use a catch-all route and trust the adapter to delegate:
// router.all('/planets/:rest(.*)', (ctx, next) => {
// ctx.path = '/' + (ctx.params.rest || '');
// return createKoaAdapter(planets)(ctx, next);
// });
app.use(router.routes()).use(router.allowedMethods());In practice, plain koa-mount is shorter and avoids the prefix-stripping dance. Use @koa/router when you're already routing other endpoints with it.
Other middleware can run before or after the adapter. Because the adapter calls next() on unknown routes, anything mounted afterward still sees requests that don't match the route pack.
app.use(mount('/planets', createKoaAdapter(planets)));
app.use(mount('/planets', async (ctx, next) => {
// Hit for /planets/special-endpoint (unknown shape, adapter delegated)
if (ctx.path === '/special-endpoint') {
ctx.body = {custom: true};
return;
}
await next();
}));Use this to add per-collection endpoints that aren't part of the standard route pack.
Standard Koa middleware composes naturally — the adapter writes to ctx.body and ctx.status, so downstream loggers / metrics middleware can read them:
app.use(async (ctx, next) => {
const start = Date.now();
await next();
console.log(`${ctx.method} ${ctx.path} ${ctx.status} ${Date.now() - start}ms`);
});
app.use(mount('/planets', createKoaAdapter(planets)));This is the main reason the adapter uses ctx.body/ctx.status instead of writing to ctx.res directly — everything in the Koa ecosystem that reads the response sees the expected state.
Compression, conditional responses, and CORS work the same way:
import compress from 'koa-compress';
import conditional from 'koa-conditional-get';
import etag from 'koa-etag';
import cors from '@koa/cors';
app.use(compress());
app.use(conditional());
app.use(etag());
app.use(cors());
app.use(bodyParser());
app.use(mount('/planets', createKoaAdapter(planets)));The adapter sets JSON bodies via ctx.body = and lets Koa's response pipeline handle serialization, ETags, compression, and content-type negotiation.
The adapter handles request bodies in two modes, picked automatically per request:
Is ctx.request.body defined?
|- Yes -> use it as-is (body-parser middleware already handled parsing)
`- No -> stream ctx.req, enforce maxBodyBytes, JSON.parse the result
Most Koa apps already have koa-bodyparser or @koa/bodyparser in the middleware chain for other routes. When ctx.request.body !== undefined, the adapter uses that value directly.
import bodyParser from 'koa-bodyparser';
app.use(bodyParser({jsonLimit: '2mb'}));
app.use(mount('/planets', createKoaAdapter(planets)));In this mode the body parser's own size cap applies; the adapter's maxBodyBytes is not consulted. This keeps the body-size policy in one place (the middleware chain), which is usually what you want.
Advantages:
- One place to configure JSON parsing (including non-JSON content types if you need them).
- Other middleware downstream / upstream can inspect / transform the body.
- Consistent
ctx.request.bodyshape across all routes.
If no body parser is installed, the adapter consumes ctx.req (the raw Node IncomingMessage) directly, enforces a byte cap, and JSON.parses the result. Rejected on cap overflow or malformed JSON.
app.use(createKoaAdapter(planets, {maxBodyBytes: 256 * 1024}));Useful when:
- Your app is small enough that a body parser isn't worth pulling in.
- You want per-adapter body-size policy (e.g. a bulk-load endpoint accepts 16 MiB while everything else caps at 64 KiB).
- You want to stay zero-deps beyond what's strictly required.
Default cap: 1048576 (1 MiB), matching the HTTP handler. The cap-enforced JSON reader the adapter uses internally is the readJsonBody helper exported by dynamodb-toolkit/handler — see HTTP handler if you want to reuse it in your own middleware.
| Condition | Status | code |
|---|---|---|
Body exceeds maxBodyBytes
|
413 |
PayloadTooLarge |
| Body is not valid JSON | 400 |
BadJsonBody |
| Body is missing required shape | 400 |
BadLoadBody |
BadLoadBody specifically fires on PUT /-load when the body isn't an array.
You can run multiple adapters with different body-cap policies:
app.use(bodyParser({jsonLimit: '256kb'})); // default for most routes
app.use(mount('/planets', createKoaAdapter(planets))); // uses pre-parsed body (256 KiB cap)
app.use(mount('/bulk', createKoaAdapter(bulk, { // but pre-parsed means the bodyparser cap applies --
maxBodyBytes: 16 * 1024 * 1024 // `maxBodyBytes` here is ignored as long as
}))); // bodyParser ran first.If you need per-route body-size policy, mount the bulk adapter before the global body parser, or scope the body parser to specific routes using koa-mount / a router.
// Bulk route streams its own body (no bodyparser upstream -> maxBodyBytes kicks in).
app.use(mount('/bulk', createKoaAdapter(bulk, {maxBodyBytes: 16 * 1024 * 1024})));
// Everything else uses the shared bodyparser.
app.use(bodyParser({jsonLimit: '256kb'}));
app.use(mount('/planets', createKoaAdapter(planets)));Some stream-parsers destroy the underlying socket when they see an over-cap body, to prevent the client from uploading more. This adapter does not — Koa still needs the socket alive to write the 413 response. Backpressure plus the aborted flag stop the reader from buffering; the response completes normally; the connection closes via the framework's standard response-end path.
The worst case is a few kernel-buffer bytes per over-sized request — negligible compared to letting the framework respond.
The adapter wraps every route handler in a try / catch. Thrown errors are mapped to a status code and serialized into a JSON body; callers never see stack traces or raw SDK errors unless you opt in. Any error with a status property in the 400-599 range uses that status directly; anything else goes through the shared status mapping (policy.statusCodes) and body builder (policy.errorBody) documented on Framework adapters.
Sources of thrown errors:
- The Adapter (CRUD methods, hooks, expression builders, batch retries).
- The request body / query parsers in
rest-core(validation rejections). - The adapter itself (unknown method on a known route gives
405, bad JSON400, oversized body413).
Any error object with a status property in the 400-599 range passes through to the response as-is. Useful for auth / validation rejections in your own hooks or wrappers:
app.use(async (ctx, next) => {
if (!ctx.state.user) {
throw Object.assign(new Error('Authentication required'), {status: 401, code: 'Unauthorized'});
}
await next();
});err.code sets the code field in the response body; err.message sets message.
policy.errorBody receives only the error — the Koa ctx isn't threaded through. For per-request correlation IDs it's usually easier to set a response header in upstream middleware and leave the body shape default. If you need request context in the error body (user ID, request ID, locale), wrap the middleware instead of overriding policy.errorBody:
const inner = createKoaAdapter(adapter);
app.use(async (ctx, next) => {
try {
await inner(ctx, next);
} catch (err) {
ctx.status = err.status || 500;
ctx.body = {
code: err.code || err.name || 'Error',
message: err.message,
requestId: ctx.state.requestId,
user: ctx.state.user?.id
};
}
});Note that the inner adapter already has its own try/catch — errors caught inside are already written to ctx.body. The outer wrapper only fires on truly unexpected throws (e.g. next() rejection from downstream middleware).
When the route shape is recognized but the method isn't supported (e.g. POST /:key), the adapter responds with 405 and {code: 'MethodNotAllowed'}. Paths that don't match any shape in matchRoute (e.g. three-segment paths) pass through to the next middleware via await next(). The adapter never fabricates a 404 for unknown shapes; Koa's default handler (or your own) gets the final say. This keeps the adapter composable.
createKoaAdapter(adapter, options?) accepts an optional options object with the following shape:
interface KoaAdapterOptions<TItem extends Record<string, unknown> = Record<string, unknown>> {
policy?: Partial<RestPolicy>;
sortableIndices?: Record<string, string>;
keyFromPath?: (rawKey: string, adapter: Adapter<TItem>) => Record<string, unknown>;
exampleFromContext?: (context: KoaExampleContext<TItem>) => Record<string, unknown>;
maxBodyBytes?: number;
}All options are optional and shared with the other adapters — see Framework adapters for what each one does. The factory is generic in TItem so that a typed Adapter<Planet, PlanetKey> flows through to keyFromPath's second argument without casts.
Two Koa-specific notes:
The callback's context bag is {query, body, adapter, framework: 'koa', ctx} — ctx is the full Koa Context. Use it to pull auth info from upstream middleware (ctx.state), request metadata (ctx.headers, ctx.ip), etc.
createKoaAdapter(adapter, {
exampleFromContext: ({query, ctx}) => ({
tenant: ctx.state.user.tenantId, // set by upstream auth middleware
status: query.status || 'active'
})
});maxBodyBytes is only enforced when the adapter is streaming the body itself — i.e. when ctx.request.body is undefined. If a body-parser middleware (koa-bodyparser, @koa/bodyparser) has already populated ctx.request.body, that parser's own cap applies. See the Body parsing section above for the full decision flow.
Both Koa 2 (still the most widely deployed) and Koa 3 (current) are supported. Koa 3 dropped some deprecated shims but didn't change the middleware contract. The adapter uses only stable surface area:
-
ctx.method,ctx.path,ctx.query,ctx.req,ctx.request.body,ctx.status,ctx.body,ctx.originalUrl; -
nextfrom the middleware signature(ctx, next) => Promise<void>.
The adapter runs wherever the toolkit does — Node 20+, Bun, and Deno; Bun and Deno both ship compatible node:http / node:stream shims for Koa. See Compatibility for the toolkit's runtime support.
Start here
- Getting started
- Concepts
- Key and field design
- Compatibility
- Migration: v2 to v3
- SDK v2 to v3 cheat sheet
Guides
- Hierarchical data walkthrough
- Key expression patterns
- Multi-type tables
- Pagination
- Mass operation semantics
- URL schema design
Adapter
- Adapter
- Constructor options
- CRUD methods
- Mass methods
- Batch builders
- Hooks
- Raw marker
- Indirect indices
- Transaction auto-upgrade
Expression builders
Batch / transactions / mass / paths
REST surface
Framework adapters
Recipes
- Recipes index
- List records of a tier
- Per-tier sparse GSI markers
- Tier within a partition
- Reservation with auto-release
- Keys-only GSI, runtime projection
- Cascade subtree operations
- Querying subtrees with buildKey
- Filter URL grammar
- Text search
- Provisioning workflow
- Resumable mass operations
History