crud-api-express 1.2.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,23 +1,260 @@
1
1
  import { Request, Response, Router, NextFunction } from 'express';
2
- import { Document, Model, Aggregate } from 'mongoose';
2
+ import { Document, Model, Types } from 'mongoose';
3
3
 
4
- interface CrudOptions<T extends Document> {
5
- middleware?: ((req: Request, res: Response, next: NextFunction) => void)[];
6
- onSuccess?: (res: Response, method: string, result: T | T[]) => void;
7
- onError?: (res: Response, method: string, error: Error) => void;
8
- methods?: ('POST' | 'GET' | 'PUT' | 'DELETE')[];
4
+ // ─── Exported Type Aliases ───────────────────────────────────────────────────
5
+
6
+ /** Express middleware function signature. */
7
+ export type MiddlewareFunction = (req: Request, res: Response, next: NextFunction) => void;
8
+
9
+ /** Success callback invoked after a successful operation. */
10
+ export type SuccessHandler<T> = (
11
+ res: Response,
12
+ method: string,
13
+ result: T | T[] | any,
14
+ meta?: PaginationMeta
15
+ ) => void;
16
+
17
+ /** Error callback invoked when an operation fails. */
18
+ export type ErrorHandler = (res: Response, method: string, error: Error) => void;
19
+
20
+ /** Validation result returned by validate hooks. */
21
+ export interface ValidationResult {
22
+ valid: boolean;
23
+ errors?: string[];
24
+ }
25
+
26
+ /** Pagination metadata included in list responses. */
27
+ export interface PaginationMeta {
28
+ total: number;
29
+ page: number;
30
+ limit: number;
31
+ pages: number;
32
+ hasNext: boolean;
33
+ hasPrev: boolean;
34
+ }
35
+
36
+ /** Allowed HTTP methods for the CRUD controller. */
37
+ export type HttpMethod = 'POST' | 'GET' | 'PUT' | 'PATCH' | 'DELETE';
38
+
39
+ // ─── CrudOptions Interface ──────────────────────────────────────────────────
40
+
41
+ /**
42
+ * Configuration options for CrudController.
43
+ *
44
+ * Every option is optional — the controller works with zero configuration,
45
+ * but each option unlocks additional flexibility.
46
+ */
47
+ export interface CrudOptions<T extends Document> {
48
+ // ── Core ──────────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * HTTP methods to enable. Defaults to all five.
52
+ * @default ['POST', 'GET', 'PUT', 'PATCH', 'DELETE']
53
+ */
54
+ methods?: HttpMethod[];
55
+
56
+ /**
57
+ * Global middleware applied to **every** generated route.
58
+ * For per-operation middleware, use `routeMiddleware` instead.
59
+ */
60
+ middleware?: MiddlewareFunction[];
61
+
62
+ /**
63
+ * Per-operation middleware — lets you apply different middleware
64
+ * to different CRUD operations (e.g. only admins can delete).
65
+ *
66
+ * @example
67
+ * routeMiddleware: {
68
+ * delete: [requireAdmin],
69
+ * create: [validateBody],
70
+ * }
71
+ */
72
+ routeMiddleware?: {
73
+ create?: MiddlewareFunction[];
74
+ read?: MiddlewareFunction[];
75
+ update?: MiddlewareFunction[];
76
+ delete?: MiddlewareFunction[];
77
+ };
78
+
79
+ // ── Response Callbacks ────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Custom success response handler.
83
+ * The 4th argument `meta` is provided on paginated list endpoints.
84
+ */
85
+ onSuccess?: SuccessHandler<T>;
86
+
87
+ /** Custom error response handler. */
88
+ onError?: ErrorHandler;
89
+
90
+ // ── Lifecycle Hooks ───────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Lifecycle hooks that run before/after each operation.
94
+ * `before*` hooks can transform data by returning a modified object.
95
+ * `after*` hooks are for side-effects (logging, events, notifications).
96
+ *
97
+ * @example
98
+ * hooks: {
99
+ * beforeCreate: async (req, data) => {
100
+ * data.createdBy = req.user.id;
101
+ * return data;
102
+ * },
103
+ * afterDelete: async (req, result) => {
104
+ * await auditLog('delete', result._id);
105
+ * },
106
+ * }
107
+ */
108
+ hooks?: {
109
+ beforeCreate?: (req: Request, data: any) => Promise<any> | any;
110
+ afterCreate?: (req: Request, result: T) => Promise<void> | void;
111
+ beforeUpdate?: (req: Request, id: string, data: any) => Promise<any> | any;
112
+ afterUpdate?: (req: Request, result: T) => Promise<void> | void;
113
+ beforeDelete?: (req: Request, id: string) => Promise<void> | void;
114
+ afterDelete?: (req: Request, result: T) => Promise<void> | void;
115
+ beforeRead?: (req: Request, query: any) => Promise<any> | any;
116
+ afterRead?: (req: Request, result: T | T[]) => Promise<T | T[]> | (T | T[]);
117
+ };
118
+
119
+ // ── Validation ────────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * Validation hooks that run **before** Mongoose validation.
123
+ * Return `{ valid: false, errors: [...] }` to reject early with a 400.
124
+ *
125
+ * @example
126
+ * validate: {
127
+ * create: (data) => ({
128
+ * valid: !!data.email,
129
+ * errors: data.email ? [] : ['Email is required'],
130
+ * }),
131
+ * }
132
+ */
133
+ validate?: {
134
+ create?: (data: any) => ValidationResult;
135
+ update?: (data: any) => ValidationResult;
136
+ };
137
+
138
+ // ── Query Features ────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * Default fields to return (Mongoose select syntax).
142
+ * Can be overridden per-request via `?select=name,email`.
143
+ * @example "name email -password"
144
+ */
145
+ select?: string;
146
+
147
+ /**
148
+ * Auto-populate references on read operations.
149
+ * Can be overridden per-request via `?populate=author,comments`.
150
+ * @example "author" or ["author", { path: "comments", select: "text" }]
151
+ */
152
+ populate?: string | object | (string | object)[];
153
+
154
+ /**
155
+ * Fields to search across when using the `/search` endpoint.
156
+ * Uses case-insensitive `$regex` matching.
157
+ * @example ['name', 'email', 'description']
158
+ */
159
+ searchFields?: string[];
160
+
161
+ // ── Soft Delete ───────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * When `true`, DELETE operations set `deletedAt: new Date()` instead of
165
+ * removing the document. GET operations auto-exclude soft-deleted records
166
+ * unless `?includeDeleted=true` is passed.
167
+ *
168
+ * A restore endpoint `PATCH /endpoint/:id/restore` is also created.
169
+ * @default false
170
+ */
171
+ softDelete?: boolean;
172
+
173
+ // ── Aggregation ───────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * MongoDB aggregation pipeline stages, or a function that receives the
177
+ * request and returns pipeline stages (for dynamic pipelines).
178
+ *
179
+ * @example
180
+ * // Static pipeline
181
+ * aggregatePipeline: [{ $match: { status: 'Active' } }]
182
+ *
183
+ * // Dynamic pipeline
184
+ * aggregatePipeline: (req) => [
185
+ * { $match: { region: req.query.region } },
186
+ * ]
187
+ */
188
+ aggregatePipeline?: object[] | ((req: Request) => object[]);
189
+
190
+ // ── Related Model ─────────────────────────────────────────────────────
191
+
192
+ /** Related Mongoose model for cascading operations. */
9
193
  relatedModel?: Model<any>;
194
+
195
+ /** Field name linking the related model to this model. */
10
196
  relatedField?: string;
11
- relatedMethods?: ('POST' | 'GET' | 'PUT' | 'DELETE')[];
12
- aggregatePipeline?: object[];
13
- customRoutes?: { method: 'post' | 'get' | 'put' | 'delete', path: string, handler: (req: Request, res: Response) => void }[];
197
+
198
+ /** HTTP methods to cascade to the related model. */
199
+ relatedMethods?: HttpMethod[];
200
+
201
+ // ── Custom Routes ─────────────────────────────────────────────────────
202
+
203
+ /**
204
+ * Additional custom routes beyond standard CRUD.
205
+ * Custom routes are **always** registered regardless of `methods` filter.
206
+ *
207
+ * @example
208
+ * customRoutes: [{
209
+ * method: 'get',
210
+ * path: '/stats',
211
+ * handler: async (req, res) => {
212
+ * const count = await Model.countDocuments();
213
+ * res.json({ count });
214
+ * },
215
+ * }]
216
+ */
217
+ customRoutes?: {
218
+ method: 'post' | 'get' | 'put' | 'patch' | 'delete';
219
+ path: string;
220
+ middleware?: MiddlewareFunction[];
221
+ handler: (req: Request, res: Response) => void;
222
+ }[];
223
+ }
224
+
225
+ // ─── Route Info ─────────────────────────────────────────────────────────────
226
+
227
+ export interface RouteInfo {
228
+ method: string;
229
+ path: string;
230
+ params?: string[];
14
231
  }
15
232
 
233
+ // ─── CrudController Class ───────────────────────────────────────────────────
234
+
235
+ /**
236
+ * A powerful, flexible CRUD Controller for Express + Mongoose.
237
+ *
238
+ * Automatically generates RESTful endpoints for any Mongoose model with
239
+ * support for lifecycle hooks, validation, soft delete, bulk operations,
240
+ * search, field selection, population, and more.
241
+ *
242
+ * @example
243
+ * const ctrl = new CrudController(UserModel, 'users', {
244
+ * methods: ['GET', 'POST', 'PATCH', 'DELETE'],
245
+ * softDelete: true,
246
+ * searchFields: ['name', 'email'],
247
+ * hooks: {
248
+ * beforeCreate: (req, data) => ({ ...data, createdBy: req.user.id }),
249
+ * },
250
+ * });
251
+ * app.use('/api', ctrl.getRouter());
252
+ */
16
253
  class CrudController<T extends Document> {
17
254
  private model: Model<T>;
18
255
  private endpoint: string;
19
256
  private router: Router;
20
- private routes: { method: string, path: string, params?: string[] }[];
257
+ private routes: RouteInfo[];
21
258
 
22
259
  constructor(model: Model<T>, endpoint: string, options: CrudOptions<T> = {}) {
23
260
  this.model = model;
@@ -27,214 +264,752 @@ class CrudController<T extends Document> {
27
264
  this.configureRoutes(options);
28
265
  }
29
266
 
30
- private configureRoutes(options: CrudOptions<T>) {
267
+ // ── Helpers ─────────────────────────────────────────────────────────────
268
+
269
+ /**
270
+ * Merges global middleware + per-operation middleware + handler into a single array.
271
+ */
272
+ private buildMiddlewareChain(
273
+ handler: (req: Request, res: Response) => void,
274
+ globalMiddleware: MiddlewareFunction[],
275
+ operationMiddleware?: MiddlewareFunction[]
276
+ ): (MiddlewareFunction | ((req: Request, res: Response) => void))[] {
277
+ return [...globalMiddleware, ...(operationMiddleware || []), handler];
278
+ }
279
+
280
+ /** Records a route in the internal registry. */
281
+ private registerRoute(method: string, path: string, params?: string[]): void {
282
+ this.routes.push({ method, path, params });
283
+ }
284
+
285
+ /** Parses a `?select=name,email` query param into Mongoose select syntax. */
286
+ private parseSelect(querySelect: string | undefined, defaultSelect?: string): string | undefined {
287
+ if (querySelect) return (querySelect as string).split(',').join(' ');
288
+ return defaultSelect;
289
+ }
290
+
291
+ /** Parses a `?populate=author,comments` query param. */
292
+ private parsePopulate(
293
+ queryPopulate: string | undefined,
294
+ defaultPopulate?: string | object | (string | object)[]
295
+ ): any {
296
+ if (queryPopulate) {
297
+ return (queryPopulate as string).split(',').map((p) => p.trim());
298
+ }
299
+ return defaultPopulate;
300
+ }
301
+
302
+ /** Safely parses JSON from a query param, returns fallback on failure. */
303
+ private safeJsonParse(value: string | undefined, fallback: any = {}): any {
304
+ if (!value) return fallback;
305
+ try {
306
+ return JSON.parse(value);
307
+ } catch {
308
+ return fallback;
309
+ }
310
+ }
311
+
312
+ /** Builds the soft-delete filter to exclude deleted records. */
313
+ private softDeleteFilter(req: Request, softDelete: boolean): object {
314
+ if (!softDelete) return {};
315
+ const includeDeleted = req.query.includeDeleted === 'true';
316
+ return includeDeleted ? {} : { deletedAt: { $exists: false } };
317
+ }
318
+
319
+ // ── Route Configuration ────────────────────────────────────────────────
320
+
321
+ private configureRoutes(options: CrudOptions<T>): void {
31
322
  const {
32
323
  middleware = [],
33
- onSuccess = (res, method, result) => res.status(200).send(result),
34
- onError = (res, method, error) => res.status(400).send(error),
35
- methods = ['POST', 'GET', 'PUT', 'DELETE']
324
+ routeMiddleware = {},
325
+ onSuccess = (res, method, result, meta) => {
326
+ if (meta) {
327
+ res.status(200).json({ data: result, pagination: meta });
328
+ } else {
329
+ res.status(200).json(result);
330
+ }
331
+ },
332
+ onError = (res, method, error) => res.status(400).json({ error: error.message }),
333
+ methods = ['POST', 'GET', 'PUT', 'PATCH', 'DELETE'] as HttpMethod[],
334
+ hooks = {},
335
+ validate = {},
336
+ softDelete = false,
337
+ select: defaultSelect,
338
+ populate: defaultPopulate,
339
+ searchFields = [],
36
340
  } = options;
37
341
 
38
- const applyMiddleware = (routeHandler: (req: Request, res: Response) => void) => {
39
- return [...middleware, routeHandler];
40
- };
342
+ // ── CREATE POST /endpoint ─────────────────────────────────────────
41
343
 
42
- // Helper to register a route
43
- const registerRoute = (method: string, path: string, params?: string[]) => {
44
- this.routes.push({ method, path, params });
45
- };
46
-
47
- // Create
48
344
  if (methods.includes('POST')) {
49
345
  const path = `/${this.endpoint}`;
50
- this.router.post(path, applyMiddleware(async (req, res) => {
51
- const method = 'POST';
52
- try {
53
- const result: any = await this.model.create(req.body);
54
- if (options.relatedModel && options.relatedMethods?.includes('POST')) {
55
- await options.relatedModel.create({ [options.relatedField!]: result._id, ...req.body });
56
- }
57
- onSuccess(res, method, result);
58
- } catch (error: any) {
59
- onError(res, method, error);
60
- }
61
- }));
62
- registerRoute('POST', path);
346
+ this.router.post(
347
+ path,
348
+ ...this.buildMiddlewareChain(
349
+ async (req: Request, res: Response) => {
350
+ try {
351
+ // Validation hook
352
+ if (validate.create) {
353
+ const validation = validate.create(req.body);
354
+ if (!validation.valid) {
355
+ return res.status(400).json({ errors: validation.errors });
356
+ }
357
+ }
358
+ // Before hook
359
+ let data = req.body;
360
+ if (hooks.beforeCreate) {
361
+ data = (await hooks.beforeCreate(req, data)) ?? data;
362
+ }
363
+ const result: any = await this.model.create(data);
364
+ // Related model cascade
365
+ if (options.relatedModel && options.relatedMethods?.includes('POST')) {
366
+ await options.relatedModel.create({
367
+ [options.relatedField!]: result._id,
368
+ ...data,
369
+ });
370
+ }
371
+ // After hook
372
+ if (hooks.afterCreate) {
373
+ await hooks.afterCreate(req, result);
374
+ }
375
+ onSuccess(res.status(201), 'POST', result);
376
+ } catch (error: any) {
377
+ onError(res, 'POST', error);
378
+ }
379
+ },
380
+ middleware,
381
+ routeMiddleware.create
382
+ )
383
+ );
384
+ this.registerRoute('POST', path);
385
+ }
386
+
387
+ // ── BULK CREATE ─ POST /endpoint/bulk ───────────────────────────────
388
+
389
+ if (methods.includes('POST')) {
390
+ const path = `/${this.endpoint}/bulk`;
391
+ this.router.post(
392
+ path,
393
+ ...this.buildMiddlewareChain(
394
+ async (req: Request, res: Response) => {
395
+ try {
396
+ const items = req.body;
397
+ if (!Array.isArray(items)) {
398
+ return res.status(400).json({ error: 'Request body must be an array' });
399
+ }
400
+ // Validate each item
401
+ if (validate.create) {
402
+ for (let i = 0; i < items.length; i++) {
403
+ const validation = validate.create(items[i]);
404
+ if (!validation.valid) {
405
+ return res.status(400).json({
406
+ error: `Validation failed for item at index ${i}`,
407
+ errors: validation.errors,
408
+ });
409
+ }
410
+ }
411
+ }
412
+ // Before hooks
413
+ let processedItems = items;
414
+ if (hooks.beforeCreate) {
415
+ processedItems = [];
416
+ for (const item of items) {
417
+ const result = (await hooks.beforeCreate(req, item)) ?? item;
418
+ processedItems.push(result);
419
+ }
420
+ }
421
+ const results = await this.model.insertMany(processedItems);
422
+ onSuccess(res.status(201), 'POST (Bulk)', results);
423
+ } catch (error: any) {
424
+ onError(res, 'POST (Bulk)', error);
425
+ }
426
+ },
427
+ middleware,
428
+ routeMiddleware.create
429
+ )
430
+ );
431
+ this.registerRoute('POST', path);
432
+ }
433
+
434
+ // ── SEARCH ─ GET /endpoint/search ───────────────────────────────────
435
+
436
+ if (methods.includes('GET') && searchFields.length > 0) {
437
+ const path = `/${this.endpoint}/search`;
438
+ this.router.get(
439
+ path,
440
+ ...this.buildMiddlewareChain(
441
+ async (req: Request, res: Response) => {
442
+ try {
443
+ const q = req.query.q as string;
444
+ if (!q) {
445
+ return res.status(400).json({ error: 'Query parameter "q" is required' });
446
+ }
447
+ const fieldsParam = req.query.fields as string | undefined;
448
+ const fields = fieldsParam ? fieldsParam.split(',').map((f) => f.trim()) : searchFields;
449
+
450
+ const searchQuery: any = {
451
+ $or: fields.map((field) => ({
452
+ [field]: { $regex: q, $options: 'i' },
453
+ })),
454
+ ...this.softDeleteFilter(req, softDelete),
455
+ };
456
+
457
+ const pageNumber = parseInt(req.query.page as string, 10) || 1;
458
+ const pageSize = parseInt(req.query.limit as string, 10) || 10;
459
+ const skip = (pageNumber - 1) * pageSize;
460
+
461
+ const selectStr = this.parseSelect(req.query.select as string, defaultSelect);
462
+ const populateOpt = this.parsePopulate(req.query.populate as string, defaultPopulate);
463
+
464
+ let query = this.model.find(searchQuery).skip(skip).limit(pageSize);
465
+ if (selectStr) query = query.select(selectStr);
466
+ if (populateOpt) query = query.populate(populateOpt);
467
+
468
+ const [items, total] = await Promise.all([
469
+ query.exec(),
470
+ this.model.countDocuments(searchQuery),
471
+ ]);
472
+
473
+ const meta: PaginationMeta = {
474
+ total,
475
+ page: pageNumber,
476
+ limit: pageSize,
477
+ pages: Math.ceil(total / pageSize),
478
+ hasNext: pageNumber * pageSize < total,
479
+ hasPrev: pageNumber > 1,
480
+ };
481
+
482
+ let result: any = items;
483
+ if (hooks.afterRead) {
484
+ result = (await hooks.afterRead(req, items as any)) ?? items;
485
+ }
486
+ onSuccess(res, 'GET (Search)', result, meta);
487
+ } catch (error: any) {
488
+ onError(res, 'GET (Search)', error);
489
+ }
490
+ },
491
+ middleware,
492
+ routeMiddleware.read
493
+ )
494
+ );
495
+ this.registerRoute('GET', path, ['q', 'fields', 'page', 'limit', 'select', 'populate']);
496
+ }
497
+
498
+ // ── COUNT ─ GET /endpoint/count ─────────────────────────────────────
499
+
500
+ if (methods.includes('GET')) {
501
+ const path = `/${this.endpoint}/count`;
502
+ this.router.get(
503
+ path,
504
+ ...this.buildMiddlewareChain(
505
+ async (req: Request, res: Response) => {
506
+ try {
507
+ const filter = {
508
+ ...this.safeJsonParse(req.query.filter as string),
509
+ ...this.softDeleteFilter(req, softDelete),
510
+ };
511
+ const count = await this.model.countDocuments(filter);
512
+ onSuccess(res, 'GET (Count)', { count });
513
+ } catch (error: any) {
514
+ onError(res, 'GET (Count)', error);
515
+ }
516
+ },
517
+ middleware,
518
+ routeMiddleware.read
519
+ )
520
+ );
521
+ this.registerRoute('GET', path, ['filter']);
63
522
  }
64
523
 
65
- // Read all
524
+ // ── AGGREGATE ─ GET /endpoint/aggregate ─────────────────────────────
525
+
526
+ if (methods.includes('GET') && options.aggregatePipeline) {
527
+ const path = `/${this.endpoint}/aggregate`;
528
+ this.router.get(
529
+ path,
530
+ ...this.buildMiddlewareChain(
531
+ async (req: Request, res: Response) => {
532
+ try {
533
+ const pipeline: any[] =
534
+ typeof options.aggregatePipeline === 'function'
535
+ ? options.aggregatePipeline(req)
536
+ : options.aggregatePipeline || [];
537
+ const results = await this.model.aggregate(pipeline);
538
+ onSuccess(res, 'GET (Aggregate)', results);
539
+ } catch (error: any) {
540
+ onError(res, 'GET (Aggregate)', error);
541
+ }
542
+ },
543
+ middleware,
544
+ routeMiddleware.read
545
+ )
546
+ );
547
+ this.registerRoute('GET', path);
548
+ }
549
+
550
+ // ── EXISTS ─ GET /endpoint/exists/:id ───────────────────────────────
551
+
552
+ if (methods.includes('GET')) {
553
+ const path = `/${this.endpoint}/exists/:id`;
554
+ this.router.get(
555
+ path,
556
+ ...this.buildMiddlewareChain(
557
+ async (req: Request, res: Response) => {
558
+ try {
559
+ const filter: any = { _id: req.params.id };
560
+ if (softDelete) filter.deletedAt = { $exists: false };
561
+ const exists = await this.model.exists(filter);
562
+ onSuccess(res, 'GET (Exists)', { exists: !!exists });
563
+ } catch (error: any) {
564
+ onError(res, 'GET (Exists)', error);
565
+ }
566
+ },
567
+ middleware,
568
+ routeMiddleware.read
569
+ )
570
+ );
571
+ this.registerRoute('GET', path, ['id']);
572
+ }
573
+
574
+ // ── READ ALL ─ GET /endpoint ────────────────────────────────────────
575
+
66
576
  if (methods.includes('GET')) {
67
577
  const path = `/${this.endpoint}`;
68
- this.router.get(path, applyMiddleware(async (req, res) => {
69
- const method = 'GET';
70
- try {
71
- const { filter, sort, page, limit } = req.query;
72
- const query = filter ? JSON.parse(filter as string) : {};
73
- const sortOrder = sort ? JSON.parse(sort as string) : {};
74
- const pageNumber = parseInt(page as string, 10) || 1;
75
- const pageSize = parseInt(limit as string, 10) || 10;
76
- const skip = (pageNumber - 1) * pageSize;
77
-
78
- let items: T[] | Aggregate<any[]>;
79
- if (options.relatedModel && options.relatedMethods?.includes('GET')) {
80
- items = await this.model.aggregate([
81
- { $match: query },
82
- {
83
- $lookup: {
84
- from: options.relatedModel.collection.name,
85
- localField: options.relatedField!,
86
- foreignField: '_id',
87
- as: 'relatedData'
88
- }
89
- },
90
- { $sort: sortOrder },
91
- { $skip: skip },
92
- { $limit: pageSize }
93
- ]);
94
- } else {
95
- items = await this.model.find(query).sort(sortOrder).skip(skip).limit(pageSize);
96
- }
97
- onSuccess(res, method, items);
98
- } catch (error: any) {
99
- onError(res, method, error);
100
- }
101
- }));
102
- registerRoute('GET', path, ['filter', 'sort', 'page', 'limit']);
578
+ this.router.get(
579
+ path,
580
+ ...this.buildMiddlewareChain(
581
+ async (req: Request, res: Response) => {
582
+ try {
583
+ let filter = {
584
+ ...this.safeJsonParse(req.query.filter as string),
585
+ ...this.softDeleteFilter(req, softDelete),
586
+ };
587
+ const sortOrder = this.safeJsonParse(req.query.sort as string);
588
+ const pageNumber = parseInt(req.query.page as string, 10) || 1;
589
+ const pageSize = parseInt(req.query.limit as string, 10) || 10;
590
+ const skip = (pageNumber - 1) * pageSize;
591
+ const selectStr = this.parseSelect(req.query.select as string, defaultSelect);
592
+ const populateOpt = this.parsePopulate(req.query.populate as string, defaultPopulate);
593
+
594
+ // Before read hook
595
+ if (hooks.beforeRead) {
596
+ filter = (await hooks.beforeRead(req, filter)) ?? filter;
597
+ }
598
+
599
+ let items: T[];
600
+ if (options.relatedModel && options.relatedMethods?.includes('GET')) {
601
+ items = await this.model.aggregate([
602
+ { $match: filter },
603
+ {
604
+ $lookup: {
605
+ from: options.relatedModel.collection.name,
606
+ localField: options.relatedField!,
607
+ foreignField: '_id',
608
+ as: 'relatedData',
609
+ },
610
+ },
611
+ { $sort: Object.keys(sortOrder).length ? sortOrder : { _id: -1 } },
612
+ { $skip: skip },
613
+ { $limit: pageSize },
614
+ ]);
615
+ } else {
616
+ let query = this.model
617
+ .find(filter)
618
+ .sort(Object.keys(sortOrder).length ? sortOrder : undefined)
619
+ .skip(skip)
620
+ .limit(pageSize);
621
+ if (selectStr) query = query.select(selectStr);
622
+ if (populateOpt) query = query.populate(populateOpt);
623
+ items = await query.exec();
624
+ }
625
+
626
+ const total = await this.model.countDocuments(filter);
627
+ const meta: PaginationMeta = {
628
+ total,
629
+ page: pageNumber,
630
+ limit: pageSize,
631
+ pages: Math.ceil(total / pageSize),
632
+ hasNext: pageNumber * pageSize < total,
633
+ hasPrev: pageNumber > 1,
634
+ };
635
+
636
+ let result: T | T[] = items;
637
+ if (hooks.afterRead) {
638
+ result = (await hooks.afterRead(req, items)) ?? items;
639
+ }
640
+ onSuccess(res, 'GET', result, meta);
641
+ } catch (error: any) {
642
+ onError(res, 'GET', error);
643
+ }
644
+ },
645
+ middleware,
646
+ routeMiddleware.read
647
+ )
648
+ );
649
+ this.registerRoute('GET', path, ['filter', 'sort', 'page', 'limit', 'select', 'populate']);
103
650
  }
104
651
 
105
- // Read one
652
+ // ── READ ONE ─ GET /endpoint/:id ────────────────────────────────────
653
+
106
654
  if (methods.includes('GET')) {
107
655
  const path = `/${this.endpoint}/:id`;
108
- this.router.get(path, applyMiddleware(async (req, res) => {
109
- const method = 'GET';
110
- try {
111
- let item: T | null;
112
- if (options.relatedModel && options.relatedMethods?.includes('GET')) {
113
- const aggregateResult = await this.model.aggregate([
114
- { $match: { _id: req.params.id } },
115
- {
116
- $lookup: {
117
- from: options.relatedModel.collection.name,
118
- localField: options.relatedField!,
119
- foreignField: '_id',
120
- as: 'relatedData'
121
- }
656
+ this.router.get(
657
+ path,
658
+ ...this.buildMiddlewareChain(
659
+ async (req: Request, res: Response) => {
660
+ try {
661
+ const selectStr = this.parseSelect(req.query.select as string, defaultSelect);
662
+ const populateOpt = this.parsePopulate(req.query.populate as string, defaultPopulate);
663
+
664
+ let item: T | null;
665
+ if (options.relatedModel && options.relatedMethods?.includes('GET')) {
666
+ const matchFilter: any = { _id: new Types.ObjectId(req.params.id) };
667
+ if (softDelete) matchFilter.deletedAt = { $exists: false };
668
+ const aggregateResult = await this.model.aggregate([
669
+ { $match: matchFilter },
670
+ {
671
+ $lookup: {
672
+ from: options.relatedModel.collection.name,
673
+ localField: options.relatedField!,
674
+ foreignField: '_id',
675
+ as: 'relatedData',
676
+ },
677
+ },
678
+ ]);
679
+ item = (aggregateResult[0] as T) || null;
680
+ } else {
681
+ const findFilter: any = { _id: req.params.id };
682
+ if (softDelete) findFilter.deletedAt = { $exists: false };
683
+ let query = this.model.findOne(findFilter);
684
+ if (selectStr) query = query.select(selectStr);
685
+ if (populateOpt) query = query.populate(populateOpt);
686
+ item = await query.exec();
122
687
  }
123
- ]);
124
- item = aggregateResult[0] as T;
125
- } else {
126
- item = await this.model.findById(req.params.id);
127
- }
128
- if (!item) {
129
- return res.status(404).send();
130
- }
131
- onSuccess(res, method, item);
132
- } catch (error: any) {
133
- onError(res, method, error);
134
- }
135
- }));
136
- registerRoute('GET', path, ['id']);
688
+
689
+ if (!item) {
690
+ return res.status(404).json({ message: 'Item not found' });
691
+ }
692
+
693
+ let result: T | T[] = item;
694
+ if (hooks.afterRead) {
695
+ result = (await hooks.afterRead(req, item)) ?? item;
696
+ }
697
+ onSuccess(res, 'GET', result);
698
+ } catch (error: any) {
699
+ onError(res, 'GET', error);
700
+ }
701
+ },
702
+ middleware,
703
+ routeMiddleware.read
704
+ )
705
+ );
706
+ this.registerRoute('GET', path, ['id', 'select', 'populate']);
137
707
  }
138
708
 
139
- // Update
709
+ // ── UPDATE (FULL) ─ PUT /endpoint/:id ───────────────────────────────
710
+
140
711
  if (methods.includes('PUT')) {
141
712
  const path = `/${this.endpoint}/:id`;
142
- this.router.put(path, applyMiddleware(async (req, res) => {
143
- const method = 'PUT';
144
- try {
145
- const item = await this.model.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true });
146
- if (!item) {
147
- return res.status(404).send();
148
- }
149
- if (options.relatedModel && options.relatedMethods?.includes('PUT')) {
150
- await options.relatedModel.updateMany({ [options.relatedField!]: item._id }, req.body);
151
- }
152
- onSuccess(res, method, item);
153
- } catch (error: any) {
154
- onError(res, method, error);
155
- }
156
- }));
157
- registerRoute('PUT', path, ['id']);
713
+ this.router.put(
714
+ path,
715
+ ...this.buildMiddlewareChain(
716
+ async (req: Request, res: Response) => {
717
+ try {
718
+ if (validate.update) {
719
+ const validation = validate.update(req.body);
720
+ if (!validation.valid) {
721
+ return res.status(400).json({ errors: validation.errors });
722
+ }
723
+ }
724
+ let data = req.body;
725
+ if (hooks.beforeUpdate) {
726
+ data = (await hooks.beforeUpdate(req, req.params.id, data)) ?? data;
727
+ }
728
+ const item = await this.model.findByIdAndUpdate(req.params.id, data, {
729
+ new: true,
730
+ runValidators: true,
731
+ });
732
+ if (!item) {
733
+ return res.status(404).json({ message: 'Item not found' });
734
+ }
735
+ if (options.relatedModel && options.relatedMethods?.includes('PUT')) {
736
+ await options.relatedModel.updateMany(
737
+ { [options.relatedField!]: item._id },
738
+ data
739
+ );
740
+ }
741
+ if (hooks.afterUpdate) {
742
+ await hooks.afterUpdate(req, item);
743
+ }
744
+ onSuccess(res, 'PUT', item);
745
+ } catch (error: any) {
746
+ onError(res, 'PUT', error);
747
+ }
748
+ },
749
+ middleware,
750
+ routeMiddleware.update
751
+ )
752
+ );
753
+ this.registerRoute('PUT', path, ['id']);
754
+ }
755
+
756
+ // ── UPDATE (PARTIAL) ─ PATCH /endpoint/:id ──────────────────────────
757
+
758
+ if (methods.includes('PATCH')) {
759
+ const path = `/${this.endpoint}/:id`;
760
+ this.router.patch(
761
+ path,
762
+ ...this.buildMiddlewareChain(
763
+ async (req: Request, res: Response) => {
764
+ try {
765
+ if (validate.update) {
766
+ const validation = validate.update(req.body);
767
+ if (!validation.valid) {
768
+ return res.status(400).json({ errors: validation.errors });
769
+ }
770
+ }
771
+ let data = req.body;
772
+ if (hooks.beforeUpdate) {
773
+ data = (await hooks.beforeUpdate(req, req.params.id, data)) ?? data;
774
+ }
775
+ const item = await this.model.findByIdAndUpdate(
776
+ req.params.id,
777
+ { $set: data },
778
+ { new: true, runValidators: true }
779
+ );
780
+ if (!item) {
781
+ return res.status(404).json({ message: 'Item not found' });
782
+ }
783
+ if (hooks.afterUpdate) {
784
+ await hooks.afterUpdate(req, item);
785
+ }
786
+ onSuccess(res, 'PATCH', item);
787
+ } catch (error: any) {
788
+ onError(res, 'PATCH', error);
789
+ }
790
+ },
791
+ middleware,
792
+ routeMiddleware.update
793
+ )
794
+ );
795
+ this.registerRoute('PATCH', path, ['id']);
158
796
  }
159
797
 
160
- // Delete multiple
798
+ // ── BULK UPDATE ─ PATCH /endpoint/bulk ──────────────────────────────
799
+
800
+ if (methods.includes('PATCH')) {
801
+ const path = `/${this.endpoint}/bulk`;
802
+ this.router.patch(
803
+ path,
804
+ ...this.buildMiddlewareChain(
805
+ async (req: Request, res: Response) => {
806
+ try {
807
+ const { filter, update } = req.body;
808
+ if (!filter || !update) {
809
+ return res.status(400).json({
810
+ error: 'Request body must contain "filter" and "update" objects',
811
+ });
812
+ }
813
+ if (validate.update) {
814
+ const validation = validate.update(update);
815
+ if (!validation.valid) {
816
+ return res.status(400).json({ errors: validation.errors });
817
+ }
818
+ }
819
+ const result = await this.model.updateMany(filter, { $set: update }, { runValidators: true });
820
+ onSuccess(res, 'PATCH (Bulk)', result);
821
+ } catch (error: any) {
822
+ onError(res, 'PATCH (Bulk)', error);
823
+ }
824
+ },
825
+ middleware,
826
+ routeMiddleware.update
827
+ )
828
+ );
829
+ this.registerRoute('PATCH', path, ['filter', 'update']);
830
+ }
831
+
832
+ // ── SOFT DELETE RESTORE ─ PATCH /endpoint/:id/restore ───────────────
833
+
834
+ if (softDelete && (methods.includes('PATCH') || methods.includes('PUT'))) {
835
+ const path = `/${this.endpoint}/:id/restore`;
836
+ this.router.patch(
837
+ path,
838
+ ...this.buildMiddlewareChain(
839
+ async (req: Request, res: Response) => {
840
+ try {
841
+ const item = await this.model.findByIdAndUpdate(
842
+ req.params.id,
843
+ { $unset: { deletedAt: 1 } },
844
+ { new: true }
845
+ );
846
+ if (!item) {
847
+ return res.status(404).json({ message: 'Item not found' });
848
+ }
849
+ onSuccess(res, 'PATCH (Restore)', item);
850
+ } catch (error: any) {
851
+ onError(res, 'PATCH (Restore)', error);
852
+ }
853
+ },
854
+ middleware,
855
+ routeMiddleware.update
856
+ )
857
+ );
858
+ this.registerRoute('PATCH', path, ['id']);
859
+ }
860
+
861
+ // ── DELETE MULTIPLE ─ DELETE /endpoint ───────────────────────────────
862
+
161
863
  if (methods.includes('DELETE')) {
162
864
  const path = `/${this.endpoint}`;
163
- this.router.delete(path, applyMiddleware(async (req, res) => {
164
- const method = 'DELETE';
165
- try {
166
- const query = req.query.filter ? JSON.parse(req.query.filter as string) : {};
167
- const deleteResult: any = await this.model.deleteMany(query);
168
- if (deleteResult.deletedCount === 0) {
169
- return res.status(404).send();
170
- }
171
- if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
172
- await options.relatedModel.deleteMany({ [options.relatedField!]: { $in: query } });
173
- }
174
- onSuccess(res, method, deleteResult);
175
- } catch (error: any) {
176
- onError(res, method, error);
177
- }
178
- }));
179
- registerRoute('DELETE', path, ['filter']);
865
+ this.router.delete(
866
+ path,
867
+ ...this.buildMiddlewareChain(
868
+ async (req: Request, res: Response) => {
869
+ try {
870
+ const query = this.safeJsonParse(req.query.filter as string);
871
+ if (Object.keys(query).length === 0) {
872
+ return res.status(400).json({
873
+ error: 'A filter is required for bulk delete to prevent accidental data loss',
874
+ });
875
+ }
876
+ let deleteResult: any;
877
+ if (softDelete) {
878
+ deleteResult = await this.model.updateMany(query, {
879
+ $set: { deletedAt: new Date() },
880
+ });
881
+ } else {
882
+ deleteResult = await this.model.deleteMany(query);
883
+ }
884
+ if ((deleteResult.deletedCount ?? deleteResult.modifiedCount) === 0) {
885
+ return res.status(404).json({ message: 'No matching items found to delete' });
886
+ }
887
+ if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
888
+ const ids = (await this.model.find(query).select('_id')).map((d: any) => d._id);
889
+ await options.relatedModel.deleteMany({
890
+ [options.relatedField!]: { $in: ids },
891
+ });
892
+ }
893
+ onSuccess(res, 'DELETE', deleteResult);
894
+ } catch (error: any) {
895
+ onError(res, 'DELETE', error);
896
+ }
897
+ },
898
+ middleware,
899
+ routeMiddleware.delete
900
+ )
901
+ );
902
+ this.registerRoute('DELETE', path, ['filter']);
180
903
  }
181
904
 
182
- // Delete one
905
+ // ── DELETE ONE ─ DELETE /endpoint/:id ────────────────────────────────
906
+
183
907
  if (methods.includes('DELETE')) {
184
908
  const path = `/${this.endpoint}/:id`;
185
- this.router.delete(path, applyMiddleware(async (req, res) => {
186
- const method = 'DELETE';
187
- try {
188
- const item = await this.model.findByIdAndDelete(req.params.id);
189
- if (!item) {
190
- return res.status(404).send();
191
- }
192
- if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
193
- await options.relatedModel.deleteMany({ [options.relatedField!]: item._id });
194
- }
195
- onSuccess(res, method, item);
196
- } catch (error: any) {
197
- onError(res, method, error);
198
- }
199
- }));
200
- registerRoute('DELETE', path, ['id']);
909
+ this.router.delete(
910
+ path,
911
+ ...this.buildMiddlewareChain(
912
+ async (req: Request, res: Response) => {
913
+ try {
914
+ if (hooks.beforeDelete) {
915
+ await hooks.beforeDelete(req, req.params.id);
916
+ }
917
+ let item: T | null;
918
+ if (softDelete) {
919
+ item = await this.model.findByIdAndUpdate(
920
+ req.params.id,
921
+ { $set: { deletedAt: new Date() } as any },
922
+ { new: true }
923
+ );
924
+ } else {
925
+ item = await this.model.findByIdAndDelete(req.params.id);
926
+ }
927
+ if (!item) {
928
+ return res.status(404).json({ message: 'Item not found' });
929
+ }
930
+ if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
931
+ await options.relatedModel.deleteMany({
932
+ [options.relatedField!]: item._id,
933
+ });
934
+ }
935
+ if (hooks.afterDelete) {
936
+ await hooks.afterDelete(req, item);
937
+ }
938
+ onSuccess(res, 'DELETE', item);
939
+ } catch (error: any) {
940
+ onError(res, 'DELETE', error);
941
+ }
942
+ },
943
+ middleware,
944
+ routeMiddleware.delete
945
+ )
946
+ );
947
+ this.registerRoute('DELETE', path, ['id']);
201
948
  }
202
949
 
203
- // Aggregate
204
- if (methods.includes('GET') && options.aggregatePipeline) {
205
- const path = `/${this.endpoint}/aggregate`;
206
- this.router.get(path, applyMiddleware(async (req, res) => {
207
- const method = 'GET (Aggregate)';
208
- try {
209
- const pipeline: any = options.aggregatePipeline ?? [];
210
- const results = await this.model.aggregate(pipeline);
211
- onSuccess(res, method, results);
212
- } catch (error: any) {
213
- onError(res, method, error);
214
- }
215
- }));
216
- registerRoute('GET', path);
950
+ // ── BULK DELETE ─ DELETE /endpoint/bulk ──────────────────────────────
951
+
952
+ if (methods.includes('DELETE')) {
953
+ const path = `/${this.endpoint}/bulk`;
954
+ this.router.delete(
955
+ path,
956
+ ...this.buildMiddlewareChain(
957
+ async (req: Request, res: Response) => {
958
+ try {
959
+ const { ids } = req.body;
960
+ if (!Array.isArray(ids) || ids.length === 0) {
961
+ return res.status(400).json({
962
+ error: 'Request body must contain a non-empty "ids" array',
963
+ });
964
+ }
965
+ let deleteResult: any;
966
+ if (softDelete) {
967
+ deleteResult = await this.model.updateMany(
968
+ { _id: { $in: ids } },
969
+ { $set: { deletedAt: new Date() } }
970
+ );
971
+ } else {
972
+ deleteResult = await this.model.deleteMany({ _id: { $in: ids } });
973
+ }
974
+ onSuccess(res, 'DELETE (Bulk)', deleteResult);
975
+ } catch (error: any) {
976
+ onError(res, 'DELETE (Bulk)', error);
977
+ }
978
+ },
979
+ middleware,
980
+ routeMiddleware.delete
981
+ )
982
+ );
983
+ this.registerRoute('DELETE', path);
217
984
  }
218
985
 
219
- // Custom routes
986
+ // ── CUSTOM ROUTES ───────────────────────────────────────────────────
987
+
220
988
  if (options.customRoutes) {
221
- options.customRoutes.forEach(route => {
222
- const { method, path, handler } = route;
223
- if (methods.includes(method.toUpperCase() as 'POST' | 'GET' | 'PUT' | 'DELETE')) {
224
- this.router[method](`/${this.endpoint}${path}`, applyMiddleware(handler));
225
- registerRoute(method.toUpperCase(), `/${this.endpoint}${path}`);
226
- }
989
+ options.customRoutes.forEach((route) => {
990
+ const { method, path, handler, middleware: routeMw } = route;
991
+ const fullPath = `/${this.endpoint}${path}`;
992
+ this.router[method](
993
+ fullPath,
994
+ ...this.buildMiddlewareChain(handler, middleware, routeMw)
995
+ );
996
+ this.registerRoute(method.toUpperCase(), fullPath);
227
997
  });
228
998
  }
229
999
  }
230
1000
 
1001
+ // ── Public API ──────────────────────────────────────────────────────────
1002
+
1003
+ /** Returns the configured Express Router with all CRUD routes. */
231
1004
  public getRouter(): Router {
232
1005
  return this.router;
233
1006
  }
234
1007
 
235
- public getRoutes(): { method: string, path: string, params?: string[] }[] {
1008
+ /** Returns an array of all registered route definitions. */
1009
+ public getRoutes(): RouteInfo[] {
236
1010
  return this.routes;
237
1011
  }
238
1012
  }
239
1013
 
1014
+ export { CrudController };
240
1015
  export default CrudController;