controller-sets v3.3.0

Before you start#

You need Node.js 20.19 or newer and a MongoDB you can connect to — a local mongod, docker run -p 27017:27017 mongo, or a free Atlas cluster. By the end you will have a products API that anyone can read and only an admin can change.

Build a products API#

  1. Create the project

    mkdir shop-api && cd shop-api
    npm init -y && npm pkg set type=module
    npm install express-controller-sets express mongoose
  2. Define a model

    A normal Mongoose model — nothing library-specific.

    // models/Product.js
    import mongoose from 'mongoose';
    
    const productSchema = new mongoose.Schema({
      name:     { type: String, required: true },
      price:    { type: Number, required: true, min: 0 },
      category: String,
      inStock:  { type: Boolean, default: true },
    }, { timestamps: true });
    
    export default mongoose.model('Product', productSchema);
  3. Mount a router

    Each option grants clients one permission. Anything you don't list stays closed.

    // app.js
    import express from 'express';
    import mongoose from 'mongoose';
    import { createRouter, errorHandler } from 'express-controller-sets';
    import Product from './models/Product.js';
    
    const app = express();
    app.use(express.json());                                     // 1. parse JSON bodies — first
    
    app.use('/api/products', createRouter({                      // 2. mount the router
      model: Product,
      orderBy: '-createdAt',                                     // default order: newest first
      query: ['category', 'inStock'],                            // may filter: ?category=chairs
      search: ['name'],                                          // may search:  ?s=oak
      filterableFields: ['price', 'category', 'inStock'],        // may range/compare: price 50–200
      sortableFields: ['price', 'name', 'createdAt'],            // may sort:    ?sort=-price
      allowedFields: ['name', 'price', 'category', 'inStock'],   // may write these fields, nothing else
    }));
    
    app.use(errorHandler);                                       // 3. error handler — always last
    
    await mongoose.connect(process.env.MONGODB_URI ?? 'mongodb://localhost:27017/shop');
    app.listen(3000, () => console.log('http://localhost:3000/api/products'));
  4. Run it

    node app.js
  5. Call it

    # Create
    curl -X POST localhost:3000/api/products -H 'Content-Type: application/json' \
      -d '{"name":"Oak chair","price":120,"category":"chairs"}'
    
    # List: filter, search, sort, paginate
    curl 'localhost:3000/api/products?category=chairs&s=oak&sort=-price&page=1&pageSize=10'
    
    # Read, update, delete one
    curl localhost:3000/api/products/<id>
    curl -X PATCH localhost:3000/api/products/<id> -H 'Content-Type: application/json' -d '{"price":99}'
    curl -X DELETE localhost:3000/api/products/<id>
  6. Lock down writes

    Right now anyone can create, change and delete products — generated routes are public until you add a guard. Split the model into a public read-only router and a guarded admin router:

    const catalogue = {
      model: Product,
      orderBy: '-createdAt',
      query: ['category', 'inStock'],
      search: ['name'],
      filterableFields: ['price', 'category', 'inStock'],
      sortableFields: ['price', 'name', 'createdAt'],
    };
    
    // Stand-in guard for this tutorial — replace with auth.requireAuth + requireRole('admin')
    const adminOnly = (req, res, next) =>
      req.get('x-api-key') === process.env.ADMIN_KEY
        ? next()
        : res.status(401).json({ success: false, error: 'Unauthorized.' });
    
    // Refuse every method that isn't a read.
    const readOnly = (req, res, next) =>
      ['GET', 'HEAD', 'OPTIONS', 'QUERY'].includes(req.method)
        ? next()
        : res.status(405).json({ success: false, error: 'Method not allowed.' });
    
    // Public: read only.
    app.use('/api/products', createRouter({ ...catalogue, middlewares: [readOnly], allowedFields: [] }));
    
    // Admin: read and write, behind the guard.
    app.use('/api/admin/products', createRouter({
      ...catalogue,
      middlewares: [adminOnly],
      allowedFields: ['name', 'price', 'category', 'inStock'],
    }));

    allowedFields: [] alone does not make a router read-only. It stops POST and PATCH from writing any field, but DELETE /:id has no body to filter — without readOnly, anyone could delete products.

    For real sign-in with JWTs and roles, see Authentication.

What you get back#

Every response has the same envelope, so a client always branches on success:

// GET /api/products?page=1&pageSize=10
{
  "success": true,
  "data": [ { "_id": "65af…", "name": "Oak chair", "price": 120, "category": "chairs", "inStock": true } ],
  "pagination": { "currentPage": 1, "pageSize": 10, "totalPages": 1, "totalRecords": 1 }
}

// Any error
{ "success": false, "error": "Entry not found." }

What just happened#

  • createRouter built six routes from one model. Options didn't add features — they granted permissions. A field not named in query can't be filtered; one not in allowedFields can't be written. That's the deny-by-default model.
  • express.json() goes first, so request bodies are parsed; errorHandler goes last, so unexpected failures become a clean 500 instead of a stack trace.
  • Access control is yours: the library builds routes, middlewares decides who may call them.

Where next#

Esc
↑ ↓ to move↵ to open