Skip to content

URL schema design

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

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.

Ground rules (scheme-independent)

  • 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: /-clone can't collide with a key /Tatooine because 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 with null at 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 (mountPath on 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 UnscopedMassDelete for a bare DELETE / unless ?confirm=true or a scope (filter, search, tenant example) is present.

Scheme 1 — flat collection + method prefix (the shipped pack)

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

Scheme 2 — composite key in one segment

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.

Scheme 3 — hierarchical paths + meta-markers

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 /TX cascade 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/:vin and /: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.

Choosing

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.

The scale seam

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.

Related

Clone this wiki locally