vanta-api 1.1.1 → 1.1.3
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/.gitattributes +2 -2
- package/LICENSE +1 -1
- package/README.md +290 -439
- package/bin/create-security-config.js +3 -1
- package/package.json +30 -29
- package/src/api-features.js +146 -264
package/.gitattributes
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
# Auto detect text files and perform LF normalization
|
|
2
|
-
* text=auto
|
|
1
|
+
# Auto detect text files and perform LF normalization
|
|
2
|
+
* text=auto
|
package/LICENSE
CHANGED
|
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
|
18
18
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
19
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
20
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,439 +1,290 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
###
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
```javascript
|
|
293
|
-
app.get("/example", catchAsync(async (req, res, next) => {
|
|
294
|
-
// Your async logic here
|
|
295
|
-
// If an error occurs, use handleError as shown above
|
|
296
|
-
res.status(200).json({ data: "Success" });
|
|
297
|
-
}));
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
### 3. catchError
|
|
301
|
-
|
|
302
|
-
**Purpose:**
|
|
303
|
-
An Express middleware that catches any error passed along (either from synchronous or asynchronous routes).
|
|
304
|
-
It sets the appropriate HTTP status and returns a JSON response containing the error’s status and message.
|
|
305
|
-
|
|
306
|
-
**Usage Example:**
|
|
307
|
-
|
|
308
|
-
```javascript
|
|
309
|
-
// At the end of your middleware stack:
|
|
310
|
-
app.use(catchError);
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
---
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
---
|
|
317
|
-
|
|
318
|
-
## Full Examples
|
|
319
|
-
|
|
320
|
-
### Example 1: Basic Query
|
|
321
|
-
To import the package along with the error handling components, use:
|
|
322
|
-
|
|
323
|
-
```javascript
|
|
324
|
-
import ApiFeatures, { handleError, catchAsync, catchError } from "vanta-api";
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
```javascript
|
|
328
|
-
import Product from "./models/product.js";
|
|
329
|
-
|
|
330
|
-
// URL: /api/products?status=active&price[gte]=100&sort=-price,createdAt&fields=name,price,category&page=1&limit=10&populate=category,brand
|
|
331
|
-
const features = new ApiFeatures(Product, req.query, "user");
|
|
332
|
-
const result = await features
|
|
333
|
-
.filter()
|
|
334
|
-
.sort()
|
|
335
|
-
.limitFields()
|
|
336
|
-
.paginate()
|
|
337
|
-
.populate()
|
|
338
|
-
.execute();
|
|
339
|
-
console.log(result);
|
|
340
|
-
```
|
|
341
|
-
|
|
342
|
-
### Example 2: Query with Manual Filters
|
|
343
|
-
|
|
344
|
-
*(Call `addManualFilters()` before `filter()`)*
|
|
345
|
-
|
|
346
|
-
```javascript
|
|
347
|
-
const query = { status: "active" };
|
|
348
|
-
const manualFilter = { category: "electronics" };
|
|
349
|
-
const features = new ApiFeatures(Product, query, "user");
|
|
350
|
-
features.addManualFilters(manualFilter).filter();
|
|
351
|
-
const result = await features.execute();
|
|
352
|
-
console.log(result);
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
### Example 3: Advanced Nested Populate with Array Input
|
|
356
|
-
|
|
357
|
-
```javascript
|
|
358
|
-
const populateArray = [
|
|
359
|
-
"brand", // Simple string input
|
|
360
|
-
{ path: "category", select: "name description" },
|
|
361
|
-
{ path: "category", select: "name", populate: { path: "subCategory", select: "title" } }
|
|
362
|
-
];
|
|
363
|
-
const features = new ApiFeatures(Product, req.query, "admin");
|
|
364
|
-
const result = await features.populate(populateArray).execute();
|
|
365
|
-
console.log(result);
|
|
366
|
-
```
|
|
367
|
-
|
|
368
|
-
### Example 4: Full Advanced Query Example
|
|
369
|
-
|
|
370
|
-
```http
|
|
371
|
-
GET /api/products?
|
|
372
|
-
page=1&
|
|
373
|
-
limit=10&
|
|
374
|
-
sort=-createdAt,price&
|
|
375
|
-
fields=name,price,category&
|
|
376
|
-
populate=category,brand&
|
|
377
|
-
price[gte]=1000&
|
|
378
|
-
category[in]=electronics,phones&
|
|
379
|
-
name[regex]=^Samsung
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
### Example 5: Using Default Pagination (when page and limit are not provided)
|
|
383
|
-
|
|
384
|
-
```javascript
|
|
385
|
-
// URL: /api/products?status=active
|
|
386
|
-
const features = new ApiFeatures(Product, req.query);
|
|
387
|
-
const result = await features
|
|
388
|
-
.filter() // Defaults to page 1 and limit 10
|
|
389
|
-
.execute();
|
|
390
|
-
console.log(result);
|
|
391
|
-
```
|
|
392
|
-
|
|
393
|
-
---
|
|
394
|
-
|
|
395
|
-
## Summary
|
|
396
|
-
|
|
397
|
-
- **Filtering:**
|
|
398
|
-
Combines query parameters with manual filters and applies a safe `$match` with an automatic `isActive: true` condition for non-admin users.
|
|
399
|
-
- **Sorting:**
|
|
400
|
-
Converts a comma-separated string into a proper `$sort` object.
|
|
401
|
-
- **Field Selection:**
|
|
402
|
-
Uses `$project` to include only permitted fields while excluding forbidden fields.
|
|
403
|
-
- **Pagination:**
|
|
404
|
-
Applies `$skip` and `$limit` with default values (page 1, limit 10) and role-based limits.
|
|
405
|
-
- **Populate:**
|
|
406
|
-
Joins related documents using `$lookup` and `$unwind`, supporting nested and varied input types.
|
|
407
|
-
- **Security:**
|
|
408
|
-
Enforces allowed operators, sanitizes inputs, validates numeric fields, and removes dangerous operators via the security configuration.
|
|
409
|
-
- **Logging & Error Handling:**
|
|
410
|
-
Integrated advanced logging using winston and centralized error handling with a custom error class and middleware.
|
|
411
|
-
- **Performance Optimizations:**
|
|
412
|
-
Supports aggregation cursor for large datasets and optimizes aggregation pipelines for efficient resource usage.
|
|
413
|
-
|
|
414
|
-
- **ApiFeatures:**
|
|
415
|
-
Provides advanced query capabilities such as filtering, sorting, pagination, and document population for your MongoDB data.
|
|
416
|
-
|
|
417
|
-
- **Error Handling Components:**
|
|
418
|
-
- **handleError:**
|
|
419
|
-
Throw consistent, structured errors with custom messages and status codes.
|
|
420
|
-
- **catchAsync:**
|
|
421
|
-
Wrap asynchronous route handlers to automatically propagate errors.
|
|
422
|
-
- **catchError:**
|
|
423
|
-
Centralized middleware to catch and respond to errors uniformly.
|
|
424
|
-
|
|
425
|
-
- **Importing:**
|
|
426
|
-
Use the following statement to access all features:
|
|
427
|
-
|
|
428
|
-
```javascript
|
|
429
|
-
import ApiFeatures, { handleError, catchAsync, catchError } from "vanta-api";
|
|
430
|
-
```
|
|
431
|
-
|
|
432
|
-
By following these guidelines, you can integrate and use Vanta-API for advanced, secure query handling and robust error management in your Node.js/Express projects.
|
|
433
|
-
|
|
434
|
-
---
|
|
435
|
-
|
|
436
|
-
VantaApi provides a complete solution for integrating powerful, secure, and customizable query capabilities into any Node.js/MongoDB project.
|
|
437
|
-
|
|
438
|
-
---
|
|
439
|
-
|
|
1
|
+
# VantaApi: Advanced MongoDB API Utilities
|
|
2
|
+
|
|
3
|
+
  
|
|
4
|
+
|
|
5
|
+
**VantaApi** is a comprehensive toolkit for building secure, performant, and flexible APIs on top of MongoDB with Mongoose. It streamlines common query operations—filtering, sorting, field selection, pagination, and population—while enforcing robust security policies and sanitization.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 🧩 Features Overview
|
|
10
|
+
|
|
11
|
+
* **Advanced Query Parsing**: Convert HTTP query parameters into MongoDB aggregation stages.
|
|
12
|
+
* **Filtering**: Support for comparison operators (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`), inclusion (`in`, `nin`), regex, existence, logical (`or`, `and`), and nested filters.
|
|
13
|
+
* **Sorting**: Dynamic, multi-field sorting with ascending/descending options and schema-based validation.
|
|
14
|
+
* **Field Selection**: Whitelisted projection of fields, automatic exclusion of sensitive data.
|
|
15
|
+
* **Pagination**: `$skip`/`$limit` with default values, role-based maximum limits, and helpful HTTP headers.
|
|
16
|
+
* **Population**: Flexible population via `$lookup` and `$unwind`, supporting string, object, array inputs, deep (nested) references, and role-based permissions.
|
|
17
|
+
* **Security & Sanitization**: Whitelist operators, strip dangerous operators (`$where`, `$function`, etc.), sanitize ObjectId and date values, and enforce forbidden fields.
|
|
18
|
+
* **Role-Based Access Control**: Define per-role limits on pagination, allowed populate paths, and other behaviors in a central config.
|
|
19
|
+
* **Performance Safeguards**: Limit maximum pipeline stages, enforce `maxTimeMS` for aggregation, and optional aggregation cursor support for large datasets.
|
|
20
|
+
* **Logging & Debugging**: Integrate with Winston for structured logs, debug flag to print built pipelines.
|
|
21
|
+
* **Error Handling**: Custom error class (`HandleERROR`), `catchAsync` helper, and `catchError` middleware for uniform error responses.
|
|
22
|
+
* **TypeScript-Ready**: Designed with generics and typings in mind for seamless TS integration.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 🚀 Installation
|
|
27
|
+
|
|
28
|
+
### Requirements
|
|
29
|
+
|
|
30
|
+
* **Node.js** 16 or higher
|
|
31
|
+
* **MongoDB** 5 or higher
|
|
32
|
+
* **Mongoose** 7 or higher
|
|
33
|
+
|
|
34
|
+
### Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install vanta-api
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
or
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
yarn add vanta-api
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Also install peer dependencies in your project:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm install mongoose winston pluralize
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 🔧 Configuration
|
|
55
|
+
|
|
56
|
+
Centralize security and behavior settings in `config.js`:
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
export const securityConfig = {
|
|
60
|
+
// Allowed filter operators
|
|
61
|
+
allowedOperators: [
|
|
62
|
+
'eq','ne','gt','gte','lt','lte','in','nin','regex','exists','size','or','and'
|
|
63
|
+
],
|
|
64
|
+
|
|
65
|
+
// Fields never exposed or matched
|
|
66
|
+
forbiddenFields: ['password','__v'],
|
|
67
|
+
|
|
68
|
+
// Role-based settings
|
|
69
|
+
accessLevels: {
|
|
70
|
+
guest: { maxLimit: 50, allowedPopulate: ['*'] },
|
|
71
|
+
user: { maxLimit: 100, allowedPopulate: ['profile','orders'] },
|
|
72
|
+
admin: { maxLimit: 1000, allowedPopulate: ['*'] },
|
|
73
|
+
superAdmin: { maxLimit: 5000, allowedPopulate: ['*'] }
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// Aggregation safeguards
|
|
77
|
+
maxPipelineStages: 20
|
|
78
|
+
};
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
* **allowedOperators**: Only these operators pass through parsing.
|
|
82
|
+
* **forbiddenFields**: Always removed from `$match` and `$project`.
|
|
83
|
+
* **accessLevels**: Configure per-role `maxLimit` and `allowedPopulate` paths.
|
|
84
|
+
* **maxPipelineStages**: Prevent overly complex pipelines.
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 🛠️ Usage Guide
|
|
89
|
+
|
|
90
|
+
### 1. Importing
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
import ApiFeatures, { HandleERROR, catchAsync, catchError } from 'vanta-api';
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 2. Express Integration
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import express from 'express';
|
|
100
|
+
import Product from './models/product.js';
|
|
101
|
+
|
|
102
|
+
const router = express.Router();
|
|
103
|
+
|
|
104
|
+
router.get(
|
|
105
|
+
'/',
|
|
106
|
+
catchAsync(async (req, res) => {
|
|
107
|
+
const features = new ApiFeatures(Product, req.query, req.user.role);
|
|
108
|
+
const result = await features
|
|
109
|
+
.filter()
|
|
110
|
+
.sort()
|
|
111
|
+
.limitFields()
|
|
112
|
+
.paginate()
|
|
113
|
+
.populate()
|
|
114
|
+
.execute({ debug: req.query.debug === 'true' });
|
|
115
|
+
|
|
116
|
+
res
|
|
117
|
+
.set('X-Total-Count', result.count)
|
|
118
|
+
.status(200)
|
|
119
|
+
.json(result);
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Global error handler
|
|
124
|
+
app.use(catchError);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 3. Chaining Methods
|
|
128
|
+
|
|
129
|
+
All methods (except `addManualFilters`) are chainable and return `this`.
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
const api = new ApiFeatures(Model, req.query, 'user');
|
|
133
|
+
api
|
|
134
|
+
.addManualFilters({ status: 'active' }) // optional
|
|
135
|
+
.filter()
|
|
136
|
+
.sort()
|
|
137
|
+
.limitFields()
|
|
138
|
+
.paginate()
|
|
139
|
+
.populate(['category','brand'])
|
|
140
|
+
.execute();
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## 📚 ApiFeatures Class Methods
|
|
146
|
+
|
|
147
|
+
### `.filter()`
|
|
148
|
+
|
|
149
|
+
* **Purpose**: Parse `req.query` and merge with optional manual filters, apply security filters (`forbiddenFields`, `isActive:true` for non-admin).
|
|
150
|
+
* **Behavior**:
|
|
151
|
+
|
|
152
|
+
1. Remove pagination/sort/project/populate keys.
|
|
153
|
+
2. Parse comparison operators and logical (`$or/$and`).
|
|
154
|
+
3. Sanitize values (ObjectId, boolean, number).
|
|
155
|
+
4. Apply `$match` with forbidden fields stripped.
|
|
156
|
+
* **Returns**: `this`.
|
|
157
|
+
|
|
158
|
+
### `.sort()`
|
|
159
|
+
|
|
160
|
+
* **Purpose**: Generate `$sort` stage.
|
|
161
|
+
* **Behavior**:
|
|
162
|
+
|
|
163
|
+
1. Split comma-separated list.
|
|
164
|
+
2. Determine direction (`-` prefix = descending).
|
|
165
|
+
3. Validate against schema paths.
|
|
166
|
+
4. Push `{ $sort: {...} }` if valid.
|
|
167
|
+
* **Returns**: `this`.
|
|
168
|
+
|
|
169
|
+
### `.limitFields()`
|
|
170
|
+
|
|
171
|
+
* **Purpose**: Generate `$project` stage for field selection.
|
|
172
|
+
* **Behavior**:
|
|
173
|
+
|
|
174
|
+
1. Split comma-separated fields.
|
|
175
|
+
2. Exclude `forbiddenFields`.
|
|
176
|
+
3. Validate against schema paths.
|
|
177
|
+
4. Push `{ $project: {...} }`.
|
|
178
|
+
* **Returns**: `this`.
|
|
179
|
+
|
|
180
|
+
### `.paginate()`
|
|
181
|
+
|
|
182
|
+
* **Purpose**: Add `$skip` and `$limit` for pagination.
|
|
183
|
+
* **Behavior**:
|
|
184
|
+
|
|
185
|
+
1. Parse `page` and `limit`, default to 1 and 10.
|
|
186
|
+
2. Enforce `maxLimit` per role.
|
|
187
|
+
3. Push `{ $skip }` and `{ $limit }`.
|
|
188
|
+
* **Returns**: `this`.
|
|
189
|
+
|
|
190
|
+
### `.populate(input?)`
|
|
191
|
+
|
|
192
|
+
* **Purpose**: Add `$lookup`/`$unwind` stages for population.
|
|
193
|
+
* **Input Types**: `string`, `{ path, select, populate? }`, `array`
|
|
194
|
+
* **Behavior**:
|
|
195
|
+
|
|
196
|
+
1. Collect inputs and `req.query.populate`.
|
|
197
|
+
2. Deduplicate and enforce `allowedPopulate` per role.
|
|
198
|
+
3. For each field:
|
|
199
|
+
|
|
200
|
+
* Determine `collection` via `pluralize`.
|
|
201
|
+
* Build `$lookup` with optional `pipeline` for projections.
|
|
202
|
+
* Add `$unwind` preserving nulls.
|
|
203
|
+
* **Returns**: `this`.
|
|
204
|
+
|
|
205
|
+
### `.addManualFilters(filters)`
|
|
206
|
+
|
|
207
|
+
* **Purpose**: Inject custom filters before calling `.filter()`.
|
|
208
|
+
* **Behavior**: Merge into internal `manualFilters`.
|
|
209
|
+
* **Returns**: `this`.
|
|
210
|
+
|
|
211
|
+
### `.execute(options?)`
|
|
212
|
+
|
|
213
|
+
* **Purpose**: Execute the aggregation pipeline and return results.
|
|
214
|
+
* **Options**:
|
|
215
|
+
|
|
216
|
+
* `useCursor` (boolean): Return a cursor for streaming large sets.
|
|
217
|
+
* `allowDiskUse` (boolean): Enable disk use.
|
|
218
|
+
* `maxTimeMS` (number): Timeout for aggregation.
|
|
219
|
+
* `debug` (boolean): Log the pipeline.
|
|
220
|
+
* `projection` (object): Final projection on returned documents.
|
|
221
|
+
* **Behavior**:
|
|
222
|
+
|
|
223
|
+
1. Validate `maxPipelineStages`.
|
|
224
|
+
2. Optionally log pipeline.
|
|
225
|
+
3. Run `countPipeline` + `$count` to get total.
|
|
226
|
+
4. Run `pipeline` with or without cursor.
|
|
227
|
+
5. Apply `projection` to results if provided.
|
|
228
|
+
* **Returns**: `{ success: true, count: number, data: array }`.
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## 🔄 Error Handling Utilities
|
|
233
|
+
|
|
234
|
+
### `HandleERROR`
|
|
235
|
+
|
|
236
|
+
Custom error class:
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
throw new HandleERROR('Not Found', 404);
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### `catchAsync(fn)`
|
|
243
|
+
|
|
244
|
+
Wrap async handlers:
|
|
245
|
+
|
|
246
|
+
```js
|
|
247
|
+
app.get('/', catchAsync(async (req,res) => { /* ... */ }));
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### `catchError`
|
|
251
|
+
|
|
252
|
+
Express error middleware:
|
|
253
|
+
|
|
254
|
+
```js
|
|
255
|
+
app.use(catchError);
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## 🌟 Examples
|
|
261
|
+
|
|
262
|
+
See the `examples/` directory for a full Express app demonstrating basic and advanced use cases.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## 🔬 Testing
|
|
267
|
+
|
|
268
|
+
Unit tests powered by Jest:
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
npm test
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## 🤝 Contributing
|
|
277
|
+
|
|
278
|
+
1. Fork the repo
|
|
279
|
+
2. Create a feature branch: `git checkout -b feature/YourFeature`
|
|
280
|
+
3. Commit: `git commit -m 'Add awesome feature'`
|
|
281
|
+
4. Push: `git push origin feature/YourFeature`
|
|
282
|
+
5. Open a Pull Request
|
|
283
|
+
|
|
284
|
+
Please follow existing code style, include tests, and update documentation if needed.
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## 📜 License
|
|
289
|
+
|
|
290
|
+
Licensed under the MIT License. See [LICENSE](./LICENSE) for details.
|
|
@@ -19,7 +19,9 @@ export const securityConfig = {
|
|
|
19
19
|
user: { maxLimit: 100, allowedPopulate: ["*"] },
|
|
20
20
|
admin: { maxLimit: 1000, allowedPopulate: ["*"] },
|
|
21
21
|
superAdmin: { maxLimit: 1000, allowedPopulate: ["*"] }
|
|
22
|
-
}
|
|
22
|
+
},
|
|
23
|
+
// Aggregation safeguards
|
|
24
|
+
maxPipelineStages: 20
|
|
23
25
|
};
|
|
24
26
|
`;
|
|
25
27
|
|
package/package.json
CHANGED
|
@@ -1,29 +1,30 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "vanta-api",
|
|
3
|
-
"version": "1.1.
|
|
4
|
-
"description": "Advanced API features and security configuration for Node.js/MongoDB.",
|
|
5
|
-
"main": "index.js",
|
|
6
|
-
"scripts": {
|
|
7
|
-
"postinstall": "node bin/create-security-config.js",
|
|
8
|
-
"test": "jest",
|
|
9
|
-
"start": "node index.js"
|
|
10
|
-
},
|
|
11
|
-
"keywords": [
|
|
12
|
-
"api",
|
|
13
|
-
"mongodb",
|
|
14
|
-
"mongoose",
|
|
15
|
-
"aggregation",
|
|
16
|
-
"security",
|
|
17
|
-
"nodejs"
|
|
18
|
-
],
|
|
19
|
-
"author": "Alireza Aghaee",
|
|
20
|
-
"license": "MIT",
|
|
21
|
-
"dependencies": {
|
|
22
|
-
"mongoose": "^7.0.0",
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "vanta-api",
|
|
3
|
+
"version": "1.1.3",
|
|
4
|
+
"description": "Advanced API features and security configuration for Node.js/MongoDB.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"postinstall": "node bin/create-security-config.js",
|
|
8
|
+
"test": "jest",
|
|
9
|
+
"start": "node index.js"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"api",
|
|
13
|
+
"mongodb",
|
|
14
|
+
"mongoose",
|
|
15
|
+
"aggregation",
|
|
16
|
+
"security",
|
|
17
|
+
"nodejs"
|
|
18
|
+
],
|
|
19
|
+
"author": "Alireza Aghaee",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"mongoose": "^7.0.0",
|
|
23
|
+
"pluralize": "^8.0.0",
|
|
24
|
+
"winston": "^3.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"jest": "^29.0.0"
|
|
28
|
+
},
|
|
29
|
+
"type": "module"
|
|
30
|
+
}
|
package/src/api-features.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import mongoose from "mongoose";
|
|
2
2
|
import winston from "winston";
|
|
3
|
-
import
|
|
3
|
+
import pluralize from "pluralize";
|
|
4
4
|
import HandleERROR from "./handleError.js";
|
|
5
|
+
import { securityConfig } from "./config.js";
|
|
5
6
|
|
|
7
|
+
// Logger setup
|
|
6
8
|
const logger = winston.createLogger({
|
|
7
9
|
level: "info",
|
|
8
10
|
format: winston.format.combine(
|
|
@@ -13,338 +15,218 @@ const logger = winston.createLogger({
|
|
|
13
15
|
});
|
|
14
16
|
|
|
15
17
|
export class ApiFeatures {
|
|
16
|
-
constructor(model, query, userRole = "guest") {
|
|
17
|
-
this.
|
|
18
|
+
constructor(model, query = {}, userRole = "guest") {
|
|
19
|
+
this.model = model;
|
|
18
20
|
this.query = { ...query };
|
|
19
21
|
this.userRole = userRole;
|
|
22
|
+
|
|
20
23
|
this.pipeline = [];
|
|
21
24
|
this.countPipeline = [];
|
|
22
25
|
this.manualFilters = {};
|
|
23
26
|
this.useCursor = false;
|
|
24
|
-
|
|
27
|
+
|
|
28
|
+
this._sanitization();
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
// ---------- Core Methods ----------
|
|
28
|
-
filter() {
|
|
29
|
-
const queryFilters = this.#parseQueryFilters();
|
|
30
|
-
const mergedFilters = { ...queryFilters, ...this.manualFilters };
|
|
31
|
-
const safeFilters = this.#applySecurityFilters(mergedFilters);
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
filter() {
|
|
34
|
+
// Parse and sanitize both query and manual filters
|
|
35
|
+
const queryFilters = this._parseQueryFilters();
|
|
36
|
+
const manual = this._sanitizeFilters(this.manualFilters);
|
|
37
|
+
const merged = { ...queryFilters, ...manual };
|
|
38
|
+
const safe = this._applySecurityFilters(merged);
|
|
39
|
+
|
|
40
|
+
if (Object.keys(safe).length) {
|
|
41
|
+
this.pipeline.push({ $match: safe });
|
|
42
|
+
this.countPipeline.push({ $match: safe });
|
|
36
43
|
}
|
|
37
44
|
return this;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
sort() {
|
|
41
|
-
if (this.query.sort)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
if (!this.query.sort) return this;
|
|
49
|
+
const parts = this.query.sort.split(",");
|
|
50
|
+
const validFields = Object.keys(this.model.schema.paths);
|
|
51
|
+
const sortObj = {};
|
|
52
|
+
|
|
53
|
+
for (const part of parts) {
|
|
54
|
+
const dir = part.startsWith("-") ? -1 : 1;
|
|
55
|
+
const key = part.replace(/^[-+]/, "");
|
|
56
|
+
if (validFields.includes(key)) sortObj[key] = dir;
|
|
50
57
|
}
|
|
58
|
+
|
|
59
|
+
if (Object.keys(sortObj).length) this.pipeline.push({ $sort: sortObj });
|
|
51
60
|
return this;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
limitFields() {
|
|
55
|
-
if (this.query.fields)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
.filter((f) => !securityConfig.forbiddenFields.includes(f))
|
|
59
|
-
.reduce((acc, curr) => ({ ...acc, [curr]: 1 }), {});
|
|
64
|
+
if (!this.query.fields) return this;
|
|
65
|
+
const validFields = Object.keys(this.model.schema.paths).filter(f => !securityConfig.forbiddenFields.includes(f));
|
|
66
|
+
const project = {};
|
|
60
67
|
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
this.query.fields.split(",").forEach(f => {
|
|
69
|
+
if (validFields.includes(f)) project[f] = 1;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (Object.keys(project).length) this.pipeline.push({ $project: project });
|
|
63
73
|
return this;
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
paginate() {
|
|
67
|
-
const { maxLimit } = securityConfig.accessLevels[this.userRole] || {
|
|
68
|
-
maxLimit: 100,
|
|
69
|
-
};
|
|
77
|
+
const { maxLimit } = securityConfig.accessLevels[this.userRole] || { maxLimit: 100 };
|
|
70
78
|
const page = Math.max(parseInt(this.query.page, 10) || 1, 1);
|
|
71
|
-
const
|
|
79
|
+
const lim = Math.min(Math.max(parseInt(this.query.limit, 10) || 10, 1), maxLimit);
|
|
72
80
|
|
|
73
|
-
this.pipeline.push({ $skip: (page - 1) *
|
|
81
|
+
this.pipeline.push({ $skip: (page - 1) * lim }, { $limit: lim });
|
|
74
82
|
return this;
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
populate(input = "") {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
});
|
|
88
|
-
} else if (typeof input === "object" && input.path) {
|
|
89
|
-
populateOptions.push(input);
|
|
90
|
-
} else if (typeof input === "string" && input.trim().length > 0) {
|
|
91
|
-
input
|
|
92
|
-
.split(",")
|
|
93
|
-
.filter(Boolean)
|
|
94
|
-
.forEach((item) => {
|
|
95
|
-
populateOptions.push(item.trim());
|
|
96
|
-
});
|
|
97
|
-
}
|
|
86
|
+
// Build list from input and query.populate
|
|
87
|
+
let list = [];
|
|
88
|
+
const raw = Array.isArray(input) ? input : [input];
|
|
89
|
+
if (this.query.populate) raw.push(...this.query.populate.split(","));
|
|
90
|
+
|
|
91
|
+
raw.forEach(item => {
|
|
92
|
+
if (typeof item === 'string' && item.trim()) list.push(item.trim());
|
|
93
|
+
else if (item?.path) list.push(item);
|
|
94
|
+
});
|
|
98
95
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
});
|
|
106
|
-
}
|
|
96
|
+
// Deduplicate
|
|
97
|
+
const map = new Map();
|
|
98
|
+
list.forEach(opt => {
|
|
99
|
+
const key = typeof opt === 'string' ? opt : opt.path;
|
|
100
|
+
map.set(key, opt);
|
|
101
|
+
});
|
|
107
102
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
uniqueMap.set(item, item);
|
|
114
|
-
}
|
|
103
|
+
// Enforce role-based populate
|
|
104
|
+
const allowed = securityConfig.accessLevels[this.userRole]?.allowedPopulate || [];
|
|
105
|
+
const final = [];
|
|
106
|
+
map.forEach((opt, key) => {
|
|
107
|
+
if (allowed.includes('*') || allowed.includes(key)) final.push(opt);
|
|
115
108
|
});
|
|
116
|
-
const uniquePopulateOptions = Array.from(uniqueMap.values());
|
|
117
|
-
|
|
118
|
-
uniquePopulateOptions.forEach((option) => {
|
|
119
|
-
let field,
|
|
120
|
-
projection = {};
|
|
121
|
-
if (typeof option === "object") {
|
|
122
|
-
field = option.path;
|
|
123
|
-
if (option.select) {
|
|
124
|
-
option.select.split(" ").forEach((fieldName) => {
|
|
125
|
-
if (fieldName) projection[fieldName.trim()] = 1;
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
} else if (typeof option === "string") {
|
|
129
|
-
field = option;
|
|
130
|
-
}
|
|
131
109
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
$lookup: {
|
|
139
|
-
from: collection,
|
|
140
|
-
let: { localId: `$${field}` },
|
|
141
|
-
pipeline: [
|
|
142
|
-
{
|
|
143
|
-
$match: {
|
|
144
|
-
$expr: { $eq: ["$_id", "$$localId"] },
|
|
145
|
-
},
|
|
146
|
-
},
|
|
147
|
-
{ $project: projection },
|
|
148
|
-
],
|
|
149
|
-
as: field,
|
|
150
|
-
},
|
|
151
|
-
};
|
|
152
|
-
} else {
|
|
153
|
-
lookupStage = {
|
|
154
|
-
$lookup: {
|
|
155
|
-
from: collection,
|
|
156
|
-
localField: field,
|
|
157
|
-
foreignField: "_id",
|
|
158
|
-
as: field,
|
|
159
|
-
},
|
|
160
|
-
};
|
|
161
|
-
}
|
|
110
|
+
// Apply lookups
|
|
111
|
+
for (const opt of final) {
|
|
112
|
+
const field = typeof opt === 'string' ? opt : opt.path;
|
|
113
|
+
const proj = typeof opt === 'object' && opt.select
|
|
114
|
+
? opt.select.split(' ').reduce((a, f) => { a[f]=1; return a; }, {})
|
|
115
|
+
: {};
|
|
162
116
|
|
|
163
|
-
this.
|
|
164
|
-
|
|
165
|
-
$
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
});
|
|
170
|
-
}
|
|
117
|
+
const { collection } = this._getCollectionInfo(field);
|
|
118
|
+
const lookup = proj && Object.keys(proj).length
|
|
119
|
+
? { from: collection, let: { id: `$${field}` }, pipeline: [ { $match: { $expr: { $eq: ['$_id','$$id'] } } }, { $project: proj } ], as: field }
|
|
120
|
+
: { from: collection, localField: field, foreignField: '_id', as: field };
|
|
121
|
+
|
|
122
|
+
this.pipeline.push({ $lookup: lookup });
|
|
123
|
+
this.pipeline.push({ $unwind: { path: `$${field}`, preserveNullAndEmptyArrays: true } });
|
|
124
|
+
}
|
|
171
125
|
|
|
172
126
|
return this;
|
|
173
127
|
}
|
|
174
128
|
|
|
175
129
|
addManualFilters(filters) {
|
|
176
|
-
if (filters) {
|
|
177
|
-
this.manualFilters = { ...this.manualFilters, ...filters };
|
|
178
|
-
}
|
|
130
|
+
if (filters) this.manualFilters = { ...this.manualFilters, ...filters };
|
|
179
131
|
return this;
|
|
180
132
|
}
|
|
181
133
|
|
|
182
134
|
async execute(options = {}) {
|
|
183
135
|
try {
|
|
184
|
-
if (options.useCursor
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
this.Model.aggregate([...this.countPipeline, { $count: "total" }]),
|
|
189
|
-
this.useCursor
|
|
190
|
-
? this.Model.aggregate(this.pipeline)
|
|
191
|
-
.cursor({ batchSize: 100 })
|
|
192
|
-
.exec()
|
|
193
|
-
: this.Model.aggregate(this.pipeline)
|
|
194
|
-
.allowDiskUse(options.allowDiskUse || false)
|
|
195
|
-
.readConcern("majority"),
|
|
196
|
-
]);
|
|
197
|
-
|
|
198
|
-
const count = countResult[0]?.total || 0;
|
|
199
|
-
let data = [];
|
|
200
|
-
if (this.useCursor) {
|
|
201
|
-
const cursor = dataResult;
|
|
202
|
-
for await (const doc of cursor) {
|
|
203
|
-
data.push(doc);
|
|
204
|
-
}
|
|
205
|
-
} else {
|
|
206
|
-
data = dataResult;
|
|
136
|
+
if (options.useCursor) this.useCursor = true;
|
|
137
|
+
if (options.debug) logger.info('Pipeline:', this.pipeline);
|
|
138
|
+
if (this.pipeline.length > (securityConfig.maxPipelineStages || 20)) {
|
|
139
|
+
throw new HandleERROR('Too many pipeline stages', 400);
|
|
207
140
|
}
|
|
208
141
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
this
|
|
142
|
+
let agg = this.model.aggregate(pipeline).option({ maxTimeMS: 10000 });
|
|
143
|
+
const [cnt] = await this.model.aggregate([...this.countPipeline, { $count: 'total' }]);
|
|
144
|
+
const cursorOrData = this.useCursor
|
|
145
|
+
? agg.cursor({ batchSize: 100 }).exec()
|
|
146
|
+
: agg.allowDiskUse(options.allowDiskUse || false).readConcern('majority');
|
|
147
|
+
|
|
148
|
+
const data = this.useCursor
|
|
149
|
+
? await cursorOrData.toArray()
|
|
150
|
+
: await cursorOrData;
|
|
151
|
+
|
|
152
|
+
const result = { success: true, count: cnt?.total || 0, data };
|
|
153
|
+
if (options.projection) {
|
|
154
|
+
result.data = result.data.map(doc => {
|
|
155
|
+
const projDoc = {};
|
|
156
|
+
Object.keys(options.projection).forEach(f => {
|
|
157
|
+
if (options.projection[f]) projDoc[f] = doc[f];
|
|
158
|
+
});
|
|
159
|
+
return projDoc;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
this._handleError(err);
|
|
216
165
|
}
|
|
217
166
|
}
|
|
218
167
|
|
|
219
|
-
// ----------
|
|
220
|
-
|
|
221
|
-
|
|
168
|
+
// ---------- Private Helpers ----------
|
|
169
|
+
|
|
170
|
+
_sanitization() {
|
|
171
|
+
// Remove unsafe ops
|
|
172
|
+
['$', '$where', '$accumulator', '$function'].forEach(op => {
|
|
222
173
|
delete this.query[op];
|
|
223
|
-
delete this.manualFilters[op];
|
|
224
174
|
});
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
175
|
+
// Validate numeric
|
|
176
|
+
['page','limit'].forEach(f => {
|
|
177
|
+
if (this.query[f] && !/^[0-9]+$/.test(this.query[f])) {
|
|
178
|
+
throw new HandleERROR(`Invalid ${f}`,400);
|
|
228
179
|
}
|
|
229
180
|
});
|
|
230
181
|
}
|
|
231
182
|
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
[
|
|
235
|
-
(el) => delete queryObj[el]
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
return JSON.parse(
|
|
239
|
-
JSON.stringify(queryObj).replace(
|
|
240
|
-
/\b(gte|gt|lte|lt|in|nin|eq|ne|regex|exists|size)\b/g,
|
|
241
|
-
"$$$&"
|
|
242
|
-
)
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
#applySecurityFilters(filters) {
|
|
247
|
-
let result = { ...filters };
|
|
248
|
-
|
|
249
|
-
securityConfig.forbiddenFields.forEach((field) => delete result[field]);
|
|
250
|
-
|
|
251
|
-
if (this.userRole !== "admin" && this.Model.schema.path("isActive")) {
|
|
252
|
-
result.isActive = true;
|
|
253
|
-
result = this.#sanitizeNestedObjects(result);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return result;
|
|
257
|
-
}
|
|
183
|
+
_parseQueryFilters() {
|
|
184
|
+
const obj = { ...this.query };
|
|
185
|
+
['page','limit','sort','fields','populate'].forEach(k => delete obj[k]);
|
|
258
186
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
typeof value === "object" &&
|
|
265
|
-
!Array.isArray(value)
|
|
266
|
-
) {
|
|
267
|
-
const sanitizedObj = {};
|
|
268
|
-
for (const [op, val] of Object.entries(value)) {
|
|
269
|
-
if (
|
|
270
|
-
["$eq", "$ne", "$gt", "$gte", "$lt", "$lte"].includes(op) &&
|
|
271
|
-
mongoose.isValidObjectId(val)
|
|
272
|
-
) {
|
|
273
|
-
sanitizedObj[op] = new mongoose.Types.ObjectId(val);
|
|
274
|
-
} else if (["$in", "$nin"].includes(op) && Array.isArray(val)) {
|
|
275
|
-
sanitizedObj[op] = val
|
|
276
|
-
.filter((v) => mongoose.isValidObjectId(v))
|
|
277
|
-
.map((v) => new mongoose.Types.ObjectId(v));
|
|
278
|
-
} else {
|
|
279
|
-
sanitizedObj[op] = val;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
acc[key] = sanitizedObj;
|
|
283
|
-
} else if (typeof value === "object" && !Array.isArray(value)) {
|
|
284
|
-
acc[key] = this.#sanitizeNestedObjects(value);
|
|
187
|
+
// Whitelist operators
|
|
188
|
+
const out = {};
|
|
189
|
+
for (const [k,v] of Object.entries(obj)) {
|
|
190
|
+
if (['or','and'].includes(k)) {
|
|
191
|
+
out[`$${k}`] = Array.isArray(v) ? v : [v];
|
|
285
192
|
} else {
|
|
286
|
-
|
|
193
|
+
out[k] = typeof v === 'string' && v.includes(',')
|
|
194
|
+
? v.split(',')
|
|
195
|
+
: v;
|
|
287
196
|
}
|
|
288
|
-
return acc;
|
|
289
|
-
}, {});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
#sanitizeValue(key, value) {
|
|
293
|
-
if (key.endsWith("Id") && mongoose.isValidObjectId(value)) {
|
|
294
|
-
return new mongoose.Types.ObjectId(value);
|
|
295
|
-
}
|
|
296
|
-
if (typeof value === "string") {
|
|
297
|
-
if (value === "true") return true;
|
|
298
|
-
if (value === "false") return false;
|
|
299
|
-
if (/^\d+$/.test(value)) return parseInt(value, 10);
|
|
300
197
|
}
|
|
301
|
-
return
|
|
198
|
+
return out;
|
|
302
199
|
}
|
|
303
200
|
|
|
304
|
-
|
|
305
|
-
|
|
201
|
+
_sanitizeFilters(filters) {
|
|
202
|
+
// Simple deep clone with ObjectId and boolean parsing
|
|
203
|
+
return JSON.parse(JSON.stringify(filters), (key,val) => {
|
|
204
|
+
if (key.endsWith('Id') && mongoose.isValidObjectId(val)) return new mongoose.Types.ObjectId(val);
|
|
205
|
+
if (val === 'true') return true;
|
|
206
|
+
if (val === 'false') return false;
|
|
207
|
+
if (/^[0-9]+$/.test(val)) return parseInt(val,10);
|
|
208
|
+
return val;
|
|
209
|
+
});
|
|
306
210
|
}
|
|
307
|
-
#getCollectionName(modelName) {
|
|
308
|
-
const lowerName = modelName.toLowerCase();
|
|
309
|
-
const lastChar = lowerName.slice(-1);
|
|
310
|
-
const lastTwoChars = lowerName.slice(-2);
|
|
311
|
-
|
|
312
|
-
if (lastChar === "y") {
|
|
313
|
-
const secondLastChar = lowerName.slice(-2, -1);
|
|
314
|
-
if (!this.#isVowel(secondLastChar)) {
|
|
315
|
-
return lowerName.slice(0, -1) + "ies";
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
211
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
lastChar === "z"
|
|
325
|
-
) {
|
|
326
|
-
return lowerName + "es";
|
|
212
|
+
_applySecurityFilters(filters) {
|
|
213
|
+
let res = { ...filters };
|
|
214
|
+
securityConfig.forbiddenFields.forEach(f => delete res[f]);
|
|
215
|
+
if (this.userRole !== 'admin' && this.model.schema.path('isActive')) {
|
|
216
|
+
res.isActive = true;
|
|
327
217
|
}
|
|
328
|
-
|
|
329
|
-
return lowerName + "s";
|
|
218
|
+
return res;
|
|
330
219
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
return {
|
|
337
|
-
collection: this.#getCollectionName(schemaPath?.options?.ref),
|
|
338
|
-
isArray: schemaPath.instance === "Array",
|
|
339
|
-
};
|
|
220
|
+
|
|
221
|
+
_getCollectionInfo(field) {
|
|
222
|
+
const path = this.model.schema.path(field);
|
|
223
|
+
if (!path?.options?.ref) throw new HandleERROR(`Invalid populate: ${field}`,400);
|
|
224
|
+
return { collection: pluralize(path.options.ref), isArray: path.instance==='Array' };
|
|
340
225
|
}
|
|
341
226
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
stack: error.stack,
|
|
346
|
-
});
|
|
347
|
-
throw error;
|
|
227
|
+
_handleError(err) {
|
|
228
|
+
logger.error(`[ApiFeatures] ${err.message}`, { stack: err.stack });
|
|
229
|
+
throw err;
|
|
348
230
|
}
|
|
349
231
|
}
|
|
350
232
|
|