crud-api-express 1.2.6 → 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/dist/index.cjs +483 -119
- package/dist/index.d.ts +197 -37
- package/dist/index.mjs +480 -119
- package/package.json +27 -18
- package/readme.md +322 -198
- package/src/index.ts +909 -199
- package/dist/index.js +0 -212
package/dist/index.cjs
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
3
5
|
var express = require('express');
|
|
6
|
+
var mongoose = require('mongoose');
|
|
4
7
|
|
|
8
|
+
// ─── CrudController Class ───────────────────────────────────────────────────
|
|
5
9
|
/**
|
|
6
|
-
* A
|
|
10
|
+
* A powerful, flexible CRUD Controller for Express + Mongoose.
|
|
11
|
+
*
|
|
12
|
+
* Automatically generates RESTful endpoints for any Mongoose model with
|
|
13
|
+
* support for lifecycle hooks, validation, soft delete, bulk operations,
|
|
14
|
+
* search, field selection, population, and more.
|
|
7
15
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
16
|
+
* @example
|
|
17
|
+
* const ctrl = new CrudController(UserModel, 'users', {
|
|
18
|
+
* methods: ['GET', 'POST', 'PATCH', 'DELETE'],
|
|
19
|
+
* softDelete: true,
|
|
20
|
+
* searchFields: ['name', 'email'],
|
|
21
|
+
* hooks: {
|
|
22
|
+
* beforeCreate: (req, data) => ({ ...data, createdBy: req.user.id }),
|
|
23
|
+
* },
|
|
24
|
+
* });
|
|
25
|
+
* app.use('/api', ctrl.getRouter());
|
|
10
26
|
*/
|
|
11
27
|
class CrudController {
|
|
12
28
|
constructor(model, endpoint, options = {}) {
|
|
@@ -16,235 +32,583 @@ class CrudController {
|
|
|
16
32
|
this.routes = [];
|
|
17
33
|
this.configureRoutes(options);
|
|
18
34
|
}
|
|
35
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
19
36
|
/**
|
|
20
|
-
*
|
|
21
|
-
* @param routeHandler The original route handler.
|
|
22
|
-
* @param middlewareList Optional array of middleware functions.
|
|
23
|
-
* @returns Array of middleware functions including the route handler.
|
|
37
|
+
* Merges global middleware + per-operation middleware + handler into a single array.
|
|
24
38
|
*/
|
|
25
|
-
|
|
26
|
-
return [...
|
|
39
|
+
buildMiddlewareChain(handler, globalMiddleware, operationMiddleware) {
|
|
40
|
+
return [...globalMiddleware, ...(operationMiddleware || []), handler];
|
|
27
41
|
}
|
|
28
|
-
/**
|
|
29
|
-
* Registers route definitions to the internal list.
|
|
30
|
-
* @param method HTTP method.
|
|
31
|
-
* @param path URL path.
|
|
32
|
-
* @param params Optional route parameter names.
|
|
33
|
-
*/
|
|
42
|
+
/** Records a route in the internal registry. */
|
|
34
43
|
registerRoute(method, path, params) {
|
|
35
44
|
this.routes.push({ method, path, params });
|
|
36
45
|
}
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
46
|
+
/** Parses a `?select=name,email` query param into Mongoose select syntax. */
|
|
47
|
+
parseSelect(querySelect, defaultSelect) {
|
|
48
|
+
if (querySelect)
|
|
49
|
+
return querySelect.split(',').join(' ');
|
|
50
|
+
return defaultSelect;
|
|
51
|
+
}
|
|
52
|
+
/** Parses a `?populate=author,comments` query param. */
|
|
53
|
+
parsePopulate(queryPopulate, defaultPopulate) {
|
|
54
|
+
if (queryPopulate) {
|
|
55
|
+
return queryPopulate.split(',').map((p) => p.trim());
|
|
56
|
+
}
|
|
57
|
+
return defaultPopulate;
|
|
58
|
+
}
|
|
59
|
+
/** Safely parses JSON from a query param, returns fallback on failure. */
|
|
60
|
+
safeJsonParse(value, fallback = {}) {
|
|
61
|
+
if (!value)
|
|
62
|
+
return fallback;
|
|
63
|
+
try {
|
|
64
|
+
return JSON.parse(value);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Builds the soft-delete filter to exclude deleted records. */
|
|
71
|
+
softDeleteFilter(req, softDelete) {
|
|
72
|
+
if (!softDelete)
|
|
73
|
+
return {};
|
|
74
|
+
const includeDeleted = req.query.includeDeleted === 'true';
|
|
75
|
+
return includeDeleted ? {} : { deletedAt: { $exists: false } };
|
|
76
|
+
}
|
|
77
|
+
// ── Route Configuration ────────────────────────────────────────────────
|
|
41
78
|
configureRoutes(options) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
79
|
+
const { middleware = [], routeMiddleware = {}, onSuccess = (res, method, result, meta) => {
|
|
80
|
+
if (meta) {
|
|
81
|
+
res.status(200).json({ data: result, pagination: meta });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
res.status(200).json(result);
|
|
85
|
+
}
|
|
86
|
+
}, 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;
|
|
87
|
+
// ── CREATE ─ POST /endpoint ─────────────────────────────────────────
|
|
45
88
|
if (methods.includes('POST')) {
|
|
46
89
|
const path = `/${this.endpoint}`;
|
|
47
|
-
this.router.post(path, this.
|
|
48
|
-
const method = 'POST';
|
|
90
|
+
this.router.post(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
49
91
|
try {
|
|
50
|
-
|
|
51
|
-
|
|
92
|
+
// Validation hook
|
|
93
|
+
if (validate.create) {
|
|
94
|
+
const validation = validate.create(req.body);
|
|
95
|
+
if (!validation.valid) {
|
|
96
|
+
return res.status(400).json({ errors: validation.errors });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Before hook
|
|
100
|
+
let data = req.body;
|
|
101
|
+
if (hooks.beforeCreate) {
|
|
102
|
+
data = (await hooks.beforeCreate(req, data)) ?? data;
|
|
103
|
+
}
|
|
104
|
+
const result = await this.model.create(data);
|
|
105
|
+
// Related model cascade
|
|
52
106
|
if (options.relatedModel && options.relatedMethods?.includes('POST')) {
|
|
53
107
|
await options.relatedModel.create({
|
|
54
108
|
[options.relatedField]: result._id,
|
|
55
|
-
...
|
|
109
|
+
...data,
|
|
56
110
|
});
|
|
57
111
|
}
|
|
58
|
-
//
|
|
59
|
-
|
|
112
|
+
// After hook
|
|
113
|
+
if (hooks.afterCreate) {
|
|
114
|
+
await hooks.afterCreate(req, result);
|
|
115
|
+
}
|
|
116
|
+
onSuccess(res.status(201), 'POST', result);
|
|
60
117
|
}
|
|
61
118
|
catch (error) {
|
|
62
|
-
onError(res,
|
|
119
|
+
onError(res, 'POST', error);
|
|
63
120
|
}
|
|
64
|
-
}, middleware));
|
|
121
|
+
}, middleware, routeMiddleware.create));
|
|
65
122
|
this.registerRoute('POST', path);
|
|
66
123
|
}
|
|
67
|
-
//
|
|
124
|
+
// ── BULK CREATE ─ POST /endpoint/bulk ───────────────────────────────
|
|
125
|
+
if (methods.includes('POST')) {
|
|
126
|
+
const path = `/${this.endpoint}/bulk`;
|
|
127
|
+
this.router.post(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
128
|
+
try {
|
|
129
|
+
const items = req.body;
|
|
130
|
+
if (!Array.isArray(items)) {
|
|
131
|
+
return res.status(400).json({ error: 'Request body must be an array' });
|
|
132
|
+
}
|
|
133
|
+
// Validate each item
|
|
134
|
+
if (validate.create) {
|
|
135
|
+
for (let i = 0; i < items.length; i++) {
|
|
136
|
+
const validation = validate.create(items[i]);
|
|
137
|
+
if (!validation.valid) {
|
|
138
|
+
return res.status(400).json({
|
|
139
|
+
error: `Validation failed for item at index ${i}`,
|
|
140
|
+
errors: validation.errors,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Before hooks
|
|
146
|
+
let processedItems = items;
|
|
147
|
+
if (hooks.beforeCreate) {
|
|
148
|
+
processedItems = [];
|
|
149
|
+
for (const item of items) {
|
|
150
|
+
const result = (await hooks.beforeCreate(req, item)) ?? item;
|
|
151
|
+
processedItems.push(result);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const results = await this.model.insertMany(processedItems);
|
|
155
|
+
onSuccess(res.status(201), 'POST (Bulk)', results);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
onError(res, 'POST (Bulk)', error);
|
|
159
|
+
}
|
|
160
|
+
}, middleware, routeMiddleware.create));
|
|
161
|
+
this.registerRoute('POST', path);
|
|
162
|
+
}
|
|
163
|
+
// ── SEARCH ─ GET /endpoint/search ───────────────────────────────────
|
|
164
|
+
if (methods.includes('GET') && searchFields.length > 0) {
|
|
165
|
+
const path = `/${this.endpoint}/search`;
|
|
166
|
+
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
167
|
+
try {
|
|
168
|
+
const q = req.query.q;
|
|
169
|
+
if (!q) {
|
|
170
|
+
return res.status(400).json({ error: 'Query parameter "q" is required' });
|
|
171
|
+
}
|
|
172
|
+
const fieldsParam = req.query.fields;
|
|
173
|
+
const fields = fieldsParam ? fieldsParam.split(',').map((f) => f.trim()) : searchFields;
|
|
174
|
+
const searchQuery = {
|
|
175
|
+
$or: fields.map((field) => ({
|
|
176
|
+
[field]: { $regex: q, $options: 'i' },
|
|
177
|
+
})),
|
|
178
|
+
...this.softDeleteFilter(req, softDelete),
|
|
179
|
+
};
|
|
180
|
+
const pageNumber = parseInt(req.query.page, 10) || 1;
|
|
181
|
+
const pageSize = parseInt(req.query.limit, 10) || 10;
|
|
182
|
+
const skip = (pageNumber - 1) * pageSize;
|
|
183
|
+
const selectStr = this.parseSelect(req.query.select, defaultSelect);
|
|
184
|
+
const populateOpt = this.parsePopulate(req.query.populate, defaultPopulate);
|
|
185
|
+
let query = this.model.find(searchQuery).skip(skip).limit(pageSize);
|
|
186
|
+
if (selectStr)
|
|
187
|
+
query = query.select(selectStr);
|
|
188
|
+
if (populateOpt)
|
|
189
|
+
query = query.populate(populateOpt);
|
|
190
|
+
const [items, total] = await Promise.all([
|
|
191
|
+
query.exec(),
|
|
192
|
+
this.model.countDocuments(searchQuery),
|
|
193
|
+
]);
|
|
194
|
+
const meta = {
|
|
195
|
+
total,
|
|
196
|
+
page: pageNumber,
|
|
197
|
+
limit: pageSize,
|
|
198
|
+
pages: Math.ceil(total / pageSize),
|
|
199
|
+
hasNext: pageNumber * pageSize < total,
|
|
200
|
+
hasPrev: pageNumber > 1,
|
|
201
|
+
};
|
|
202
|
+
let result = items;
|
|
203
|
+
if (hooks.afterRead) {
|
|
204
|
+
result = (await hooks.afterRead(req, items)) ?? items;
|
|
205
|
+
}
|
|
206
|
+
onSuccess(res, 'GET (Search)', result, meta);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
onError(res, 'GET (Search)', error);
|
|
210
|
+
}
|
|
211
|
+
}, middleware, routeMiddleware.read));
|
|
212
|
+
this.registerRoute('GET', path, ['q', 'fields', 'page', 'limit', 'select', 'populate']);
|
|
213
|
+
}
|
|
214
|
+
// ── COUNT ─ GET /endpoint/count ─────────────────────────────────────
|
|
215
|
+
if (methods.includes('GET')) {
|
|
216
|
+
const path = `/${this.endpoint}/count`;
|
|
217
|
+
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
218
|
+
try {
|
|
219
|
+
const filter = {
|
|
220
|
+
...this.safeJsonParse(req.query.filter),
|
|
221
|
+
...this.softDeleteFilter(req, softDelete),
|
|
222
|
+
};
|
|
223
|
+
const count = await this.model.countDocuments(filter);
|
|
224
|
+
onSuccess(res, 'GET (Count)', { count });
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
onError(res, 'GET (Count)', error);
|
|
228
|
+
}
|
|
229
|
+
}, middleware, routeMiddleware.read));
|
|
230
|
+
this.registerRoute('GET', path, ['filter']);
|
|
231
|
+
}
|
|
232
|
+
// ── AGGREGATE ─ GET /endpoint/aggregate ─────────────────────────────
|
|
233
|
+
if (methods.includes('GET') && options.aggregatePipeline) {
|
|
234
|
+
const path = `/${this.endpoint}/aggregate`;
|
|
235
|
+
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
236
|
+
try {
|
|
237
|
+
const pipeline = typeof options.aggregatePipeline === 'function'
|
|
238
|
+
? options.aggregatePipeline(req)
|
|
239
|
+
: options.aggregatePipeline || [];
|
|
240
|
+
const results = await this.model.aggregate(pipeline);
|
|
241
|
+
onSuccess(res, 'GET (Aggregate)', results);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
onError(res, 'GET (Aggregate)', error);
|
|
245
|
+
}
|
|
246
|
+
}, middleware, routeMiddleware.read));
|
|
247
|
+
this.registerRoute('GET', path);
|
|
248
|
+
}
|
|
249
|
+
// ── EXISTS ─ GET /endpoint/exists/:id ───────────────────────────────
|
|
250
|
+
if (methods.includes('GET')) {
|
|
251
|
+
const path = `/${this.endpoint}/exists/:id`;
|
|
252
|
+
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
253
|
+
try {
|
|
254
|
+
const filter = { _id: req.params.id };
|
|
255
|
+
if (softDelete)
|
|
256
|
+
filter.deletedAt = { $exists: false };
|
|
257
|
+
const exists = await this.model.exists(filter);
|
|
258
|
+
onSuccess(res, 'GET (Exists)', { exists: !!exists });
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
onError(res, 'GET (Exists)', error);
|
|
262
|
+
}
|
|
263
|
+
}, middleware, routeMiddleware.read));
|
|
264
|
+
this.registerRoute('GET', path, ['id']);
|
|
265
|
+
}
|
|
266
|
+
// ── READ ALL ─ GET /endpoint ────────────────────────────────────────
|
|
68
267
|
if (methods.includes('GET')) {
|
|
69
268
|
const path = `/${this.endpoint}`;
|
|
70
|
-
this.router.get(path, this.
|
|
71
|
-
const method = 'GET';
|
|
269
|
+
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
72
270
|
try {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
271
|
+
let filter = {
|
|
272
|
+
...this.safeJsonParse(req.query.filter),
|
|
273
|
+
...this.softDeleteFilter(req, softDelete),
|
|
274
|
+
};
|
|
275
|
+
const sortOrder = this.safeJsonParse(req.query.sort);
|
|
276
|
+
const pageNumber = parseInt(req.query.page, 10) || 1;
|
|
277
|
+
const pageSize = parseInt(req.query.limit, 10) || 10;
|
|
78
278
|
const skip = (pageNumber - 1) * pageSize;
|
|
279
|
+
const selectStr = this.parseSelect(req.query.select, defaultSelect);
|
|
280
|
+
const populateOpt = this.parsePopulate(req.query.populate, defaultPopulate);
|
|
281
|
+
// Before read hook
|
|
282
|
+
if (hooks.beforeRead) {
|
|
283
|
+
filter = (await hooks.beforeRead(req, filter)) ?? filter;
|
|
284
|
+
}
|
|
79
285
|
let items;
|
|
80
286
|
if (options.relatedModel && options.relatedMethods?.includes('GET')) {
|
|
81
|
-
// Use aggregation with lookup if related model exists
|
|
82
287
|
items = await this.model.aggregate([
|
|
83
|
-
{ $match:
|
|
288
|
+
{ $match: filter },
|
|
84
289
|
{
|
|
85
290
|
$lookup: {
|
|
86
291
|
from: options.relatedModel.collection.name,
|
|
87
292
|
localField: options.relatedField,
|
|
88
293
|
foreignField: '_id',
|
|
89
|
-
as: 'relatedData'
|
|
90
|
-
}
|
|
294
|
+
as: 'relatedData',
|
|
295
|
+
},
|
|
91
296
|
},
|
|
92
|
-
{ $sort: sortOrder },
|
|
297
|
+
{ $sort: Object.keys(sortOrder).length ? sortOrder : { _id: -1 } },
|
|
93
298
|
{ $skip: skip },
|
|
94
|
-
{ $limit: pageSize }
|
|
299
|
+
{ $limit: pageSize },
|
|
95
300
|
]);
|
|
96
301
|
}
|
|
97
302
|
else {
|
|
98
|
-
|
|
303
|
+
let query = this.model
|
|
304
|
+
.find(filter)
|
|
305
|
+
.sort(Object.keys(sortOrder).length ? sortOrder : undefined)
|
|
306
|
+
.skip(skip)
|
|
307
|
+
.limit(pageSize);
|
|
308
|
+
if (selectStr)
|
|
309
|
+
query = query.select(selectStr);
|
|
310
|
+
if (populateOpt)
|
|
311
|
+
query = query.populate(populateOpt);
|
|
312
|
+
items = await query.exec();
|
|
99
313
|
}
|
|
100
|
-
|
|
314
|
+
const total = await this.model.countDocuments(filter);
|
|
315
|
+
const meta = {
|
|
316
|
+
total,
|
|
317
|
+
page: pageNumber,
|
|
318
|
+
limit: pageSize,
|
|
319
|
+
pages: Math.ceil(total / pageSize),
|
|
320
|
+
hasNext: pageNumber * pageSize < total,
|
|
321
|
+
hasPrev: pageNumber > 1,
|
|
322
|
+
};
|
|
323
|
+
let result = items;
|
|
324
|
+
if (hooks.afterRead) {
|
|
325
|
+
result = (await hooks.afterRead(req, items)) ?? items;
|
|
326
|
+
}
|
|
327
|
+
onSuccess(res, 'GET', result, meta);
|
|
101
328
|
}
|
|
102
329
|
catch (error) {
|
|
103
|
-
onError(res,
|
|
330
|
+
onError(res, 'GET', error);
|
|
104
331
|
}
|
|
105
|
-
}, middleware));
|
|
106
|
-
this.registerRoute('GET', path, ['filter', 'sort', 'page', 'limit']);
|
|
332
|
+
}, middleware, routeMiddleware.read));
|
|
333
|
+
this.registerRoute('GET', path, ['filter', 'sort', 'page', 'limit', 'select', 'populate']);
|
|
107
334
|
}
|
|
108
|
-
// READ ONE
|
|
335
|
+
// ── READ ONE ─ GET /endpoint/:id ────────────────────────────────────
|
|
109
336
|
if (methods.includes('GET')) {
|
|
110
337
|
const path = `/${this.endpoint}/:id`;
|
|
111
|
-
this.router.get(path, this.
|
|
112
|
-
const method = 'GET';
|
|
338
|
+
this.router.get(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
113
339
|
try {
|
|
340
|
+
const selectStr = this.parseSelect(req.query.select, defaultSelect);
|
|
341
|
+
const populateOpt = this.parsePopulate(req.query.populate, defaultPopulate);
|
|
114
342
|
let item;
|
|
115
343
|
if (options.relatedModel && options.relatedMethods?.includes('GET')) {
|
|
344
|
+
const matchFilter = { _id: new mongoose.Types.ObjectId(req.params.id) };
|
|
345
|
+
if (softDelete)
|
|
346
|
+
matchFilter.deletedAt = { $exists: false };
|
|
116
347
|
const aggregateResult = await this.model.aggregate([
|
|
117
|
-
{ $match:
|
|
348
|
+
{ $match: matchFilter },
|
|
118
349
|
{
|
|
119
350
|
$lookup: {
|
|
120
351
|
from: options.relatedModel.collection.name,
|
|
121
352
|
localField: options.relatedField,
|
|
122
353
|
foreignField: '_id',
|
|
123
|
-
as: 'relatedData'
|
|
124
|
-
}
|
|
125
|
-
}
|
|
354
|
+
as: 'relatedData',
|
|
355
|
+
},
|
|
356
|
+
},
|
|
126
357
|
]);
|
|
127
358
|
item = aggregateResult[0] || null;
|
|
128
359
|
}
|
|
129
360
|
else {
|
|
130
|
-
|
|
361
|
+
const findFilter = { _id: req.params.id };
|
|
362
|
+
if (softDelete)
|
|
363
|
+
findFilter.deletedAt = { $exists: false };
|
|
364
|
+
let query = this.model.findOne(findFilter);
|
|
365
|
+
if (selectStr)
|
|
366
|
+
query = query.select(selectStr);
|
|
367
|
+
if (populateOpt)
|
|
368
|
+
query = query.populate(populateOpt);
|
|
369
|
+
item = await query.exec();
|
|
131
370
|
}
|
|
132
371
|
if (!item) {
|
|
133
|
-
return res.status(404).
|
|
372
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
134
373
|
}
|
|
135
|
-
|
|
374
|
+
let result = item;
|
|
375
|
+
if (hooks.afterRead) {
|
|
376
|
+
result = (await hooks.afterRead(req, item)) ?? item;
|
|
377
|
+
}
|
|
378
|
+
onSuccess(res, 'GET', result);
|
|
136
379
|
}
|
|
137
380
|
catch (error) {
|
|
138
|
-
onError(res,
|
|
381
|
+
onError(res, 'GET', error);
|
|
139
382
|
}
|
|
140
|
-
}, middleware));
|
|
141
|
-
this.registerRoute('GET', path, ['id']);
|
|
383
|
+
}, middleware, routeMiddleware.read));
|
|
384
|
+
this.registerRoute('GET', path, ['id', 'select', 'populate']);
|
|
142
385
|
}
|
|
143
|
-
// UPDATE
|
|
386
|
+
// ── UPDATE (FULL) ─ PUT /endpoint/:id ───────────────────────────────
|
|
144
387
|
if (methods.includes('PUT')) {
|
|
145
388
|
const path = `/${this.endpoint}/:id`;
|
|
146
|
-
this.router.put(path, this.
|
|
147
|
-
const method = 'PUT';
|
|
389
|
+
this.router.put(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
148
390
|
try {
|
|
149
|
-
|
|
391
|
+
if (validate.update) {
|
|
392
|
+
const validation = validate.update(req.body);
|
|
393
|
+
if (!validation.valid) {
|
|
394
|
+
return res.status(400).json({ errors: validation.errors });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
let data = req.body;
|
|
398
|
+
if (hooks.beforeUpdate) {
|
|
399
|
+
data = (await hooks.beforeUpdate(req, req.params.id, data)) ?? data;
|
|
400
|
+
}
|
|
401
|
+
const item = await this.model.findByIdAndUpdate(req.params.id, data, {
|
|
402
|
+
new: true,
|
|
403
|
+
runValidators: true,
|
|
404
|
+
});
|
|
150
405
|
if (!item) {
|
|
151
|
-
return res.status(404).
|
|
406
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
152
407
|
}
|
|
153
|
-
// Update related model entries if configured
|
|
154
408
|
if (options.relatedModel && options.relatedMethods?.includes('PUT')) {
|
|
155
|
-
await options.relatedModel.updateMany({ [options.relatedField]: item._id },
|
|
409
|
+
await options.relatedModel.updateMany({ [options.relatedField]: item._id }, data);
|
|
156
410
|
}
|
|
157
|
-
|
|
411
|
+
if (hooks.afterUpdate) {
|
|
412
|
+
await hooks.afterUpdate(req, item);
|
|
413
|
+
}
|
|
414
|
+
onSuccess(res, 'PUT', item);
|
|
158
415
|
}
|
|
159
416
|
catch (error) {
|
|
160
|
-
onError(res,
|
|
417
|
+
onError(res, 'PUT', error);
|
|
161
418
|
}
|
|
162
|
-
}, middleware));
|
|
419
|
+
}, middleware, routeMiddleware.update));
|
|
163
420
|
this.registerRoute('PUT', path, ['id']);
|
|
164
421
|
}
|
|
165
|
-
//
|
|
422
|
+
// ── UPDATE (PARTIAL) ─ PATCH /endpoint/:id ──────────────────────────
|
|
423
|
+
if (methods.includes('PATCH')) {
|
|
424
|
+
const path = `/${this.endpoint}/:id`;
|
|
425
|
+
this.router.patch(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
426
|
+
try {
|
|
427
|
+
if (validate.update) {
|
|
428
|
+
const validation = validate.update(req.body);
|
|
429
|
+
if (!validation.valid) {
|
|
430
|
+
return res.status(400).json({ errors: validation.errors });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
let data = req.body;
|
|
434
|
+
if (hooks.beforeUpdate) {
|
|
435
|
+
data = (await hooks.beforeUpdate(req, req.params.id, data)) ?? data;
|
|
436
|
+
}
|
|
437
|
+
const item = await this.model.findByIdAndUpdate(req.params.id, { $set: data }, { new: true, runValidators: true });
|
|
438
|
+
if (!item) {
|
|
439
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
440
|
+
}
|
|
441
|
+
if (hooks.afterUpdate) {
|
|
442
|
+
await hooks.afterUpdate(req, item);
|
|
443
|
+
}
|
|
444
|
+
onSuccess(res, 'PATCH', item);
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
onError(res, 'PATCH', error);
|
|
448
|
+
}
|
|
449
|
+
}, middleware, routeMiddleware.update));
|
|
450
|
+
this.registerRoute('PATCH', path, ['id']);
|
|
451
|
+
}
|
|
452
|
+
// ── BULK UPDATE ─ PATCH /endpoint/bulk ──────────────────────────────
|
|
453
|
+
if (methods.includes('PATCH')) {
|
|
454
|
+
const path = `/${this.endpoint}/bulk`;
|
|
455
|
+
this.router.patch(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
456
|
+
try {
|
|
457
|
+
const { filter, update } = req.body;
|
|
458
|
+
if (!filter || !update) {
|
|
459
|
+
return res.status(400).json({
|
|
460
|
+
error: 'Request body must contain "filter" and "update" objects',
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
if (validate.update) {
|
|
464
|
+
const validation = validate.update(update);
|
|
465
|
+
if (!validation.valid) {
|
|
466
|
+
return res.status(400).json({ errors: validation.errors });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const result = await this.model.updateMany(filter, { $set: update }, { runValidators: true });
|
|
470
|
+
onSuccess(res, 'PATCH (Bulk)', result);
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
onError(res, 'PATCH (Bulk)', error);
|
|
474
|
+
}
|
|
475
|
+
}, middleware, routeMiddleware.update));
|
|
476
|
+
this.registerRoute('PATCH', path, ['filter', 'update']);
|
|
477
|
+
}
|
|
478
|
+
// ── SOFT DELETE RESTORE ─ PATCH /endpoint/:id/restore ───────────────
|
|
479
|
+
if (softDelete && (methods.includes('PATCH') || methods.includes('PUT'))) {
|
|
480
|
+
const path = `/${this.endpoint}/:id/restore`;
|
|
481
|
+
this.router.patch(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
482
|
+
try {
|
|
483
|
+
const item = await this.model.findByIdAndUpdate(req.params.id, { $unset: { deletedAt: 1 } }, { new: true });
|
|
484
|
+
if (!item) {
|
|
485
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
486
|
+
}
|
|
487
|
+
onSuccess(res, 'PATCH (Restore)', item);
|
|
488
|
+
}
|
|
489
|
+
catch (error) {
|
|
490
|
+
onError(res, 'PATCH (Restore)', error);
|
|
491
|
+
}
|
|
492
|
+
}, middleware, routeMiddleware.update));
|
|
493
|
+
this.registerRoute('PATCH', path, ['id']);
|
|
494
|
+
}
|
|
495
|
+
// ── DELETE MULTIPLE ─ DELETE /endpoint ───────────────────────────────
|
|
166
496
|
if (methods.includes('DELETE')) {
|
|
167
497
|
const path = `/${this.endpoint}`;
|
|
168
|
-
this.router.delete(path, this.
|
|
169
|
-
const method = 'DELETE';
|
|
498
|
+
this.router.delete(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
170
499
|
try {
|
|
171
|
-
const query =
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
500
|
+
const query = this.safeJsonParse(req.query.filter);
|
|
501
|
+
if (Object.keys(query).length === 0) {
|
|
502
|
+
return res.status(400).json({
|
|
503
|
+
error: 'A filter is required for bulk delete to prevent accidental data loss',
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
let deleteResult;
|
|
507
|
+
if (softDelete) {
|
|
508
|
+
deleteResult = await this.model.updateMany(query, {
|
|
509
|
+
$set: { deletedAt: new Date() },
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
deleteResult = await this.model.deleteMany(query);
|
|
514
|
+
}
|
|
515
|
+
if ((deleteResult.deletedCount ?? deleteResult.modifiedCount) === 0) {
|
|
516
|
+
return res.status(404).json({ message: 'No matching items found to delete' });
|
|
175
517
|
}
|
|
176
518
|
if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
|
|
177
|
-
await
|
|
519
|
+
const ids = (await this.model.find(query).select('_id')).map((d) => d._id);
|
|
520
|
+
await options.relatedModel.deleteMany({
|
|
521
|
+
[options.relatedField]: { $in: ids },
|
|
522
|
+
});
|
|
178
523
|
}
|
|
179
|
-
onSuccess(res,
|
|
524
|
+
onSuccess(res, 'DELETE', deleteResult);
|
|
180
525
|
}
|
|
181
526
|
catch (error) {
|
|
182
|
-
onError(res,
|
|
527
|
+
onError(res, 'DELETE', error);
|
|
183
528
|
}
|
|
184
|
-
}, middleware));
|
|
529
|
+
}, middleware, routeMiddleware.delete));
|
|
185
530
|
this.registerRoute('DELETE', path, ['filter']);
|
|
186
531
|
}
|
|
187
|
-
// DELETE ONE
|
|
532
|
+
// ── DELETE ONE ─ DELETE /endpoint/:id ────────────────────────────────
|
|
188
533
|
if (methods.includes('DELETE')) {
|
|
189
534
|
const path = `/${this.endpoint}/:id`;
|
|
190
|
-
this.router.delete(path, this.
|
|
191
|
-
const method = 'DELETE';
|
|
535
|
+
this.router.delete(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
192
536
|
try {
|
|
193
|
-
|
|
537
|
+
if (hooks.beforeDelete) {
|
|
538
|
+
await hooks.beforeDelete(req, req.params.id);
|
|
539
|
+
}
|
|
540
|
+
let item;
|
|
541
|
+
if (softDelete) {
|
|
542
|
+
item = await this.model.findByIdAndUpdate(req.params.id, { $set: { deletedAt: new Date() } }, { new: true });
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
item = await this.model.findByIdAndDelete(req.params.id);
|
|
546
|
+
}
|
|
194
547
|
if (!item) {
|
|
195
|
-
return res.status(404).
|
|
548
|
+
return res.status(404).json({ message: 'Item not found' });
|
|
196
549
|
}
|
|
197
550
|
if (options.relatedModel && options.relatedMethods?.includes('DELETE')) {
|
|
198
|
-
await options.relatedModel.deleteMany({
|
|
551
|
+
await options.relatedModel.deleteMany({
|
|
552
|
+
[options.relatedField]: item._id,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
if (hooks.afterDelete) {
|
|
556
|
+
await hooks.afterDelete(req, item);
|
|
199
557
|
}
|
|
200
|
-
onSuccess(res,
|
|
558
|
+
onSuccess(res, 'DELETE', item);
|
|
201
559
|
}
|
|
202
560
|
catch (error) {
|
|
203
|
-
onError(res,
|
|
561
|
+
onError(res, 'DELETE', error);
|
|
204
562
|
}
|
|
205
|
-
}, middleware));
|
|
563
|
+
}, middleware, routeMiddleware.delete));
|
|
206
564
|
this.registerRoute('DELETE', path, ['id']);
|
|
207
565
|
}
|
|
208
|
-
//
|
|
209
|
-
if (methods.includes('
|
|
210
|
-
const path = `/${this.endpoint}/
|
|
211
|
-
this.router.
|
|
212
|
-
const method = 'GET (Aggregate)';
|
|
566
|
+
// ── BULK DELETE ─ DELETE /endpoint/bulk ──────────────────────────────
|
|
567
|
+
if (methods.includes('DELETE')) {
|
|
568
|
+
const path = `/${this.endpoint}/bulk`;
|
|
569
|
+
this.router.delete(path, ...this.buildMiddlewareChain(async (req, res) => {
|
|
213
570
|
try {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
571
|
+
const { ids } = req.body;
|
|
572
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
573
|
+
return res.status(400).json({
|
|
574
|
+
error: 'Request body must contain a non-empty "ids" array',
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
let deleteResult;
|
|
578
|
+
if (softDelete) {
|
|
579
|
+
deleteResult = await this.model.updateMany({ _id: { $in: ids } }, { $set: { deletedAt: new Date() } });
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
deleteResult = await this.model.deleteMany({ _id: { $in: ids } });
|
|
583
|
+
}
|
|
584
|
+
onSuccess(res, 'DELETE (Bulk)', deleteResult);
|
|
217
585
|
}
|
|
218
586
|
catch (error) {
|
|
219
|
-
onError(res,
|
|
587
|
+
onError(res, 'DELETE (Bulk)', error);
|
|
220
588
|
}
|
|
221
|
-
}, middleware));
|
|
222
|
-
this.registerRoute('
|
|
589
|
+
}, middleware, routeMiddleware.delete));
|
|
590
|
+
this.registerRoute('DELETE', path);
|
|
223
591
|
}
|
|
224
|
-
// CUSTOM ROUTES
|
|
592
|
+
// ── CUSTOM ROUTES ───────────────────────────────────────────────────
|
|
225
593
|
if (options.customRoutes) {
|
|
226
|
-
options.customRoutes.forEach(route => {
|
|
227
|
-
const { method, path, handler } = route;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
this.registerRoute(method.toUpperCase(), `/${this.endpoint}${path}`);
|
|
232
|
-
}
|
|
594
|
+
options.customRoutes.forEach((route) => {
|
|
595
|
+
const { method, path, handler, middleware: routeMw } = route;
|
|
596
|
+
const fullPath = `/${this.endpoint}${path}`;
|
|
597
|
+
this.router[method](fullPath, ...this.buildMiddlewareChain(handler, middleware, routeMw));
|
|
598
|
+
this.registerRoute(method.toUpperCase(), fullPath);
|
|
233
599
|
});
|
|
234
600
|
}
|
|
235
601
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
*/
|
|
602
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
603
|
+
/** Returns the configured Express Router with all CRUD routes. */
|
|
239
604
|
getRouter() {
|
|
240
605
|
return this.router;
|
|
241
606
|
}
|
|
242
|
-
/**
|
|
243
|
-
* Returns an array of registered route definitions.
|
|
244
|
-
*/
|
|
607
|
+
/** Returns an array of all registered route definitions. */
|
|
245
608
|
getRoutes() {
|
|
246
609
|
return this.routes;
|
|
247
610
|
}
|
|
248
611
|
}
|
|
249
612
|
|
|
250
|
-
|
|
613
|
+
exports.CrudController = CrudController;
|
|
614
|
+
exports.default = CrudController;
|