crud-api-express
Version:
    ![ma
306 lines (286 loc) • 11.2 kB
text/typescript
import { Request, Response, Router, NextFunction } from 'express';
import { Document, Model, Aggregate } from 'mongoose';
/**
* Options for configuring the CRUD controller behavior.
*/
export interface CrudOptions<T extends Document> {
middleware?: ((req: Request, res: Response, next: NextFunction) => void)[];
onSuccess?: (res: Response, method: string, result: T | T[] | any) => void;
onError?: (res: Response, method: string, error: Error) => void;
methods?: ('POST' | 'GET' | 'PUT' | 'DELETE')[];
relatedModel?: Model<any>;
relatedField?: string;
relatedMethods?: ('POST' | 'GET' | 'PUT' | 'DELETE')[];
aggregatePipeline?: object[];
customRoutes?: {
method: 'post' | 'get' | 'put' | 'delete',
path: string,
handler: (req: Request, res: Response) => void
}[];
}
/**
* A generic CRUD Controller for Express/Mongoose API development.
*
* It registers standard CRUD endpoints based on the provided model and endpoint string,
* along with any custom routes.
*/
class CrudController<T extends Document> {
private model: Model<T>;
private endpoint: string;
private router: Router;
private routes: { method: string; path: string; params?: string[] }[];
constructor(model: Model<T>, endpoint: string, options: CrudOptions<T> = {}) {
this.model = model;
this.endpoint = endpoint;
this.router = Router();
this.routes = [];
this.configureRoutes(options);
}
/**
* Applies middleware to the route handler.
* @param routeHandler The original route handler.
* @param middlewareList Optional array of middleware functions.
* @returns Array of middleware functions including the route handler.
*/
private applyMiddleware(handler: (req: Request, res: Response) => void, middleware: ((req: Request, res: Response, next: NextFunction) => void)[]) {
return [...middleware, handler];
}
/**
* Registers route definitions to the internal list.
* @param method HTTP method.
* @param path URL path.
* @param params Optional route parameter names.
*/
private registerRoute(method: string, path: string, params?: string[]) {
this.routes.push({ method, path, params });
}
/**
* Configures all endpoints based on given options.
* @param options CrudOptions to setup CRUD behaviors.
*/
private configureRoutes(options: CrudOptions<T>) {
// Destructure and set default middleware and callbacks
const {
middleware = [],
onSuccess = (res, method, result) => res.status(200).send(result),
onError = (res, method, error) => res.status(400).send(error),
methods = ['POST', 'GET', 'PUT', 'DELETE']
} = options;
// CREATE operation - POST /endpoint
if (methods.includes('POST')) {
const path = `/${this.endpoint}`;
this.router.post(
path,
this.applyMiddleware(async (req, res) => {
const method = 'POST';
try {
const result: any = await this.model.create(req.body);
// If a related model is defined and supports POST, create related entry
if (options.relatedModel && options.relatedMethods?.includes('POST')) {
await options.relatedModel.create({
[options.relatedField!]: result._id,
...req.body
});
}
// Return 201 Created status if successful
onSuccess(res.status(201), method, result);
} catch (error: any) {
onError(res, method, error);
}
}, middleware)
);
this.registerRoute('POST', path);
}
// READ ALL operation - GET /endpoint
if (methods.includes('GET')) {
const path = `/${this.endpoint}`;
this.router.get(
path,
this.applyMiddleware(async (req, res) => {
const method = 'GET';
try {
const { filter, sort, page, limit } = req.query;
const query = filter ? JSON.parse(filter as string) : {};
const sortOrder = sort ? JSON.parse(sort as string) : {};
const pageNumber = parseInt(page as string, 10) || 1;
const pageSize = parseInt(limit as string, 10) || 10;
const skip = (pageNumber - 1) * pageSize;
let items: T[] | Aggregate<any[]>;
if (options.relatedModel && options.relatedMethods?.includes('GET')) {
// Use aggregation with lookup if related model exists
items = await this.model.aggregate([
{ $match: query },
{
$lookup: {
from: options.relatedModel.collection.name,
localField: options.relatedField!,
foreignField: '_id',
as: 'relatedData'
}
},
{ $sort: sortOrder },
{ $skip: skip },
{ $limit: pageSize }
]);
} else {
items = await this.model.find(query).sort(sortOrder).skip(skip).limit(pageSize);
}
onSuccess(res, method, items);
} catch (error: any) {
onError(res, method, error);
}
}, middleware)
);
this.registerRoute('GET', path, ['filter', 'sort', 'page', 'limit']);
}
// READ ONE operation - GET /endpoint/:id
if (methods.includes('GET')) {
const path = `/${this.endpoint}/:id`;
this.router.get(
path,
this.applyMiddleware(async (req, res) => {
const method = 'GET';
try {
let item: T | null;
if (options.relatedModel && options.relatedMethods?.includes('GET')) {
const aggregateResult = await this.model.aggregate([
{ $match: { _id: req.params.id } },
{
$lookup: {
from: options.relatedModel.collection.name,
localField: options.relatedField!,
foreignField: '_id',
as: 'relatedData'
}
}
]);
item = aggregateResult[0] as T || null;
} else {
item = await this.model.findById(req.params.id);
}
if (!item) {
return res.status(404).send({ message: 'Item not found' });
}
onSuccess(res, method, item);
} catch (error: any) {
onError(res, method, error);
}
}, middleware)
);
this.registerRoute('GET', path, ['id']);
}
// UPDATE operation - PUT /endpoint/:id
if (methods.includes('PUT')) {
const path = `/${this.endpoint}/:id`;
this.router.put(
path,
this.applyMiddleware(async (req, res) => {
const method = 'PUT';
try {
const item = await this.model.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
if (!item) {
return res.status(404).send({ message: 'Item not found' });
}
// Update related model entries if configured
if (options.relatedModel && options.relatedMethods?.includes('PUT')) {
await options.relatedModel.updateMany({ [options.relatedField!]: item._id }, req.body);
}
onSuccess(res, method, item);
} catch (error: any) {
onError(res, method, error);
}
}, middleware)
);
this.registerRoute('PUT', path, ['id']);
}
// DELETE MULTIPLE operation - DELETE /endpoint?filter=...
if (methods.includes('DELETE')) {
const path = `/${this.endpoint}`;
this.router.delete(
path,
this.applyMiddleware(async (req, res) => {
const method = 'DELETE';
try {
const query = req.query.filter ? JSON.parse(req.query.filter as string) : {};
const deleteResult: any = await this.model.deleteMany(query);
if (deleteResult.deletedCount === 0) {
return res.status(404).send({ message: 'No matching items found to delete' });
}
if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
await options.relatedModel.deleteMany({ [options.relatedField!]: { $in: query } });
}
onSuccess(res, method, deleteResult);
} catch (error: any) {
onError(res, method, error);
}
}, middleware)
);
this.registerRoute('DELETE', path, ['filter']);
}
// DELETE ONE operation - DELETE /endpoint/:id
if (methods.includes('DELETE')) {
const path = `/${this.endpoint}/:id`;
this.router.delete(
path,
this.applyMiddleware(async (req, res) => {
const method = 'DELETE';
try {
const item = await this.model.findByIdAndDelete(req.params.id);
if (!item) {
return res.status(404).send({ message: 'Item not found' });
}
if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
await options.relatedModel.deleteMany({ [options.relatedField!]: item._id });
}
onSuccess(res, method, item);
} catch (error: any) {
onError(res, method, error);
}
}, middleware)
);
this.registerRoute('DELETE', path, ['id']);
}
// AGGREGATE operation - GET /endpoint/aggregate
if (methods.includes('GET') && options.aggregatePipeline) {
const path = `/${this.endpoint}/aggregate`;
this.router.get(
path,
this.applyMiddleware(async (req, res) => {
const method = 'GET (Aggregate)';
try {
const pipeline: any[] = options.aggregatePipeline || [];
const results = await this.model.aggregate(pipeline);
onSuccess(res, method, results);
} catch (error: any) {
onError(res, method, error);
}
}, middleware)
);
this.registerRoute('GET', path);
}
// CUSTOM ROUTES
if (options.customRoutes) {
options.customRoutes.forEach(route => {
const { method, path, handler } = route;
if (methods.includes(method.toUpperCase() as 'POST' | 'GET' | 'PUT' | 'DELETE')) {
// Prepend base endpoint to custom route path
this.router[method](`/${this.endpoint}${path}`, this.applyMiddleware(handler, middleware));
this.registerRoute(method.toUpperCase(), `/${this.endpoint}${path}`);
}
});
}
}
/**
* Returns the configured Express router.
*/
public getRouter(): Router {
return this.router;
}
/**
* Returns an array of registered route definitions.
*/
public getRoutes(): { method: string; path: string; params?: string[] }[] {
return this.routes;
}
}
export default CrudController;