controller-sets v3.3.0

Endpoints#

Every createRouter router serves the same six routes. Examples use /api/products; substitute your mount path.

MethodPathBodySuccess
GET/api/products—200, list
QUERY/api/productsJSON read parameters200, list
POST/api/productsJSON record, or multipart/form-data on upload routers201, record
GET/api/products/:id—200, record
PATCH/api/products/:idJSON fields to change (others are kept)200, record
DELETE/api/products/:id—200, message
  • Auth: protected routes need Authorization: Bearer <token>.
  • Allowlists: every parameter below only works on fields the server allows. Ask the backend team which fields are filterable, searchable, sortable and writable — or read them off the router's options.
  • No PUT: updates are PATCH and change only the fields you send.

Filtering#

FilterExampleMatchesField must be in
Equals?category=chairscategory is "chairs"query
Any of?category=chairs&category=tableschairs or tablesquery
Several fields?category=chairs&inStock=trueboth (AND)query
Range?rangeField=price&range=50-20050 ≤ price ≤ 200filterableFields
Open range?rangeField=price&range=50- · range=-200price ≥ 50 · price ≤ 200filterableFields
Compare?compareField=price&compareOperator=gt&compareValue=100price > 100filterableFields

compareOperator is one of eq (default), ne, gt, gte, lt, lte.

  • An equality parameter for a field not in query is ignored. A range or compare on a field not in filterableFields is a 400 naming it.
  • Range bounds and numeric-looking compare values are treated as numbers. Ranges split on -, so negative bounds need compareField or QUERY.
  • One range and one compare per request. Need more? Use QUERY.
  • Object-shaped values such as ?price[$ne]=0 are a 400 — that's how operator injection gets in.

Sorting#

GET /api/products?sort=price          # ascending
GET /api/products?sort=-price         # descending
  • One field, from the server's sortableFields; any other is a 400.
  • Without sort, the server's default order applies.
  • Several sort keys: use QUERY — "sort": ["-price", "name"].

Pagination#

The server uses one of two modes. Offset is the default.

Offset: page numbers#

GET /api/products?page=2&pageSize=20
{
  "success": true,
  "data": [ … 20 records … ],
  "pagination": { "currentPage": 2, "pageSize": 20, "totalPages": 7, "totalRecords": 132 }
}
You sendYou get
no pageAn unpaginated list, no pagination block, capped at the server's maxLimit (100 by default).
page=150 records (the default page size).
page=1&pageSize=5000Clamped to maxLimit.
page=abc or page=-3Treated as page 1.

If the server skips counting, pagination carries hasMore instead of totalPages and totalRecords.

Cursor: follow nextCursor#

When the server uses pagination: 'cursor', there are no page numbers. Request the first page with no cursor, then pass back nextCursor until hasMore is false:

GET /api/events?pageSize=50
→ { "data": [ … ], "pagination": { "pageSize": 50, "hasMore": true, "nextCursor": "eyJpIjoi…" } }

GET /api/events?pageSize=50&cursor=eyJpIjoi…
→ … until "hasMore": false and "nextCursor": null
  • Keep the same sort and filters for the whole scan; changing sort mid-scan is a 400.
  • If the record a cursor points at was deleted, you get a 400 — restart from the first page.
  • Sending page to a cursor server, or cursor to an offset server, is a 400.

Putting it together#

# Chairs in stock, 50–200, matching "oak", most expensive first, page 2 of 20
GET /api/products?category=chairs&inStock=true&rangeField=price&range=50-200&s=oak&sort=-price&page=2&pageSize=20
const params = new URLSearchParams({
  category: 'chairs', inStock: 'true',
  rangeField: 'price', range: '50-200',
  s: 'oak', sort: '-price', page: '2', pageSize: '20',
});
const res = await fetch(`/api/products?${params}`);
const { success, data, pagination, error } = await res.json();
if (!success) throw new Error(error);

QUERY: reads with a JSON body#

QUERY (RFC 10008) is the same read as GET /, with the parameters in a JSON body. Like GET it never writes. Use it when a URL won't do: a long list of ids, several conditions per field, a multi-key sort, or filters you'd rather keep out of access logs. The response is identical to the equivalent GET.

const res = await fetch('/api/products', {
  method: 'QUERY',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    filter: {
      category: ['chairs', 'tables'],          // list    → any of
      price: { gte: 50, lte: 200 },            // object  → operators
      inStock: true,                           // scalar  → equals
    },
    search: 'oak',
    sort: ['-price', 'name'],
    page: 1,
    pageSize: 20,
  }),
});

Body keys#

KeyTypeMeaning
filterobjectField → condition. Every field must be in filterableFields — being in query alone isn't enough if filterableFields is set.
searchstringSame as ?s=.
sortstring · string[]"-price", or up to five keys, each in sortableFields.
pageinteger ≥ 1Paginated response. Not with limit.
pageSizeinteger ≥ 1Per page, clamped to maxLimit. Alone, implies page: 1.
limitinteger ≥ 1Cap an unpaginated read, clamped to maxLimit.
cursorstringNext page, on cursor-mode servers.

Every key is optional; an empty body is a plain list. Unknown keys are a 400, so a "filters" typo never silently returns the whole collection. page, pageSize and limit must be JSON numbers, not strings.

Operators#

OperatorTakesMatches
eq · nea valueEqual · not equal
gt · gtea valueGreater than · or equal
lt · ltea valueLess than · or equal
in · nin1–100 valuesAny of · none of

Operators are spelled without $. Values are strings, numbers, booleans or null, cast by the schema — "gte": "50" on a Number field works. Nested objects, empty conditions and unknown operators are a 400.

The same read, both ways#

Query stringQUERY body
?category=65af…{ "filter": { "category": "65af…" } }
?tag=sale&tag=new{ "filter": { "tag": ["sale", "new"] } }
?rangeField=price&range=10-100{ "filter": { "price": { "gte": 10, "lte": 100 } } }
?compareField=price&compareOperator=gt&compareValue=50{ "filter": { "price": { "gt": 50 } } }
?s=chair{ "search": "chair" }
?sort=-price{ "sort": "-price" }
— not expressible{ "sort": ["-price", "name"] }
?page=2&pageSize=20{ "page": 2, "pageSize": 20 }

Sending it#

Browsers and Node's fetch send QUERY as written — see the example above.

curl -X QUERY http://localhost:3000/api/products \
  -H 'Content-Type: application/json' \
  -d '{"filter":{"price":{"gte":50}},"sort":"-price","pageSize":20}'
const { data } = await axios.request({
  url: '/api/products',
  method: 'QUERY',
  headers: { 'Content-Type': 'application/json' },   // set it: some versions omit it for unknown methods
  data: { filter: { tag: ['sale', 'new'] }, sort: ['-price', 'name'] },
});

Is it available?#

OPTIONS on the collection answers:

OPTIONS /api/products HTTP/1.1

HTTP/1.1 200 OK
Allow: GET, HEAD, POST, QUERY
Accept-Query: application/json

No QUERY in Allow means the server turned it off, runs Node older than 22.2, or a proxy stripped it. Many proxies, CDNs and API gateways drop methods they don't recognise, so test through your real edge and keep GET as the fallback. Don't put a URL-keyed shared cache in front of QUERY — every request to a collection has the same URL.

What's refused#

RequestStatusWhy
{ "filter": { "secretToken": "x" } }400Not in filterableFields.
{ "filter": { "$where": "…" } }400$-prefixed or dotted keys are never field names.
{ "filter": { "price": { "$gte": 1 } } }400Spell it gte.
{ "sort": "createdBy" }400Not in sortableFields.
{ "filters": { … } }400Unknown key.
{ "page": "2" }400Must be a number.
Body sent as text/plain, or no JSON parser on the server415The body must be parsed JSON.
QUERY /api/products/65af…404Collection only. Use GET /:id.

Read, create, update, delete#

// Read one
await fetch('/api/products/65af…');

// Create — only fields the server allows are saved; others are silently dropped
await fetch('/api/products', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Oak chair', price: 120 }),
});

// Update — send only what changes
await fetch('/api/products/65af…', {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ price: 99 }),
});

// Delete
await fetch('/api/products/65af…', { method: 'DELETE' });

Sending files#

On routers with uploads, send multipart/form-data: ordinary fields as text, files under the field names the server configured. Don't set Content-Type yourself — the browser adds the boundary.

const form = new FormData();
form.append('name', 'Chair');
form.append('price', '120');
form.append('cover', coverInput.files[0]);
for (const file of galleryInput.files) form.append('gallery', file);

const res = await fetch('/api/products', { method: 'POST', body: form });
const { data } = await res.json();
data.cover;     // "https://…/products/1726…-51234.jpg"
data.gallery;   // ["https://…", "https://…"]
curl -X POST localhost:3000/api/products \
  -F name=Chair -F price=120 \
  -F cover=@cover.jpg \
  -F gallery=@side.jpg -F gallery=@back.jpg

Responses#

success is always present; branch on it.

// GET /api/products
{ "success": true, "data": [ { "_id": "65af…", "name": "Laptop", "price": 1299 } ] }
// Offset, counted
"pagination": { "currentPage": 2, "pageSize": 20, "totalPages": 7, "totalRecords": 132 }

// Offset, countStrategy: 'none'
"pagination": { "currentPage": 2, "pageSize": 20, "hasMore": true }

// Cursor
"pagination": { "pageSize": 20, "hasMore": true, "nextCursor": "eyJpIjoi…" }
// GET /:id · POST / (201) · PATCH /:id
{ "success": true, "data": { "_id": "65af…", "name": "Laptop", "price": 1299 } }
// DELETE /:id
{ "success": true, "message": "Item successfully deleted." }
{ "success": false, "error": "Entry not found." }

// A rejected write with per-field detail
{ "success": false, "error": "Check the submitted values.", "fields": { "price": "must not be negative" } }

// Through errorHandler (e.g. a 500)
{ "success": false, "error": "Internal Server Error", "requestId": "0f2a5b8c-…" }
  • fields appears only on per-field failures: a validation error, a schema validation failure or a duplicate key. Never on a 5xx.
  • requestId appears on errors that pass through the server's errorHandler. Quote it when reporting a problem — it points to the log line.
  • stack appears only when the server runs with NODE_ENV=development.

Status codes#

CodeWhenWho fixes it
200 · 201Success · record created.—
400Malformed id; a field that isn't filterable or sortable; a bad filter value; a search term over the limit; a body with no writable fields; a failed validation; a rejected file type or upload limit; an invalid QUERY body or cursor.The client
401 · 403Not signed in · not allowed. From the server's guards.The client
404No record with that id.The client
405Method refused by a read-only guard (if the server uses one).The client
409A unique field already has that value — "Duplicate value for: email."The client
415A QUERY body that isn't JSON.The client, or the server if no JSON parser is mounted
500Anything unexpected. Details are logged, never sent.The server
503An upload route with S3 not configured, or an email/SMS sender that failed.The server
Esc
↑ ↓ to move↵ to open