-
Notifications
You must be signed in to change notification settings - Fork 0
URL schema design
How to shape the URLs in front of a DynamoDB-backed API. The toolkit ships one canonical scheme (the HTTP handler's route pack); this page explains the design behind it, two other schemes worth building when it doesn't fit, and the rules that hold across all three. The toolkit philosophy applies: these are options, not mandates — the REST layer is composable parsers + builders, so any scheme can reuse the parts.
-
Operations must never collide with keys. Any URL scheme that mixes "names of things" with "names of operations" in the same path position needs a namespacing rule. The toolkit's answer is the method prefix:
/-clonecan't collide with a key/Tatooinebecause keys are sanitized to never start with-(policy.methodPrefix, configurable). If your scheme uses meta-markers (--cars), the same rule applies: the marker alphabet must be outside the legal value alphabet. -
Two set-selection idioms, keep both. Query-derived sets (
GET /?eq-status=open&sort=-createdAt) compose filtering, sorting, projection, and paging in one request. Enumerated sets (GET /-by-keys?keys=A,B,C) fetch a caller-known selection, order-preserving withnullat misses. They are different questions — don't force one through the other. -
Casing: kebab-case for paths and multi-word query params (
/-by-keys,max-items), camelCase for envelope keys (processed,cursor). Pick once, hold the line. -
Versioning is the app's concern. The toolkit deliberately ships no
/v1/convention; mount the adapter under whatever prefix your versioning policy wants (mountPathon fetch/lambda, router mounts on Express/Koa). -
Guard the destructive defaults. A mass route whose unscoped form is catastrophic should refuse to default into it — the shipped pack returns
400 UnscopedMassDeletefor a bareDELETE /unless?confirm=trueor a scope (filter, search, tenant example) is present.
One collection root, keys as single segments, operations under the - prefix at both levels:
GET / list (offset or ?cursor paging; ?format=jsonl)
POST / create
GET /:key read PUT /:key replace PATCH /:key partial
DELETE /:key delete one
DELETE /?eq-status=stale delete the filtered set (guarded when unscoped)
GET /-by-keys?keys=A,B enumerated bulk read
PUT /-load bulk insert (array body)
PUT /-clone /-move mass copy / rename of a filtered set
PUT /:key/-clone copy one
Why it works: one operation vocabulary on every collection (a UI table component learns it once), the - prefix keeps the key namespace clean, and every list knob is a composable query param — filter grammar (<op>-<field>=), sort, fields, paging (offset/limit or cursor), search. It's deliberately flat: the key is one URL segment, even for composite keys (see keyFromPath below).
Use it when: your resources are one logical collection per adapter and clients are UIs or services that benefit from uniformity. It's the zero-design-work option — mount and go.
import {createFetchAdapter} from 'dynamodb-toolkit/fetch';
export default {fetch: createFetchAdapter(adapter, {mountPath: '/api/rentals'})};The flat pack with a delimiter inside the key segment — the smallest step up for hierarchical data:
GET /TX:Dallas%20Rental:1FTEW1E53PKE00001
keyFromPath owns the split (and the validation — thrown errors with a status map straight onto the wire):
const keyFromPath = (raw, adp) => {
const parts = raw.split(':');
if (!parts[0]) throw Object.assign(new Error('empty key'), {status: 400, code: 'BadKey'});
const key = {};
adp.keyFields.forEach((f, i) => {
if (parts[i] !== undefined && parts[i] !== '') key[f.name] = parts[i];
});
return key;
};Pick a delimiter that's URL-safe and outside the value alphabet: : and | survive without encoding in a path segment; / fights the router; , fights ?keys= parsing. Partial keys (fewer segments) address higher tiers — /TX:Dallas%20Rental is the facility record — which keeps the whole hierarchy addressable without any new routes.
Use it when: composite keys, but you want to keep the uniform route pack and its wire contract untouched.
URLs that mirror the hierarchy, with meta-markers naming the dependent sets:
GET /TX the state record
GET /TX/Dallas%20Rental the facility record
GET /TX/--facilities all facilities in Texas (paginated)
GET /TX/Dallas%20Rental/--cars all cars at the facility
DELETE /TX the state AND everything under it (cascade)
DELETE /TX/--cars all cars in Texas, facilities kept
This is the most legible scheme for deeply hierarchical domains — the URL is the subtree address, and mutating verbs on non-leaf URLs upgrade naturally to cascade operations. It is also the most design work: the toolkit doesn't ship it as a pack. Build it from plain framework routes over the Adapter + rest-core parts — the Plain Express routes page shows the approach; getListUnder, buildKey, deleteAllUnder, and parsePaging/buildEnvelope do the heavy lifting per route.
Rules that keep it sound:
- The marker prefix (
--above) must be illegal in every value at every tier — sanitize on write, reject on parse. If a legal name could start with--, pick another prefix; the marker vocabulary is yours (localize it if your team writes French). - Every tier needs an explicit decision about verbs on non-leaf URLs: does
DELETE /TXcascade or 409? Whichever you pick, make it uniform across tiers and document it — surprise cascades are the worst API bug. - Branching hierarchies fit:
/:state/:facility/cars/:vinand/:state/:facility/boats/:vin— the static segment (cars) plays the same namespacing role as a marker.
Use it when: the hierarchy is the product (admin consoles, file-system-like domains) and you can afford per-tier route code.
| Situation | Scheme |
|---|---|
| One collection per adapter, uniform clients | 1 — shipped pack, zero design work |
| Composite keys, standard wire contract | 2 — pack + keyFromPath delimiter |
| Deep hierarchy, subtree operations are the product | 3 — hierarchical paths + markers |
They compose: a hierarchical admin API (scheme 3) can mount the shipped pack (scheme 1) one level down for each leaf collection, and the enumerated-set routes (/-by-keys) are worth keeping in any scheme — they answer a question path shapes can't.
Enumerated sets travel in the URL (?keys=A,B,C…), which caps around 2k characters in practice — hundreds of keys, not thousands. Beyond that the current answer is the array body (DELETE /-by-keys accepts one). A saved server-side selection referenced by id (?selection=<id>) is the scalable design; it implies server-side state, which is why the toolkit doesn't ship it — if you build one, keep the selection immutable and expirable, and the rest of the route pack composes with it unchanged.
- HTTP handler — the shipped pack, wire-accurate.
- REST core — the parsers/builders any custom scheme reuses.
-
Filter URL grammar — the
?<op>-<field>=<value>design. - Pagination — offset vs cursor on the wire.
- Hierarchical data walkthrough — the data-side design these URLs mirror.
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