controller-sets v3.3.0

When to wire routes yourself#

createRouter is a thin layer over ControllerSets. Build the controller directly when the generated router doesn't fit:

  • Different guards per verb on one path — public reads, admin writes — without a second mount path.
  • Only some of the six routes — say, no DELETE.
  • Different paths or verbs — PUT, /:id/publish.
  • Your own logic before a handler — ownership checks, feature flags.
import { ControllerSets } from 'express-controller-sets';

const products = new ControllerSets({
  model: Product,                          // same options as createRouter, minus middlewares/enableQuery/upload
  query: ['category'],
  search: ['name'],
  allowedFields: ['name', 'price', 'category'],
});

The six handlers#

HandlerUsual routeReadsResponds
getAllGET /req.query200 list (+ pagination)
queryQUERY /req.body (JSON)200 list (+ pagination)
getGET /:idreq.params.id200 record · 400 bad id · 404
createPOST /req.body201 record · 400 · 409
updatePATCH /:idreq.params.id, req.body200 record · 400 · 404 · 409
deleteDELETE /:idreq.params.id200 message · 404
  • Handlers are pre-bound: pass them directly, router.get('/', products.getAll).
  • The route parameter must be named :id.
  • Each handler sends the response itself. To change what a read returns, use onGet or a schema toJSON.
  • create and update apply allowedFields and validate exactly as on a generated router.

A router by hand#

All six handlers with a different guard per route, plus an extra endpoint and a PUT:

import express from 'express';
import { ControllerSets, errorHandler, isQueryMethodSupported } from 'express-controller-sets';

const products = new ControllerSets({
  model: Product,
  query: ['category'],
  search: ['name'],
  sortableFields: ['price', 'createdAt'],
  allowedFields: ['name', 'price', 'category'],
});

const router = express.Router();
const admin = [auth.requireAuth, auth.requireRole('admin')];

router.get('/', products.getAll);                          // public
if (isQueryMethodSupported()) router.query('/', products.query);   // public; Node 22.2+ only
router.get('/featured', async (req, res) => {              // your own route — before '/:id'
  res.json({ success: true, data: await Product.find({ featured: true }).limit(10) });
});
router.get('/:id', products.get);                          // public
router.post('/', ...admin, products.create);               // admin only
router.patch('/:id', ...admin, products.update);
router.put('/:id', ...admin, products.update);             // PUT, if clients need it — still a partial update
router.delete('/:id', ...admin, products.delete);

app.use('/api/products', router);
app.use(errorHandler);

Guard router.query(). It only exists on runtimes that know the method; on older Node, calling it throws at startup. createRouter checks for you — wiring it yourself means checking with isQueryMethodSupported().

Add logic before a handler#

Run your own middleware, then hand over:

import mongoose from 'mongoose';
import { HttpError } from 'express-controller-sets';

// Only the owner may change or delete a note
const ownsNote = async (req, res, next) => {
  if (!mongoose.isValidObjectId(req.params.id)) return next();   // the handler answers 400
  const mine = await Note.exists({ _id: req.params.id, ownerId: req.auth.userId });
  if (!mine) throw new HttpError(404, 'Entry not found.');
  next();
};

router.patch('/:id', auth.requireAuth, ownsNote, notes.update);
router.delete('/:id', auth.requireAuth, ownsNote, notes.delete);

Throwing HttpError from async middleware works on Express 5 — it reaches errorHandler, which sends the status and message. The full per-user pattern is in Protecting routes.

Other members#

MemberWhat it gives you
controller.configThe resolved, frozen options — every default filled in.
controller.invalidateCache()Drop this model's cached responses. Resolves quietly when caching is off. Caching
controller.getPopulates(req, res)What onGet resolves to for this request: { populates, selects }.
controller.getPaginatedResults(req, res, filters, sort, populates?, selects?)One offset-paginated read of your filter, sent as a standard paginated response. Reads page and pageSize from req.query.
// A custom list endpoint with the standard response and pagination
router.get('/on-sale', (req, res) =>
  products.getPaginatedResults(req, res, { discount: { $gt: 0 } }, { discount: -1 }));

How the handlers are built#

For contributors, and for anyone writing an endpoint the library doesn't generate. Each handler is a short composition of shared modules under src/core/, so a rule enforced in one applies everywhere.

ModuleResponsibility
config.jsOptions in, one frozen config out. Every default is resolved here, once.
getAll.js · query.js · get.js · create.js · update.js · delete.jsOne module per route.
readParams.jsRequest → filters, sort, page window. Pure. The only difference between GET and QUERY.
list.js · cursor.jsThe shared list retrieval: offset pages, keyset pages, plain lists.
search.jsEscaped search terms, including across referenced models.
write.jsBody → payload: the field policy, then your validator.
dataAccess.jsEvery Mongoose call, with maxLimit, maxTimeMS, lean and onGet applied.
respond.jsThe response envelope, so no endpoint invents its own shape.
Esc
↑ ↓ to move↵ to open