Skip to content

Koa adapter

Eugene Lazutkin edited this page Jul 16, 2026 · 1 revision

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.

Getting started

Prerequisites:

  • A DynamoDB table (or DynamoDB Local running at http://localhost:8000 for 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-dynamodb

Optional, but recommended for most apps:

npm install koa-mount koa-bodyparser
  • koa-mount lets you mount the adapter at a sub-path (e.g. /planets). Without it, the adapter matches against the full ctx.path, which only works for single-collection apps.
  • koa-bodyparser (or @koa/bodyparser) pre-parses JSON bodies so every middleware sees ctx.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/pluto

The full route pack is documented on Framework adapters.

Without koa-mount

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.

Without koa-bodyparser

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.

Mounting and routing

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.

Why 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 /earth

Multiple collections

Mount 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.

Using @koa/router

@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.

Mounting adjacent middleware

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.

Composing with standard middleware

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.

Body parsing

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

Pre-parsed body (recommended)

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.body shape across all routes.

Stream-parsed body (fallback)

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.

Error responses

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.

Mixing modes in one app

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)));

Why the adapter doesn't call req.destroy() on overflow

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.

Error handling

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 JSON 400, oversized body 413).

Throwing your own errors

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.

Custom error body with Koa context

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).

Unrecognized routes

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.

Options

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:

exampleFromContext receives the Koa Context

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 defers to body parsers

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.

Compatibility

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;
  • next from 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.

Clone this wiki locally