-
Notifications
You must be signed in to change notification settings - Fork 0
Composite keys
DynamoDB tables often use a partition-key / sort-key combination. The adapter's default keyFromPath only handles the single-field case:
(raw, adapter) => ({[adapter.keyFields[0].name]: raw})For composite keys you supply your own keyFromPath that parses the URL :key segment into a full key object.
The simplest approach is to pick a URL-safe delimiter and split on it:
import {Adapter} from 'dynamodb-toolkit';
import {createExpressAdapter} from 'dynamodb-toolkit-express';
const orders = new Adapter({
client,
table: 'orders',
keyFields: ['customerId', 'orderId']
});
app.use('/orders', createExpressAdapter(orders, {
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};
}
}));Requests:
GET /orders/cust-42:ord-2026-01-7
PATCH /orders/cust-42:ord-2026-01-7
DELETE /orders/cust-42:ord-2026-01-7
Pick a delimiter that won't appear in your key values. : and | are URL-safe without encoding; / interferes with path segments; , interferes with query parsing.
Numeric partition keys:
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};
}UUID validation:
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
keyFromPath: (raw, a) => {
if (!UUID.test(raw)) {
throw Object.assign(new Error('UUID expected'), {status: 400, code: 'BadKey'});
}
return {[a.keyFields[0].name]: raw};
}When the partition key is known from elsewhere (auth token, header, path prefix), keyFromPath returns the full object assembled from multiple sources.
For example, a multi-tenant service where the tenant is derived from a subdomain and only the item ID is in the URL:
app.use((req, res, next) => {
req.tenantId = req.hostname.split('.')[0]; // `acme.example.com` → `acme`
next();
});
app.use('/items', createExpressAdapter(items, {
keyFromPath: raw => ({
tenantId: req.tenantId, // ← problem: `req` isn't in scope here
itemId: raw
})
}));keyFromPath does not receive the Express Request. It runs per request but its signature is (rawKey, adapter) only. For context-dependent key building, wrap the middleware instead and rewrite req.url before delegating:
const base = createExpressAdapter(items, {
keyFromPath: raw => {
const [tenantId, itemId] = raw.split('|');
return {tenantId, itemId};
}
});
app.use('/items', (req, res, next) => {
// Rewrite the path so the single-segment key becomes "tenantId|itemId".
// Express routes off req.url; updating it re-runs `req.path` derivation.
const m = req.path.match(/^\/([^/]+)$/);
if (m) req.url = '/' + encodeURIComponent(`${req.tenantId}|${m[1]}`);
base(req, res, next);
});This pattern keeps keyFromPath pure (no hidden context dependency) and puts the context-merging in a named middleware where it's easy to find.
POST / takes the full item in the body, so composite keys come through naturally:
curl -X POST /orders/ -H 'content-type: application/json' \
-d '{"customerId":"cust-42","orderId":"ord-7","total":99.95}'PUT /:key merges the URL key into the body — make sure your keyFromPath returns all key fields so they all end up in the stored item:
keyFromPath: raw => {
const [pk, sk] = raw.split(':');
return {pk, sk}; // both merged into body on PUT /pk:sk
}/-by-names?names=a,b,c works for composite keys the same way — each comma-separated entry goes through keyFromPath:
curl '/orders/-by-names?names=cust-42:ord-1,cust-42:ord-2,cust-7:ord-9'When the partition key is fixed (e.g. all items belong to one tenant / category) and only the sort key varies per request, skip keyFromPath for the prefix and use per-tenant filtering on the list routes via exampleFromContext.