crud-api-express
Version:
A powerful, flexible CRUD controller for Express + Mongoose — auto-generates RESTful endpoints with lifecycle hooks, validation, soft delete, search, bulk operations, pagination, and more.
610 lines (607 loc) • 30.9 kB
JavaScript
import { Router } from 'express';
import { Types } from 'mongoose';
// ─── CrudController Class ───────────────────────────────────────────────────
/**
* A powerful, flexible CRUD Controller for Express + Mongoose.
*
* Automatically generates RESTful endpoints for any Mongoose model with
* support for lifecycle hooks, validation, soft delete, bulk operations,
* search, field selection, population, and more.
*
* @example
* const ctrl = new CrudController(UserModel, 'users', {
* methods: ['GET', 'POST', 'PATCH', 'DELETE'],
* softDelete: true,
* searchFields: ['name', 'email'],
* hooks: {
* beforeCreate: (req, data) => ({ ...data, createdBy: req.user.id }),
* },
* });
* app.use('/api', ctrl.getRouter());
*/
class CrudController {
constructor(model, endpoint, options = {}) {
this.model = model;
this.endpoint = endpoint;
this.router = Router();
this.routes = [];
this.configureRoutes(options);
}
// ── Helpers ─────────────────────────────────────────────────────────────
/**
* Merges global middleware + per-operation middleware + handler into a single array.
*/
buildMiddlewareChain(handler, globalMiddleware, operationMiddleware) {
return [...globalMiddleware, ...(operationMiddleware || []), handler];
}
/** Records a route in the internal registry. */
registerRoute(method, path, params) {
this.routes.push({ method, path, params });
}
/** Parses a `?select=name,email` query param into Mongoose select syntax. */
parseSelect(querySelect, defaultSelect) {
if (querySelect)
return querySelect.split(',').join(' ');
return defaultSelect;
}
/** Parses a `?populate=author,comments` query param. */
parsePopulate(queryPopulate, defaultPopulate) {
if (queryPopulate) {
return queryPopulate.split(',').map((p) => p.trim());
}
return defaultPopulate;
}
/** Safely parses JSON from a query param, returns fallback on failure. */
safeJsonParse(value, fallback = {}) {
if (!value)
return fallback;
try {
return JSON.parse(value);
}
catch {
return fallback;
}
}
/** Builds the soft-delete filter to exclude deleted records. */
softDeleteFilter(req, softDelete) {
if (!softDelete)
return {};
const includeDeleted = req.query.includeDeleted === 'true';
return includeDeleted ? {} : { deletedAt: { $exists: false } };
}
// ── Route Configuration ────────────────────────────────────────────────
configureRoutes(options) {
const { middleware = [], routeMiddleware = {}, onSuccess = (res, method, result, meta) => {
if (meta) {
res.status(200).json({ data: result, pagination: meta });
}
else {
res.status(200).json(result);
}
}, onError = (res, method, error) => res.status(400).json({ error: error.message }), methods = ['POST', 'GET', 'PUT', 'PATCH', 'DELETE'], hooks = {}, validate = {}, softDelete = false, select: defaultSelect, populate: defaultPopulate, searchFields = [], } = options;
// ── CREATE ─ POST /endpoint ─────────────────────────────────────────
if (methods.includes('POST')) {
const path = `/${this.endpoint}`;
this.router.post(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
// Validation hook
if (validate.create) {
const validation = validate.create(req.body);
if (!validation.valid) {
return res.status(400).json({ errors: validation.errors });
}
}
// Before hook
let data = req.body;
if (hooks.beforeCreate) {
data = (await hooks.beforeCreate(req, data)) ?? data;
}
const result = await this.model.create(data);
// Related model cascade
if (options.relatedModel && options.relatedMethods?.includes('POST')) {
await options.relatedModel.create({
[options.relatedField]: result._id,
...data,
});
}
// After hook
if (hooks.afterCreate) {
await hooks.afterCreate(req, result);
}
onSuccess(res.status(201), 'POST', result);
}
catch (error) {
onError(res, 'POST', error);
}
}, middleware, routeMiddleware.create));
this.registerRoute('POST', path);
}
// ── BULK CREATE ─ POST /endpoint/bulk ───────────────────────────────
if (methods.includes('POST')) {
const path = `/${this.endpoint}/bulk`;
this.router.post(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
const items = req.body;
if (!Array.isArray(items)) {
return res.status(400).json({ error: 'Request body must be an array' });
}
// Validate each item
if (validate.create) {
for (let i = 0; i < items.length; i++) {
const validation = validate.create(items[i]);
if (!validation.valid) {
return res.status(400).json({
error: `Validation failed for item at index ${i}`,
errors: validation.errors,
});
}
}
}
// Before hooks
let processedItems = items;
if (hooks.beforeCreate) {
processedItems = [];
for (const item of items) {
const result = (await hooks.beforeCreate(req, item)) ?? item;
processedItems.push(result);
}
}
const results = await this.model.insertMany(processedItems);
onSuccess(res.status(201), 'POST (Bulk)', results);
}
catch (error) {
onError(res, 'POST (Bulk)', error);
}
}, middleware, routeMiddleware.create));
this.registerRoute('POST', path);
}
// ── SEARCH ─ GET /endpoint/search ───────────────────────────────────
if (methods.includes('GET') && searchFields.length > 0) {
const path = `/${this.endpoint}/search`;
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
const q = req.query.q;
if (!q) {
return res.status(400).json({ error: 'Query parameter "q" is required' });
}
const fieldsParam = req.query.fields;
const fields = fieldsParam ? fieldsParam.split(',').map((f) => f.trim()) : searchFields;
const searchQuery = {
$or: fields.map((field) => ({
[field]: { $regex: q, $options: 'i' },
})),
...this.softDeleteFilter(req, softDelete),
};
const pageNumber = parseInt(req.query.page, 10) || 1;
const pageSize = parseInt(req.query.limit, 10) || 10;
const skip = (pageNumber - 1) * pageSize;
const selectStr = this.parseSelect(req.query.select, defaultSelect);
const populateOpt = this.parsePopulate(req.query.populate, defaultPopulate);
let query = this.model.find(searchQuery).skip(skip).limit(pageSize);
if (selectStr)
query = query.select(selectStr);
if (populateOpt)
query = query.populate(populateOpt);
const [items, total] = await Promise.all([
query.exec(),
this.model.countDocuments(searchQuery),
]);
const meta = {
total,
page: pageNumber,
limit: pageSize,
pages: Math.ceil(total / pageSize),
hasNext: pageNumber * pageSize < total,
hasPrev: pageNumber > 1,
};
let result = items;
if (hooks.afterRead) {
result = (await hooks.afterRead(req, items)) ?? items;
}
onSuccess(res, 'GET (Search)', result, meta);
}
catch (error) {
onError(res, 'GET (Search)', error);
}
}, middleware, routeMiddleware.read));
this.registerRoute('GET', path, ['q', 'fields', 'page', 'limit', 'select', 'populate']);
}
// ── COUNT ─ GET /endpoint/count ─────────────────────────────────────
if (methods.includes('GET')) {
const path = `/${this.endpoint}/count`;
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
const filter = {
...this.safeJsonParse(req.query.filter),
...this.softDeleteFilter(req, softDelete),
};
const count = await this.model.countDocuments(filter);
onSuccess(res, 'GET (Count)', { count });
}
catch (error) {
onError(res, 'GET (Count)', error);
}
}, middleware, routeMiddleware.read));
this.registerRoute('GET', path, ['filter']);
}
// ── AGGREGATE ─ GET /endpoint/aggregate ─────────────────────────────
if (methods.includes('GET') && options.aggregatePipeline) {
const path = `/${this.endpoint}/aggregate`;
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
const pipeline = typeof options.aggregatePipeline === 'function'
? options.aggregatePipeline(req)
: options.aggregatePipeline || [];
const results = await this.model.aggregate(pipeline);
onSuccess(res, 'GET (Aggregate)', results);
}
catch (error) {
onError(res, 'GET (Aggregate)', error);
}
}, middleware, routeMiddleware.read));
this.registerRoute('GET', path);
}
// ── EXISTS ─ GET /endpoint/exists/:id ───────────────────────────────
if (methods.includes('GET')) {
const path = `/${this.endpoint}/exists/:id`;
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
const filter = { _id: req.params.id };
if (softDelete)
filter.deletedAt = { $exists: false };
const exists = await this.model.exists(filter);
onSuccess(res, 'GET (Exists)', { exists: !!exists });
}
catch (error) {
onError(res, 'GET (Exists)', error);
}
}, middleware, routeMiddleware.read));
this.registerRoute('GET', path, ['id']);
}
// ── READ ALL ─ GET /endpoint ────────────────────────────────────────
if (methods.includes('GET')) {
const path = `/${this.endpoint}`;
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
let filter = {
...this.safeJsonParse(req.query.filter),
...this.softDeleteFilter(req, softDelete),
};
const sortOrder = this.safeJsonParse(req.query.sort);
const pageNumber = parseInt(req.query.page, 10) || 1;
const pageSize = parseInt(req.query.limit, 10) || 10;
const skip = (pageNumber - 1) * pageSize;
const selectStr = this.parseSelect(req.query.select, defaultSelect);
const populateOpt = this.parsePopulate(req.query.populate, defaultPopulate);
// Before read hook
if (hooks.beforeRead) {
filter = (await hooks.beforeRead(req, filter)) ?? filter;
}
let items;
if (options.relatedModel && options.relatedMethods?.includes('GET')) {
items = await this.model.aggregate([
{ $match: filter },
{
$lookup: {
from: options.relatedModel.collection.name,
localField: options.relatedField,
foreignField: '_id',
as: 'relatedData',
},
},
{ $sort: Object.keys(sortOrder).length ? sortOrder : { _id: -1 } },
{ $skip: skip },
{ $limit: pageSize },
]);
}
else {
let query = this.model
.find(filter)
.sort(Object.keys(sortOrder).length ? sortOrder : undefined)
.skip(skip)
.limit(pageSize);
if (selectStr)
query = query.select(selectStr);
if (populateOpt)
query = query.populate(populateOpt);
items = await query.exec();
}
const total = await this.model.countDocuments(filter);
const meta = {
total,
page: pageNumber,
limit: pageSize,
pages: Math.ceil(total / pageSize),
hasNext: pageNumber * pageSize < total,
hasPrev: pageNumber > 1,
};
let result = items;
if (hooks.afterRead) {
result = (await hooks.afterRead(req, items)) ?? items;
}
onSuccess(res, 'GET', result, meta);
}
catch (error) {
onError(res, 'GET', error);
}
}, middleware, routeMiddleware.read));
this.registerRoute('GET', path, ['filter', 'sort', 'page', 'limit', 'select', 'populate']);
}
// ── READ ONE ─ GET /endpoint/:id ────────────────────────────────────
if (methods.includes('GET')) {
const path = `/${this.endpoint}/:id`;
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
const selectStr = this.parseSelect(req.query.select, defaultSelect);
const populateOpt = this.parsePopulate(req.query.populate, defaultPopulate);
let item;
if (options.relatedModel && options.relatedMethods?.includes('GET')) {
const matchFilter = { _id: new Types.ObjectId(req.params.id) };
if (softDelete)
matchFilter.deletedAt = { $exists: false };
const aggregateResult = await this.model.aggregate([
{ $match: matchFilter },
{
$lookup: {
from: options.relatedModel.collection.name,
localField: options.relatedField,
foreignField: '_id',
as: 'relatedData',
},
},
]);
item = aggregateResult[0] || null;
}
else {
const findFilter = { _id: req.params.id };
if (softDelete)
findFilter.deletedAt = { $exists: false };
let query = this.model.findOne(findFilter);
if (selectStr)
query = query.select(selectStr);
if (populateOpt)
query = query.populate(populateOpt);
item = await query.exec();
}
if (!item) {
return res.status(404).json({ message: 'Item not found' });
}
let result = item;
if (hooks.afterRead) {
result = (await hooks.afterRead(req, item)) ?? item;
}
onSuccess(res, 'GET', result);
}
catch (error) {
onError(res, 'GET', error);
}
}, middleware, routeMiddleware.read));
this.registerRoute('GET', path, ['id', 'select', 'populate']);
}
// ── UPDATE (FULL) ─ PUT /endpoint/:id ───────────────────────────────
if (methods.includes('PUT')) {
const path = `/${this.endpoint}/:id`;
this.router.put(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
if (validate.update) {
const validation = validate.update(req.body);
if (!validation.valid) {
return res.status(400).json({ errors: validation.errors });
}
}
let data = req.body;
if (hooks.beforeUpdate) {
data = (await hooks.beforeUpdate(req, req.params.id, data)) ?? data;
}
const item = await this.model.findByIdAndUpdate(req.params.id, data, {
new: true,
runValidators: true,
});
if (!item) {
return res.status(404).json({ message: 'Item not found' });
}
if (options.relatedModel && options.relatedMethods?.includes('PUT')) {
await options.relatedModel.updateMany({ [options.relatedField]: item._id }, data);
}
if (hooks.afterUpdate) {
await hooks.afterUpdate(req, item);
}
onSuccess(res, 'PUT', item);
}
catch (error) {
onError(res, 'PUT', error);
}
}, middleware, routeMiddleware.update));
this.registerRoute('PUT', path, ['id']);
}
// ── UPDATE (PARTIAL) ─ PATCH /endpoint/:id ──────────────────────────
if (methods.includes('PATCH')) {
const path = `/${this.endpoint}/:id`;
this.router.patch(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
if (validate.update) {
const validation = validate.update(req.body);
if (!validation.valid) {
return res.status(400).json({ errors: validation.errors });
}
}
let data = req.body;
if (hooks.beforeUpdate) {
data = (await hooks.beforeUpdate(req, req.params.id, data)) ?? data;
}
const item = await this.model.findByIdAndUpdate(req.params.id, { $set: data }, { new: true, runValidators: true });
if (!item) {
return res.status(404).json({ message: 'Item not found' });
}
if (hooks.afterUpdate) {
await hooks.afterUpdate(req, item);
}
onSuccess(res, 'PATCH', item);
}
catch (error) {
onError(res, 'PATCH', error);
}
}, middleware, routeMiddleware.update));
this.registerRoute('PATCH', path, ['id']);
}
// ── BULK UPDATE ─ PATCH /endpoint/bulk ──────────────────────────────
if (methods.includes('PATCH')) {
const path = `/${this.endpoint}/bulk`;
this.router.patch(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
const { filter, update } = req.body;
if (!filter || !update) {
return res.status(400).json({
error: 'Request body must contain "filter" and "update" objects',
});
}
if (validate.update) {
const validation = validate.update(update);
if (!validation.valid) {
return res.status(400).json({ errors: validation.errors });
}
}
const result = await this.model.updateMany(filter, { $set: update }, { runValidators: true });
onSuccess(res, 'PATCH (Bulk)', result);
}
catch (error) {
onError(res, 'PATCH (Bulk)', error);
}
}, middleware, routeMiddleware.update));
this.registerRoute('PATCH', path, ['filter', 'update']);
}
// ── SOFT DELETE RESTORE ─ PATCH /endpoint/:id/restore ───────────────
if (softDelete && (methods.includes('PATCH') || methods.includes('PUT'))) {
const path = `/${this.endpoint}/:id/restore`;
this.router.patch(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
const item = await this.model.findByIdAndUpdate(req.params.id, { $unset: { deletedAt: 1 } }, { new: true });
if (!item) {
return res.status(404).json({ message: 'Item not found' });
}
onSuccess(res, 'PATCH (Restore)', item);
}
catch (error) {
onError(res, 'PATCH (Restore)', error);
}
}, middleware, routeMiddleware.update));
this.registerRoute('PATCH', path, ['id']);
}
// ── DELETE MULTIPLE ─ DELETE /endpoint ───────────────────────────────
if (methods.includes('DELETE')) {
const path = `/${this.endpoint}`;
this.router.delete(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
const query = this.safeJsonParse(req.query.filter);
if (Object.keys(query).length === 0) {
return res.status(400).json({
error: 'A filter is required for bulk delete to prevent accidental data loss',
});
}
let deleteResult;
if (softDelete) {
deleteResult = await this.model.updateMany(query, {
$set: { deletedAt: new Date() },
});
}
else {
deleteResult = await this.model.deleteMany(query);
}
if ((deleteResult.deletedCount ?? deleteResult.modifiedCount) === 0) {
return res.status(404).json({ message: 'No matching items found to delete' });
}
if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
const ids = (await this.model.find(query).select('_id')).map((d) => d._id);
await options.relatedModel.deleteMany({
[options.relatedField]: { $in: ids },
});
}
onSuccess(res, 'DELETE', deleteResult);
}
catch (error) {
onError(res, 'DELETE', error);
}
}, middleware, routeMiddleware.delete));
this.registerRoute('DELETE', path, ['filter']);
}
// ── DELETE ONE ─ DELETE /endpoint/:id ────────────────────────────────
if (methods.includes('DELETE')) {
const path = `/${this.endpoint}/:id`;
this.router.delete(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
if (hooks.beforeDelete) {
await hooks.beforeDelete(req, req.params.id);
}
let item;
if (softDelete) {
item = await this.model.findByIdAndUpdate(req.params.id, { $set: { deletedAt: new Date() } }, { new: true });
}
else {
item = await this.model.findByIdAndDelete(req.params.id);
}
if (!item) {
return res.status(404).json({ message: 'Item not found' });
}
if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
await options.relatedModel.deleteMany({
[options.relatedField]: item._id,
});
}
if (hooks.afterDelete) {
await hooks.afterDelete(req, item);
}
onSuccess(res, 'DELETE', item);
}
catch (error) {
onError(res, 'DELETE', error);
}
}, middleware, routeMiddleware.delete));
this.registerRoute('DELETE', path, ['id']);
}
// ── BULK DELETE ─ DELETE /endpoint/bulk ──────────────────────────────
if (methods.includes('DELETE')) {
const path = `/${this.endpoint}/bulk`;
this.router.delete(path, ...this.buildMiddlewareChain(async (req, res) => {
try {
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({
error: 'Request body must contain a non-empty "ids" array',
});
}
let deleteResult;
if (softDelete) {
deleteResult = await this.model.updateMany({ _id: { $in: ids } }, { $set: { deletedAt: new Date() } });
}
else {
deleteResult = await this.model.deleteMany({ _id: { $in: ids } });
}
onSuccess(res, 'DELETE (Bulk)', deleteResult);
}
catch (error) {
onError(res, 'DELETE (Bulk)', error);
}
}, middleware, routeMiddleware.delete));
this.registerRoute('DELETE', path);
}
// ── CUSTOM ROUTES ───────────────────────────────────────────────────
if (options.customRoutes) {
options.customRoutes.forEach((route) => {
const { method, path, handler, middleware: routeMw } = route;
const fullPath = `/${this.endpoint}${path}`;
this.router[method](fullPath, ...this.buildMiddlewareChain(handler, middleware, routeMw));
this.registerRoute(method.toUpperCase(), fullPath);
});
}
}
// ── Public API ──────────────────────────────────────────────────────────
/** Returns the configured Express Router with all CRUD routes. */
getRouter() {
return this.router;
}
/** Returns an array of all registered route definitions. */
getRoutes() {
return this.routes;
}
}
export { CrudController, CrudController as default };