controller-sets v3.3.0

The three pieces#

Everything in the package is one of these. Knowing which one you're holding tells you where its options go.

PieceWhat it isUse it when
createRouter(options)An Express router with six routes already mounted, backed by a controller.Almost always. It's the default way to expose a model.
new ControllerSets(options)The six handlers — getAll, query, get, create, update, delete — without a router.You need a different guard per verb, different paths, or only some of the routes. See Custom routes.
createAuthRouter(options)A separate router for sign-up, login and user management on your user model, plus the requireAuth / requireRole guards.You want built-in authentication. See Authentication.

createRouter accepts every ControllerSets option plus three router-only ones: middlewares, enableQuery and upload. The createRouter page lists them all.

Nothing is open until you open it#

One idea explains almost every option. A generated API that exposes everything by default is convenient on day one and a breach on day ninety: someone filters on passwordResetToken, or POSTs {"role":"admin"}. So here, a field is not filterable, not sortable and not searchable until you name it. Reads are capped. Uploads are private. Each option is an allowlist that opens one thing.

A client wants to…Option that allows itIf you say nothing
Filter by exact valuequeryNothing is filterable
Range, compare, or filter in a QUERY bodyfilterableFieldsSame fields as query
SortsortableFieldsquery fields + the orderBy field
Search textsearchSearch does nothing
Write a fieldallowedFields / blockedFieldsEvery schema field — with a startup warning
Read the whole collectionmaxLimitCapped at 100 documents
Send a regular expressionallowRawRegexEscaped, matched literally
Upload a file typeupload.allowedMimeTypesJPEG, PNG, GIF, WebP, AVIF, PDF
Get a public file URLupload.aclprivate
Call the route at allmiddlewaresAnyone — routes are public

Two defaults are open, and both are yours to close. Writable fields: requiring allowedFields would break every existing app on upgrade, so the library warns at startup instead. Who may call a route: the library can't know your users. If you set only two things on every router, make them allowedFields and middlewares.

Fields no configuration can unlock#

Whatever you allow, these are stripped from every request body. They are database bookkeeping or MongoDB syntax, never client data:

_id __v createdAt updatedAt any $-prefixed key any key containing . __proto__ constructor prototype

To MongoDB, $ starts an operator and . reaches into a subdocument, so {"$where": "…"} or {"owner.role": "admin"} is an instruction, not data. __proto__ survives JSON.parse as a real key and can re-point an object's prototype. The check runs on nested values too, up to eight levels deep; anything deeper is refused.

A request, step by step#

What happens between GET /api/products?s=laptop and the JSON that comes back:

Your code
Middlewares

Auth, rate limits, tenancy

Library
Build filters

Only allowlisted fields

Library
Apply search

Escaped, relations resolved

Your hook
onGet

Choose populate & select

MongoDB
Query

Capped at maxLimit

Library
JSON out

Always the same envelope

A write (POST, PATCH) takes a different middle path:

  1. Your middlewares run.
  2. On an upload router, files are checked, stored in S3, and replaced in req.body by their URLs.
  3. The body is filtered to allowedFields, minus blockedFields and the fields above. If nothing is left: 400.
  4. Your validate hook runs on what's left, and may reject or rewrite it.
  5. Mongoose validates against the schema and saves.
  6. On create, runAfterCreate runs. With a cache, the model's cached reads are cleared.

A QUERY read is identical to GET / except for step two: filters come from a JSON body instead of the query string, checked against the same allowlists.

One response envelope#

Every endpoint answers in the same shape, so a client needs one code path:

// Success: data is an object (one record) or an array (a list)
{ "success": true, "data": … , "pagination": { … } }   // pagination only on paginated lists

// Delete
{ "success": true, "message": "Item successfully deleted." }

// Failure
{ "success": false, "error": "Human-readable message.", "fields": { … }, "requestId": "…" }

Every shape, and when each optional key appears: HTTP API → Responses.

How errors flow#

There are two kinds of failure, and they take different paths:

The caller's mistake

Answered directly

A malformed id, a field that isn't sortable, a failed validation. The handler responds with the right 4xx and a message written to be read — even if you forgot errorHandler.

Your server's problem

Thrown to errorHandler

A database outage, a bug in your hook. Express 5 forwards it to errorHandler, which logs the detail with a requestId and sends the client only "Internal Server Error".

In your own hooks and middleware, throw HttpError(status, message) or ValidationError(message, fields) to produce a deliberate 4xx. Anything else you throw becomes a 500.

Words used in these docs#

WordMeaning here
ModelA Mongoose model, like Product. The only required option.
RouterAn Express router you mount with app.use('/api/products', router).
Controller / handlerThe function that answers a request. ControllerSets holds six of them.
Middleware / guardA function that runs before the handler. A guard is middleware that decides whether the request may continue.
AllowlistA list of what's permitted. Anything not on it is refused or ignored.
Mass assignmentA client sending {"role":"admin"} and the database saving it. allowedFields prevents it.
Server-owned fieldA field whose value comes from the server, never the client — ownerId, author, tenantId.
QUERYAn HTTP method (RFC 10008) that reads like GET but carries its parameters in a JSON body.
Offset / cursor paginationPage numbers (?page=4) versus "the page after this record" (?cursor=…).
Esc
↑ ↓ to move↵ to open