Skip to content
This repository was archived by the owner on Jul 17, 2026. It is now read-only.

Composite keys

Eugene Lazutkin edited this page Apr 20, 2026 · 2 revisions

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]]: raw})

For composite keys you supply your own keyFromPath that parses the URL :key segment into a full key object. This page collects the common patterns.

Two-part keys with a delimiter

The simplest approach is to pick a URL-safe delimiter and split on it:

import {Adapter} from 'dynamodb-toolkit';
import {createLambdaAdapter} from 'dynamodb-toolkit-lambda';

const orders = new Adapter({
  client,
  table: 'orders',
  keyFields: ['customerId', 'orderId']
});

export const handler = createLambdaAdapter(orders, {
  mountPath: '/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 in the route pack's /-by-names and similar.

Type coercion

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]]: 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]]: raw};
}

Separate partition key from authorizer claims (Lambda-specific)

When the partition key is derived from authentication rather than the URL (e.g. each user sees only their own orders, keyed by userId + orderId), keyFromPath alone can't see the event. Pull the partition segment from the authorizer and combine it inside exampleFromContext for list routes, and inside a wrapper for item routes.

Pattern A: item routes — wrap the handler, rewrite the event

const inner = createLambdaAdapter(orders, {
  mountPath: '/orders',
  keyFromPath: raw => {
    // raw already contains both parts, injected by the wrapper
    const [userId, orderId] = raw.split('|');
    return {userId, orderId};
  }
});

export const handler = async (event, context) => {
  const userId = event.requestContext?.authorizer?.jwt?.claims?.sub
              ?? event.requestContext?.authorizer?.lambda?.userId;
  if (!userId) {
    return {statusCode: 401, body: JSON.stringify({code: 'Unauthenticated'}), headers: {'content-type': 'application/json'}};
  }

  // Rewrite `/orders/:key` to `/orders/userId|:key` before dispatching.
  const path = event.rawPath ?? event.path ?? '';
  const m = path.match(/^\/orders\/([^/]+)(\/.*)?$/);
  if (m) {
    const rewritten = `/orders/${encodeURIComponent(`${userId}|${m[1]}`)}${m[2] || ''}`;
    event = 'rawPath' in event
      ? {...event, rawPath: rewritten}
      : {...event, path: rewritten};
  }
  return inner(event, context);
};

Pattern B: list routes — exampleFromContext

For GET /, DELETE /, PUT /-clone, PUT /-move — the routes that don't carry a :key segment — pull the partition scope from the authorizer and thread it into exampleFromContext. The Adapter's prepareListInput hook shapes the KeyConditionExpression against a GSI scoped by that field.

createLambdaAdapter(orders, {
  exampleFromContext: (_q, _b, event) => {
    const userId = event.requestContext?.authorizer?.jwt?.claims?.sub
                ?? event.requestContext?.authorizer?.lambda?.userId;
    if (!userId) {
      throw Object.assign(new Error('Unauthenticated'), {status: 401, code: 'Unauthenticated'});
    }
    return {userId};
  }
});

See the parent wiki's Adapter → prepareListInput for the hook contract.

Exposing composite keys in collection operations

POST / takes the full item in the body, so composite keys come through naturally:

curl -X POST https://api.example.com/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 [customerId, orderId] = raw.split(':');
  return {customerId, orderId};  // both merged into body on PUT /cust-42:ord-7
}

/-by-names?names=a,b,c works for composite keys the same way — each comma-separated entry goes through keyFromPath:

curl 'https://api.example.com/orders/-by-names?names=cust-42:ord-1,cust-42:ord-2,cust-7:ord-9'

Sort-key-only queries

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 exampleFromContext on the list routes. The item routes can still use a simple keyFromPath that injects the fixed partition key:

keyFromPath: (raw, _a) => ({tenantId: 'shared', orderId: raw})

Cleaner is to derive the partition from the event so the same code serves multiple tenants (see Pattern A above).

URL-decoding

The rawKey argument to keyFromPath is already URL-decoded by matchRoute — you don't need to call decodeURIComponent yourself. Callers that want to include characters like # or ? in a key still need to percent-encode them client-side (encodeURIComponent(rawKey) before constructing the URL), but the value you receive is the decoded form.

Inside a Lambda trigger, URL decoding happens at the API Gateway / ALB layer before your handler fires — event.rawPath / event.path is already decoded (with a few exceptions on API Gateway v2, where the rawPath is, true to its name, raw percent-encoded bytes). The adapter uses the trigger's path directly; matchRoute handles the per-segment decoding.

IAM-signed Function URL identity keys

When a Lambda Function URL uses AuthType: AWS_IAM, the caller's IAM principal arrives on event.requestContext.authorizer.iam:

event.requestContext.authorizer.iam = {
  accessKey: 'AKIA…',
  accountId: '123456789012',
  callerId: 'AIDA…',
  cognitoIdentity: null,
  principalOrgId: null,
  userArn: 'arn:aws:iam::123456789012:user/alice',
  userId: 'AIDA…'
};

Use that instead of cookies / headers for account-scoped composite keys:

exampleFromContext: (_q, _b, event) => {
  const accountId = event.requestContext?.authorizer?.iam?.accountId;
  if (!accountId) {
    throw Object.assign(new Error('unsigned request'), {status: 401, code: 'Unauthenticated'});
  }
  return {accountId};
}

Clone this wiki locally