-
Notifications
You must be signed in to change notification settings - Fork 0
Express adapter
Express 4/5 middleware serving the standard REST route pack against a dynamodb-toolkit Adapter. It ships as a subpath of the core package: import {createExpressAdapter} from 'dynamodb-toolkit/express';. Shared behavior — routes, options, envelopes, error mapping — is documented on Framework adapters; this page covers the Express-specific wiring: mounting, body parsing, and error propagation through the Express pipeline.
Prerequisites: a DynamoDB table (or DynamoDB Local running at http://localhost:8000 for experimentation) and an Express app — the adapter plugs into any existing Express middleware chain.
npm install dynamodb-toolkit express @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb// server.js
import express from 'express';
import {DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {DynamoDBDocumentClient} from '@aws-sdk/lib-dynamodb';
import {Adapter} from 'dynamodb-toolkit';
import {createExpressAdapter} from 'dynamodb-toolkit/express';
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 = express();
app.use(express.json());
app.use('/planets', createExpressAdapter(planets));
app.listen(3000, () => console.log('Listening on http://localhost:3000'));Two notes on the setup:
-
app.use('/planets', middleware)strips the/planetsprefix fromreq.pathbefore the adapter sees it. Without a prefix mount, the adapter matches against the fullreq.path, which only works for single-collection apps. -
express.json()pre-parses JSON bodies so every middleware seesreq.body. The adapter uses it when present and falls back to streaming the raw request otherwise.
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, envelopes, and query grammar are on Framework adapters.
The adapter matches routes against req.path and treats the path as rooted at the collection. Any strategy that gets the right path into req.path before the adapter runs will work — Express's app.use(prefix, middleware) is the idiomatic choice: it strips the mount prefix natively, no extra dependency required.
If you mount the adapter top-level with app.use(createExpressAdapter(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(). Express's prefix-mount rewrites req.path so the adapter sees /earth:
app.use('/planets', createExpressAdapter(planets));
// GET /planets/earth -> adapter sees GET /earthIf you only serve one collection, you can skip the prefix and use the adapter as a top-level middleware:
const app = express();
app.use(express.json());
app.use(createExpressAdapter(planets));
app.listen(3000);
// GET /earth, POST /, PATCH /earth -- all hit the adapter directly.Without a prefix mount there's no prefix to strip, so a top-level mount means URLs don't carry a collection prefix.
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 = express();
app.use(express.json());
app.use('/planets', createExpressAdapter(planets));
app.use('/moons', createExpressAdapter(moons, {
keyFromPath: raw => {
const [planet, name] = raw.split(':');
return {planet, name};
}
}));
app.use('/probes', createExpressAdapter(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. Composite-key keyFromPath patterns, as used for moons above, are covered on Framework adapters.
express.Router() composes sub-routers. Mount the adapter inside a router when you already group related routes that way:
import express from 'express';
const apiV1 = express.Router();
apiV1.get('/health', (req, res) => res.json({ok: true}));
apiV1.use('/planets', createExpressAdapter(planets));
app.use('/api/v1', apiV1);
// GET /api/v1/planets/earth -> adapter sees GET /earthapp.use('/api/v1', router) strips /api/v1, and the router's own '/planets' mount strips /planets. The adapter sees paths relative to the collection root.
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('/planets', createExpressAdapter(planets));
app.use('/planets', (req, res, next) => {
// Hit for /planets/special-endpoint (unknown shape, adapter delegated)
if (req.path === '/special-endpoint') {
return res.json({custom: true});
}
next();
});Use this to add per-collection endpoints that aren't part of the standard route pack.
Standard Express middleware composes naturally — the adapter writes via res.status() and res.json(), so downstream loggers / metrics middleware can observe via res.on('finish', ...) or the morgan pattern:
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms`);
});
next();
});
app.use('/planets', createExpressAdapter(planets));This is the main reason the adapter uses res.status().json() instead of writing to the raw socket directly — everything in the Express ecosystem that inspects the response sees the expected state.
All standard Express middleware works:
import compression from 'compression';
import cors from 'cors';
app.use(compression());
app.use(cors());
app.use(express.json());
app.use('/planets', createExpressAdapter(planets));The adapter sets JSON bodies via res.json() and lets Express's response pipeline handle serialization, ETags (when enabled), compression, and content-type negotiation.
The adapter handles request bodies in two modes, picked automatically per request: if req.body is defined, it is used as-is (a body-parser middleware already handled parsing); otherwise the adapter streams req, enforces maxBodyBytes, and JSON.parses the result.
Most Express apps use the built-in express.json() parser for other routes. When req.body !== undefined, the adapter uses that value directly.
app.use(express.json({limit: '2mb'}));
app.use('/planets', createExpressAdapter(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
req.bodyshape across all routes.
If no body parser is installed, the adapter consumes req (the raw Node IncomingMessage — Express's Request extends it) directly, enforces a byte cap, and JSON.parses the result.
app.use(createExpressAdapter(planets, {maxBodyBytes: 256 * 1024}));Useful when:
- Your app is small enough that a body parser isn't worth wiring.
- You want per-adapter body-size policy (e.g. a bulk-load endpoint accepts 16 MiB while everything else caps at 64 KiB).
Default cap: 1048576 (1 MiB). Over-cap requests are rejected with 413 / PayloadTooLarge; malformed JSON gives 400 / BadJsonBody. The full error envelope is documented on Framework adapters.
Pre-parsed means the express.json() cap applies — a larger maxBodyBytes on one adapter is ignored as long as the global parser 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 app.use(path, middleware):
// Bulk route streams its own body (no express.json upstream -> maxBodyBytes kicks in).
app.use('/bulk', createExpressAdapter(bulk, {maxBodyBytes: 16 * 1024 * 1024}));
// Everything else uses the shared parser.
app.use(express.json({limit: '256kb'}));
app.use('/planets', createExpressAdapter(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 — the socket needs to stay alive so Express can 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 cap-enforced JSON reader used by the adapter is readJsonBody, exported by the core package:
import {readJsonBody} from 'dynamodb-toolkit/handler';
// Elsewhere in your Express app:
const body = await readJsonBody(req, 1024 * 1024);Normally you won't need this — the adapter handles it internally. It's exported so consumers can reuse the same cap-enforced JSON reader in unrelated routes or when writing their own middleware for edge cases.
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. The status mapping (SDK error names to policy.statusCodes buckets) and the error-body shape are shared across adapters and documented on Framework adapters.
Express-specific: unexpected post-response failures are forwarded to next(err) so the Express error pipeline can finish the socket — this covers both Express 4 (no async-handler auto-await) and Express 5 uniformly.
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 middleware:
app.use((req, res, next) => {
if (!req.user) {
return next(Object.assign(new Error('Authentication required'), {status: 401, code: 'Unauthorized'}));
}
next();
});err.code sets the code field in the response body; err.message sets message. Errors reaching Express's default error handler are rendered generically — wrap the adapter (see below) if you need richer error bodies for errors raised outside the adapter.
policy.errorBody receives only the error — the Express req isn't threaded through. If you need request context in the error body (user ID, request ID, locale), install an Express error-handling middleware downstream — it catches anything the adapter forwards via next(err):
const inner = createExpressAdapter(adapter);
app.use('/planets', inner);
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
res.status(err.status || 500).json({
code: err.code || err.name || 'Error',
message: err.message,
requestId: req.requestId,
user: req.user?.id
});
});Note that the inner adapter already has its own try/catch — errors caught inside are already written via res.status().json(). The outer error handler only fires on truly unexpected throws (e.g. a rejection that escapes the adapter's dispatch, or errors in sibling middleware). For per-request correlation IDs it's usually easier to set a response header in upstream middleware and leave the body shape default.
Paths that don't match any route shape (e.g. three-segment paths) pass through to the next middleware via next(). The adapter never fabricates a 404 for unknown shapes; Express's default handler (or your own) gets the final say. This keeps the adapter composable. When the route shape is recognized but the method isn't supported, the adapter responds itself with 405 Method Not Allowed.
createExpressAdapter(adapter, options?) accepts the shared adapter options — policy, sortableIndices, keyFromPath, exampleFromContext, maxBodyBytes — documented on Framework adapters. Express-specific notes:
-
exampleFromContextreceives the full ExpressRequestin its context (framework: 'express',req) — use it to pull auth info from upstream middleware (req.user) or request metadata (req.headers,req.ip). - The URL query is normalized to
Record<string, string>: nested-object values from Express'sqsparser are dropped; array values are collapsed to the first element. -
maxBodyBytesis enforced only when the adapter streams the body itself — see Body parsing above. Default:1048576(1 MiB), matching the bundled HTTP handler.
Both Express 4 and Express 5 are supported (^4.21.0 || ^5.0.0). The adapter uses only stable surface area:
-
req.method,req.path,req.query,req.body,req.url,req.originalUrl; -
res.status(N).json(obj),res.status(N).end(); -
nextfrom the middleware signature(req, res, next) => void.
Express 5 auto-awaits async handlers and forwards rejections to next(err); Express 4 does not. The adapter wraps its own dispatch in a try/catch that forwards to next(err) for unexpected failures, so error propagation works identically on both.
Runtime support (Node / Bun / Deno), module format, and TypeScript packaging follow the core package — see Compatibility.
The factory is generic in TItem so typed Adapter<Item, Key> instances flow through without casts:
import {Adapter} from 'dynamodb-toolkit';
import {createExpressAdapter, type ExpressAdapterOptions} from 'dynamodb-toolkit/express';
interface Planet extends Record<string, unknown> {
name: string;
climate?: string;
}
type PlanetKey = Pick<Planet, 'name'>;
const adapter = new Adapter<Planet, PlanetKey>({client, table: 'planets', keyFields: ['name']});
const opts: ExpressAdapterOptions<Planet> = {
keyFromPath: (raw, a) => ({[a.keyFields[0].name]: raw}),
policy: {defaultLimit: 25, maxLimit: 200}
};
const mw = createExpressAdapter(adapter, opts);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