UNPKG

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
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 };