vanta-api 1.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/.gitattributes +2 -0
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/bin/create-security-config.js +30 -0
- package/package.json +29 -0
- package/src/api-features.js +306 -0
- package/src/config.js +31 -0
- package/src/errorHandler.js +9 -0
- package/src/handleError.js +10 -0
- package/test/api-features.test.js +0 -0
package/.gitattributes
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 AlirezaAghaee1996
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
# VantaApi: Advanced API Features & Security Config for MongoDB
|
|
4
|
+
|
|
5
|
+
This repository provides a robust, feature-rich, and secure solution for building, customizing, and optimizing your Node.js APIs powered by MongoDB. **VantaApi** processes incoming query parameters and builds an aggregation pipeline step by step, offering powerful features such as advanced filtering, sorting, field selection, pagination, and document population with comprehensive security controls.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
1. [Installation & Setup](#installation--setup)
|
|
12
|
+
2. [Overview](#overview)
|
|
13
|
+
3. [VantaApi Class Methods](#VantaApi-class-methods)
|
|
14
|
+
- [filter()](#filter)
|
|
15
|
+
- [sort()](#sort)
|
|
16
|
+
- [limitFields()](#limitfields)
|
|
17
|
+
- [paginate()](#paginate)
|
|
18
|
+
- [populate()](#populate)
|
|
19
|
+
- [addManualFilters()](#addmanualfilters)
|
|
20
|
+
- [execute()](#execute)
|
|
21
|
+
4. [Input Types & Supported Operators](#input-types--supported-operators)
|
|
22
|
+
5. [Additional Conditions](#additional-conditions)
|
|
23
|
+
6. [Security Configuration](#security-configuration)
|
|
24
|
+
7. [Security & Performance Enhancements](#security--performance-enhancements)
|
|
25
|
+
8. [Error Handling Middleware](#error-handling-middleware)
|
|
26
|
+
9. [Full Examples](#full-examples)
|
|
27
|
+
10. [Summary](#summary)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Installation & Setup
|
|
32
|
+
|
|
33
|
+
### Prerequisites
|
|
34
|
+
|
|
35
|
+
- Node.js 16+
|
|
36
|
+
- MongoDB 5+
|
|
37
|
+
- Mongoose 7+
|
|
38
|
+
|
|
39
|
+
### Install Dependencies
|
|
40
|
+
|
|
41
|
+
Install core dependencies:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install mongoose lodash dotenv winston
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
For testing purposes, install Jest:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install --save-dev jest
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Overview
|
|
56
|
+
|
|
57
|
+
The **VantaApi** class processes incoming query parameters and progressively builds an aggregation pipeline. The package supports:
|
|
58
|
+
|
|
59
|
+
- **Advanced filtering, sorting, and field selection.**
|
|
60
|
+
- **Pagination with defaults** (defaults to page 1 and limit 10 if not provided), with the maximum limit based on the user's role.
|
|
61
|
+
- **Document population:** Supports joining related documents, including nested population.
|
|
62
|
+
- **Automatic Conditions:** If the model includes an `isActive` field and the user is not an admin, `isActive: true` is added automatically.
|
|
63
|
+
- **Input Sanitization & Validation:** Sanitizes inputs, validates numeric fields, and enforces security through a dedicated configuration.
|
|
64
|
+
- **Enhanced Logging & Error Handling:** Uses winston for logging and employs a custom error class for centralized error management.
|
|
65
|
+
- **Performance Improvements:** Features aggregation cursor support and optimized pipeline ordering.
|
|
66
|
+
- **Error Middleware:** Provides a centralized error handling middleware for consistent API error responses.
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## VantaApi Class Methods
|
|
71
|
+
|
|
72
|
+
### filter()
|
|
73
|
+
- **Description:**
|
|
74
|
+
Parses query parameters, merges them with manually added filters (if provided), and applies security filters. If the model includes an `isActive` field and the user is not "admin," it automatically adds `isActive: true`.
|
|
75
|
+
- **Usage Example:**
|
|
76
|
+
|
|
77
|
+
```javascript
|
|
78
|
+
// URL: /api/products?status=active&price[gte]=100
|
|
79
|
+
const features = new VantaApi(Product, req.query);
|
|
80
|
+
features.filter();
|
|
81
|
+
// Pipeline adds: { $match: { status: "active", price: { $gte: 100 }, isActive: true } }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### sort()
|
|
85
|
+
- **Description:**
|
|
86
|
+
Converts a comma-separated list of sorting fields into a `$sort` object. A "-" prefix indicates descending order.
|
|
87
|
+
- **Usage Example:**
|
|
88
|
+
|
|
89
|
+
```javascript
|
|
90
|
+
// URL: /api/products?sort=-price,createdAt
|
|
91
|
+
const features = new VantaApi(Product, req.query);
|
|
92
|
+
features.sort();
|
|
93
|
+
// Pipeline adds: { $sort: { price: -1, createdAt: 1 } }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### limitFields()
|
|
97
|
+
- **Description:**
|
|
98
|
+
Uses `$project` to return only the specified fields while excluding forbidden fields (such as "password").
|
|
99
|
+
- **Usage Example:**
|
|
100
|
+
|
|
101
|
+
```javascript
|
|
102
|
+
// URL: /api/products?fields=name,price,category,password
|
|
103
|
+
const features = new VantaApi(Product, req.query);
|
|
104
|
+
features.limitFields();
|
|
105
|
+
// Pipeline adds: { $project: { name: 1, price: 1, category: 1 } }
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### paginate()
|
|
109
|
+
- **Description:**
|
|
110
|
+
Determines the page and limit values, applying pagination with defaults (page 1 and limit 10 if not provided). The maximum limit is based on the user's role as defined in the security configuration.
|
|
111
|
+
- **Usage Example:**
|
|
112
|
+
|
|
113
|
+
```javascript
|
|
114
|
+
// URL: /api/products?page=2&limit=20
|
|
115
|
+
const features = new VantaApi(Product, req.query, "user");
|
|
116
|
+
features.paginate();
|
|
117
|
+
// Pipeline adds: { $skip: 20 } and { $limit: 20 }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### populate()
|
|
121
|
+
- **Description:**
|
|
122
|
+
Joins related documents using `$lookup` and `$unwind`. It supports:
|
|
123
|
+
- **String Input:** A comma-separated list of field names.
|
|
124
|
+
- **Object Input:** An object with properties `path` (required) and `select` (optional).
|
|
125
|
+
- **Array Input:** An array of strings or objects for multiple or nested population.
|
|
126
|
+
- **Usage Examples:**
|
|
127
|
+
- **String Input:**
|
|
128
|
+
|
|
129
|
+
```javascript
|
|
130
|
+
// URL: /api/products?populate=category,brand
|
|
131
|
+
const features = new VantaApi(Product, req.query);
|
|
132
|
+
features.populate();
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
- **Object Input:**
|
|
136
|
+
|
|
137
|
+
```javascript
|
|
138
|
+
const populateOptions = { path: "category", select: "name description" };
|
|
139
|
+
const features = new VantaApi(Product, req.query);
|
|
140
|
+
features.populate(populateOptions);
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- **Array Input:**
|
|
144
|
+
|
|
145
|
+
```javascript
|
|
146
|
+
const populateArray = [
|
|
147
|
+
"brand",
|
|
148
|
+
{ path: "category", select: "name description" },
|
|
149
|
+
{ path: "category", select: "name", populate: { path: "subCategory", select: "title" } }
|
|
150
|
+
];
|
|
151
|
+
const features = new VantaApi(Product, req.query, "admin");
|
|
152
|
+
features.populate(populateArray);
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### addManualFilters()
|
|
156
|
+
- **Description:**
|
|
157
|
+
Merges additional manual filters with the parsed query filters. **Note:** Call `addManualFilters()` before `filter()` to incorporate the manual filters properly.
|
|
158
|
+
- **Usage Example:**
|
|
159
|
+
|
|
160
|
+
```javascript
|
|
161
|
+
const manualFilter = { category: "electronics" };
|
|
162
|
+
const features = new VantaApi(Product, { status: "active" });
|
|
163
|
+
features.addManualFilters(manualFilter).filter();
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### execute()
|
|
167
|
+
- **Description:**
|
|
168
|
+
Executes the built aggregation pipeline using Mongoose and returns an object containing a success flag, total document count, and result data. Supports aggregation cursor for handling large datasets.
|
|
169
|
+
- **Usage Example:**
|
|
170
|
+
|
|
171
|
+
```javascript
|
|
172
|
+
const features = new VantaApi(Product, req.query);
|
|
173
|
+
const result = await features
|
|
174
|
+
.filter()
|
|
175
|
+
.sort()
|
|
176
|
+
.limitFields()
|
|
177
|
+
.paginate()
|
|
178
|
+
.populate()
|
|
179
|
+
.execute();
|
|
180
|
+
console.log(result);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Input Types & Supported Operators
|
|
186
|
+
|
|
187
|
+
### Filtering Operators
|
|
188
|
+
|
|
189
|
+
The class translates query parameters into MongoDB operators:
|
|
190
|
+
|
|
191
|
+
| Operator | Query Example | Description |
|
|
192
|
+
|----------|---------------------------|----------------------------|
|
|
193
|
+
| eq | `?age=25` | Equal to |
|
|
194
|
+
| ne | `?status[ne]=inactive` | Not equal to |
|
|
195
|
+
| gt | `?price[gt]=100` | Greater than |
|
|
196
|
+
| gte | `?stock[gte]=50` | Greater than or equal to |
|
|
197
|
+
| lt | `?weight[lt]=500` | Less than |
|
|
198
|
+
| lte | `?rating[lte]=3` | Less than or equal to |
|
|
199
|
+
| in | `?colors[in]=red,blue` | In the list |
|
|
200
|
+
| nin | `?size[nin]=xl` | Not in the list |
|
|
201
|
+
| regex | `?name[regex]=^A` | Regex search |
|
|
202
|
+
| exists | `?discount[exists]=true` | Field existence |
|
|
203
|
+
|
|
204
|
+
Additional aspects like sorting, projection, and pagination are handled via `$sort`, `$project`, `$skip`, and `$limit`.
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Additional Conditions
|
|
209
|
+
|
|
210
|
+
- **isActive Condition:**
|
|
211
|
+
If the model includes an `isActive` field and the user is not an admin, `filter()` automatically adds `isActive: true`.
|
|
212
|
+
- **Default Pagination:**
|
|
213
|
+
Defaults are applied (page 1 and limit 10) when no pagination parameters are provided.
|
|
214
|
+
- **Numeric Validation:**
|
|
215
|
+
Validates that fields such as `page` and `limit` contain only numeric values.
|
|
216
|
+
- **Removal of Dangerous Operators:**
|
|
217
|
+
Operators such as `$where`, `$accumulator`, and `$function` are removed from the query.
|
|
218
|
+
- **Ordering of Manual Filters:**
|
|
219
|
+
Ensure that `addManualFilters()` is called before `filter()` so that manual filters are merged correctly.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Security Configuration
|
|
224
|
+
|
|
225
|
+
Security settings enforce allowed operators, exclude forbidden fields (e.g., "password"), and apply role-based access limits:
|
|
226
|
+
|
|
227
|
+
```javascript
|
|
228
|
+
export const securityConfig = {
|
|
229
|
+
allowedOperators: [
|
|
230
|
+
"eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "regex", "exists", "size", "or", "and"
|
|
231
|
+
],
|
|
232
|
+
forbiddenFields: ["password"],
|
|
233
|
+
accessLevels: {
|
|
234
|
+
guest: { maxLimit: 50, allowedPopulate: ["*"] },
|
|
235
|
+
user: { maxLimit: 100, allowedPopulate: ["*"] },
|
|
236
|
+
admin: { maxLimit: 1000, allowedPopulate: ["*"] },
|
|
237
|
+
superAdmin: { maxLimit: 1000, allowedPopulate: ["*"] }
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
These configurations are applied automatically in methods such as `filter()`, `paginate()`, and `limitFields()`.
|
|
243
|
+
|
|
244
|
+
---
|
|
245
|
+
|
|
246
|
+
## Security & Performance Enhancements
|
|
247
|
+
|
|
248
|
+
- **Enhanced Logging:**
|
|
249
|
+
Uses winston to log events and errors with detailed timestamps and stack traces.
|
|
250
|
+
- **Improved Error Handling:**
|
|
251
|
+
A custom error class (HandleERROR) coupled with dedicated error middleware centralizes error management.
|
|
252
|
+
- **Dynamic Configuration:**
|
|
253
|
+
Security settings are maintained in a separate config file (`config.js`) for easy modifications without changing core code.
|
|
254
|
+
- **Performance Optimization:**
|
|
255
|
+
Features aggregation cursor support in `execute()` and optimized pipeline ordering for resource-efficient execution.
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## Error Handling Middleware
|
|
260
|
+
|
|
261
|
+
A dedicated error-handling middleware is available to centralize error responses:
|
|
262
|
+
|
|
263
|
+
```javascript
|
|
264
|
+
import catchError from "./errorHandler.js";
|
|
265
|
+
|
|
266
|
+
// In your Express app:
|
|
267
|
+
// app.use(catchError);
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
This middleware sets the appropriate HTTP status code and JSON error message when an error occurs.
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Full Examples
|
|
275
|
+
|
|
276
|
+
### Example 1: Basic Query
|
|
277
|
+
|
|
278
|
+
```javascript
|
|
279
|
+
import VantaApi from "./api-features.js";
|
|
280
|
+
import Product from "./models/product.js";
|
|
281
|
+
|
|
282
|
+
// URL: /api/products?status=active&price[gte]=100&sort=-price,createdAt&fields=name,price,category&page=1&limit=10&populate=category,brand
|
|
283
|
+
const features = new VantaApi(Product, req.query, "user");
|
|
284
|
+
const result = await features
|
|
285
|
+
.filter()
|
|
286
|
+
.sort()
|
|
287
|
+
.limitFields()
|
|
288
|
+
.paginate()
|
|
289
|
+
.populate()
|
|
290
|
+
.execute();
|
|
291
|
+
console.log(result);
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Example 2: Query with Manual Filters
|
|
295
|
+
|
|
296
|
+
*(Call `addManualFilters()` before `filter()`)*
|
|
297
|
+
|
|
298
|
+
```javascript
|
|
299
|
+
const query = { status: "active" };
|
|
300
|
+
const manualFilter = { category: "electronics" };
|
|
301
|
+
const features = new VantaApi(Product, query, "user");
|
|
302
|
+
features.addManualFilters(manualFilter).filter();
|
|
303
|
+
const result = await features.execute();
|
|
304
|
+
console.log(result);
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Example 3: Advanced Nested Populate with Array Input
|
|
308
|
+
|
|
309
|
+
```javascript
|
|
310
|
+
const populateArray = [
|
|
311
|
+
"brand", // Simple string input
|
|
312
|
+
{ path: "category", select: "name description" },
|
|
313
|
+
{ path: "category", select: "name", populate: { path: "subCategory", select: "title" } }
|
|
314
|
+
];
|
|
315
|
+
const features = new VantaApi(Product, req.query, "admin");
|
|
316
|
+
const result = await features.populate(populateArray).execute();
|
|
317
|
+
console.log(result);
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Example 4: Full Advanced Query Example
|
|
321
|
+
|
|
322
|
+
```http
|
|
323
|
+
GET /api/products?
|
|
324
|
+
page=1&
|
|
325
|
+
limit=10&
|
|
326
|
+
sort=-createdAt,price&
|
|
327
|
+
fields=name,price,category&
|
|
328
|
+
populate=category,brand&
|
|
329
|
+
price[gte]=1000&
|
|
330
|
+
category[in]=electronics,phones&
|
|
331
|
+
name[regex]=^Samsung
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
### Example 5: Using Default Pagination (when page and limit are not provided)
|
|
335
|
+
|
|
336
|
+
```javascript
|
|
337
|
+
// URL: /api/products?status=active
|
|
338
|
+
const features = new VantaApi(Product, req.query);
|
|
339
|
+
const result = await features
|
|
340
|
+
.filter() // Defaults to page 1 and limit 10
|
|
341
|
+
.execute();
|
|
342
|
+
console.log(result);
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## Summary
|
|
348
|
+
|
|
349
|
+
- **Filtering:**
|
|
350
|
+
Combines query parameters with manual filters and applies a safe `$match` with an automatic `isActive: true` condition for non-admin users.
|
|
351
|
+
- **Sorting:**
|
|
352
|
+
Converts a comma-separated string into a proper `$sort` object.
|
|
353
|
+
- **Field Selection:**
|
|
354
|
+
Uses `$project` to include only permitted fields while excluding forbidden fields.
|
|
355
|
+
- **Pagination:**
|
|
356
|
+
Applies `$skip` and `$limit` with default values (page 1, limit 10) and role-based limits.
|
|
357
|
+
- **Populate:**
|
|
358
|
+
Joins related documents using `$lookup` and `$unwind`, supporting nested and varied input types.
|
|
359
|
+
- **Security:**
|
|
360
|
+
Enforces allowed operators, sanitizes inputs, validates numeric fields, and removes dangerous operators via the security configuration.
|
|
361
|
+
- **Logging & Error Handling:**
|
|
362
|
+
Integrated advanced logging using winston and centralized error handling with a custom error class and middleware.
|
|
363
|
+
- **Performance Optimizations:**
|
|
364
|
+
Supports aggregation cursor for large datasets and optimizes aggregation pipelines for efficient resource usage.
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
VantaApi provides a complete solution for integrating powerful, secure, and customizable query capabilities into any Node.js/MongoDB project.
|
|
369
|
+
|
|
370
|
+
---
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// bin/create-security-config.js
|
|
2
|
+
import { existsSync, writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
|
|
5
|
+
// Define the target path for the security-config file.
|
|
6
|
+
// This example creates it in the current working directory.
|
|
7
|
+
const configPath = join(process.cwd(), 'security-config.js');
|
|
8
|
+
|
|
9
|
+
// If the file does not exist, create it with default content.
|
|
10
|
+
if (!existsSync(configPath)) {
|
|
11
|
+
const defaultConfig = `// Default Security Configuration
|
|
12
|
+
export const securityConfig = {
|
|
13
|
+
allowedOperators: [
|
|
14
|
+
"eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "regex", "exists", "size", "or", "and"
|
|
15
|
+
],
|
|
16
|
+
forbiddenFields: ["password"],
|
|
17
|
+
accessLevels: {
|
|
18
|
+
guest: { maxLimit: 50, allowedPopulate: ["*"] },
|
|
19
|
+
user: { maxLimit: 100, allowedPopulate: ["*"] },
|
|
20
|
+
admin: { maxLimit: 1000, allowedPopulate: ["*"] },
|
|
21
|
+
superAdmin: { maxLimit: 1000, allowedPopulate: ["*"] }
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
writeFileSync(configPath, defaultConfig);
|
|
27
|
+
console.log('Default security-config.js created in your project root. You can customize it as needed.');
|
|
28
|
+
} else {
|
|
29
|
+
console.log('security-config.js already exists. Using custom configuration.');
|
|
30
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vanta-api",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Advanced API features and security configuration for Node.js/MongoDB.",
|
|
5
|
+
"main": "src/api-features.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"postinstall": "node bin/create-security-config.js",
|
|
8
|
+
"test": "jest",
|
|
9
|
+
"start": "node src/api-features.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
|
+
"winston": "^3.0.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"jest": "^29.0.0"
|
|
27
|
+
},
|
|
28
|
+
"type": "module"
|
|
29
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// api-features.js
|
|
2
|
+
import mongoose from "mongoose";
|
|
3
|
+
import winston from "winston";
|
|
4
|
+
import { securityConfig } from "./config.js";
|
|
5
|
+
import HandleERROR from "./handleError.js";
|
|
6
|
+
|
|
7
|
+
// تنظیم logger با winston
|
|
8
|
+
const logger = winston.createLogger({
|
|
9
|
+
level: "info",
|
|
10
|
+
format: winston.format.combine(
|
|
11
|
+
winston.format.timestamp(),
|
|
12
|
+
winston.format.json()
|
|
13
|
+
),
|
|
14
|
+
transports: [new winston.transports.Console()]
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export class ApiFeatures {
|
|
18
|
+
constructor(model, query, userRole = "guest") {
|
|
19
|
+
this.Model = model;
|
|
20
|
+
this.query = { ...query };
|
|
21
|
+
this.userRole = userRole;
|
|
22
|
+
this.pipeline = [];
|
|
23
|
+
this.countPipeline = [];
|
|
24
|
+
this.manualFilters = {};
|
|
25
|
+
// انتخاب استفاده از cursor برای پردازش دادههای حجیم
|
|
26
|
+
this.useCursor = false;
|
|
27
|
+
this.#initialSanitization();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------- Core Methods ----------
|
|
31
|
+
filter() {
|
|
32
|
+
const queryFilters = this.#parseQueryFilters();
|
|
33
|
+
const mergedFilters = { ...queryFilters, ...this.manualFilters };
|
|
34
|
+
const safeFilters = this.#applySecurityFilters(mergedFilters);
|
|
35
|
+
|
|
36
|
+
if (Object.keys(safeFilters).length > 0) {
|
|
37
|
+
// اضافه کردن فیلتر به ابتدای pipeline جهت بهبود عملکرد
|
|
38
|
+
this.pipeline.push({ $match: safeFilters });
|
|
39
|
+
this.countPipeline.push({ $match: safeFilters });
|
|
40
|
+
}
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
sort() {
|
|
45
|
+
if (this.query.sort) {
|
|
46
|
+
const sortObject = this.query.sort.split(",").reduce((acc, field) => {
|
|
47
|
+
const [key, order] = field.startsWith("-")
|
|
48
|
+
? [field.slice(1), -1]
|
|
49
|
+
: [field, 1];
|
|
50
|
+
acc[key] = order;
|
|
51
|
+
return acc;
|
|
52
|
+
}, {});
|
|
53
|
+
this.pipeline.push({ $sort: sortObject });
|
|
54
|
+
}
|
|
55
|
+
return this;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
limitFields() {
|
|
59
|
+
if (this.query.fields) {
|
|
60
|
+
const allowedFields = this.query.fields
|
|
61
|
+
.split(",")
|
|
62
|
+
.filter(f => !securityConfig.forbiddenFields.includes(f))
|
|
63
|
+
.reduce((acc, curr) => ({ ...acc, [curr]: 1 }), {});
|
|
64
|
+
|
|
65
|
+
this.pipeline.push({ $project: allowedFields });
|
|
66
|
+
}
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
paginate() {
|
|
71
|
+
const { maxLimit } = securityConfig.accessLevels[this.userRole] || { maxLimit: 100 };
|
|
72
|
+
const page = Math.max(parseInt(this.query.page, 10) || 1, 1);
|
|
73
|
+
const limit = Math.min(
|
|
74
|
+
parseInt(this.query.limit, 10) || 10,
|
|
75
|
+
maxLimit
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
this.pipeline.push(
|
|
79
|
+
{ $skip: (page - 1) * limit },
|
|
80
|
+
{ $limit: limit }
|
|
81
|
+
);
|
|
82
|
+
return this;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
populate(input = "") {
|
|
86
|
+
let populateOptions = [];
|
|
87
|
+
|
|
88
|
+
if (Array.isArray(input)) {
|
|
89
|
+
input.forEach(item => {
|
|
90
|
+
if (typeof item === "object" && item.path) {
|
|
91
|
+
populateOptions.push(item);
|
|
92
|
+
} else if (typeof item === "string") {
|
|
93
|
+
populateOptions.push(item);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
} else if (typeof input === "object" && input.path) {
|
|
97
|
+
populateOptions.push(input);
|
|
98
|
+
} else if (typeof input === "string" && input.trim().length > 0) {
|
|
99
|
+
input.split(",").filter(Boolean).forEach(item => {
|
|
100
|
+
populateOptions.push(item.trim());
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (this.query.populate) {
|
|
105
|
+
this.query.populate.split(",").filter(Boolean).forEach(item => {
|
|
106
|
+
populateOptions.push(item.trim());
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const uniqueMap = new Map();
|
|
111
|
+
populateOptions.forEach(item => {
|
|
112
|
+
if (typeof item === "object" && item.path) {
|
|
113
|
+
uniqueMap.set(item.path, item);
|
|
114
|
+
} else if (typeof item === "string") {
|
|
115
|
+
uniqueMap.set(item, item);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
const uniquePopulateOptions = Array.from(uniqueMap.values());
|
|
119
|
+
|
|
120
|
+
uniquePopulateOptions.forEach(option => {
|
|
121
|
+
let field, projection = {};
|
|
122
|
+
if (typeof option === "object") {
|
|
123
|
+
field = option.path;
|
|
124
|
+
if (option.select) {
|
|
125
|
+
option.select.split(" ").forEach(fieldName => {
|
|
126
|
+
if (fieldName) projection[fieldName.trim()] = 1;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
} else if (typeof option === "string") {
|
|
130
|
+
field = option;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
field = field.trim();
|
|
134
|
+
const { collection, isArray } = this.#getCollectionInfo(field);
|
|
135
|
+
|
|
136
|
+
let lookupStage = {};
|
|
137
|
+
if (Object.keys(projection).length > 0) {
|
|
138
|
+
lookupStage = {
|
|
139
|
+
$lookup: {
|
|
140
|
+
from: collection,
|
|
141
|
+
let: { localField: `$${field}` },
|
|
142
|
+
pipeline: [
|
|
143
|
+
{
|
|
144
|
+
$match: {
|
|
145
|
+
$expr: { $eq: ["$_id", "$$localField"] }
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
{ $project: projection }
|
|
149
|
+
],
|
|
150
|
+
as: field
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
} else {
|
|
154
|
+
lookupStage = {
|
|
155
|
+
$lookup: {
|
|
156
|
+
from: collection,
|
|
157
|
+
localField: field,
|
|
158
|
+
foreignField: "_id",
|
|
159
|
+
as: field
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.pipeline.push(lookupStage);
|
|
165
|
+
this.pipeline.push({
|
|
166
|
+
$unwind: {
|
|
167
|
+
path: `$${field}`,
|
|
168
|
+
preserveNullAndEmptyArrays: true
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// پشتیبانی از nested populate: در صورت نیاز، منطق تو در تو را میتوانید اینجا اضافه کنید.
|
|
174
|
+
|
|
175
|
+
return this;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
addManualFilters(filters) {
|
|
179
|
+
if (filters) {
|
|
180
|
+
this.manualFilters = { ...this.manualFilters, ...filters };
|
|
181
|
+
}
|
|
182
|
+
return this;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async execute(options = {}) {
|
|
186
|
+
try {
|
|
187
|
+
// انتخاب حالت cursor در مواقع پردازش دادههای حجیم
|
|
188
|
+
if (options.useCursor === true) {
|
|
189
|
+
this.useCursor = true;
|
|
190
|
+
}
|
|
191
|
+
// اجرای موازی pipelineهای شمارش و داده
|
|
192
|
+
const [countResult, dataResult] = await Promise.all([
|
|
193
|
+
this.Model.aggregate([...this.countPipeline, { $count: "total" }]),
|
|
194
|
+
(this.useCursor
|
|
195
|
+
? this.Model.aggregate(this.pipeline).cursor({ batchSize: 100 }).exec()
|
|
196
|
+
: this.Model.aggregate(this.pipeline)
|
|
197
|
+
.allowDiskUse(options.allowDiskUse || false)
|
|
198
|
+
.readConcern("majority")
|
|
199
|
+
)
|
|
200
|
+
]);
|
|
201
|
+
|
|
202
|
+
const count = countResult[0]?.total || 0;
|
|
203
|
+
let data = [];
|
|
204
|
+
if (this.useCursor) {
|
|
205
|
+
const cursor = dataResult;
|
|
206
|
+
for await (const doc of cursor) {
|
|
207
|
+
data.push(doc);
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
data = dataResult;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
success: true,
|
|
215
|
+
count,
|
|
216
|
+
data
|
|
217
|
+
};
|
|
218
|
+
} catch (error) {
|
|
219
|
+
this.#handleError(error);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------- Security and Sanitization Methods ----------
|
|
224
|
+
#initialSanitization() {
|
|
225
|
+
["$where", "$accumulator", "$function"].forEach(op => {
|
|
226
|
+
delete this.query[op];
|
|
227
|
+
delete this.manualFilters[op];
|
|
228
|
+
});
|
|
229
|
+
["page", "limit"].forEach(field => {
|
|
230
|
+
if (this.query[field] && !/^\d+$/.test(this.query[field])) {
|
|
231
|
+
throw new HandleERROR(`Invalid value for ${field}`, 400);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#parseQueryFilters() {
|
|
237
|
+
const queryObj = { ...this.query };
|
|
238
|
+
["page", "limit", "sort", "fields", "populate"].forEach(el => delete queryObj[el]);
|
|
239
|
+
|
|
240
|
+
return JSON.parse(
|
|
241
|
+
JSON.stringify(queryObj)
|
|
242
|
+
.replace(/\b(gte|gt|lte|lt|in|nin|eq|ne|regex|exists|size)\b/g, "$$$&")
|
|
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
|
+
}
|
|
258
|
+
|
|
259
|
+
#sanitizeNestedObjects(obj) {
|
|
260
|
+
return Object.entries(obj).reduce((acc, [key, value]) => {
|
|
261
|
+
if (typeof value === "object" && !Array.isArray(value)) {
|
|
262
|
+
acc[key] = this.#sanitizeNestedObjects(value);
|
|
263
|
+
} else {
|
|
264
|
+
acc[key] = this.#sanitizeValue(key, value);
|
|
265
|
+
}
|
|
266
|
+
return acc;
|
|
267
|
+
}, {});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#sanitizeValue(key, value) {
|
|
271
|
+
if (key.endsWith("Id") && mongoose.isValidObjectId(value)) {
|
|
272
|
+
return new mongoose.Types.ObjectId(value);
|
|
273
|
+
}
|
|
274
|
+
if (typeof value === "string") {
|
|
275
|
+
if (value === "true") return true;
|
|
276
|
+
if (value === "false") return false;
|
|
277
|
+
if (/^\d+$/.test(value)) return parseInt(value);
|
|
278
|
+
}
|
|
279
|
+
return value;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#getCollectionInfo(field) {
|
|
283
|
+
const schemaPath = this.Model.schema.path(field);
|
|
284
|
+
if (!schemaPath?.options?.ref) {
|
|
285
|
+
throw new HandleERROR(`Invalid populate field: ${field}`, 400);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const refModel = mongoose.model(schemaPath.options.ref);
|
|
289
|
+
if (refModel.schema.options.restricted && this.userRole !== "admin") {
|
|
290
|
+
throw new HandleERROR(`Unauthorized to populate ${field}`, 403);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
collection: refModel.collection.name,
|
|
295
|
+
isArray: schemaPath.instance === "Array"
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#handleError(error) {
|
|
300
|
+
// ثبت خطا در logger همراه با stack trace
|
|
301
|
+
logger.error(`[API Features Error]: ${error.message}`, { stack: error.stack });
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export default ApiFeatures;
|
package/src/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const securityConfig = {
|
|
2
|
+
allowedOperators: [
|
|
3
|
+
"eq", "ne", "gt", "gte",
|
|
4
|
+
"lt", "lte", "in", "nin",
|
|
5
|
+
"regex", "exists", "size","or","and"
|
|
6
|
+
],
|
|
7
|
+
|
|
8
|
+
forbiddenFields: [
|
|
9
|
+
"password",
|
|
10
|
+
],
|
|
11
|
+
|
|
12
|
+
accessLevels: {
|
|
13
|
+
guest: {
|
|
14
|
+
maxLimit: 50,
|
|
15
|
+
allowedPopulate: ["*"]
|
|
16
|
+
},
|
|
17
|
+
user: {
|
|
18
|
+
maxLimit: 100,
|
|
19
|
+
allowedPopulate: ["*"]
|
|
20
|
+
},
|
|
21
|
+
admin: {
|
|
22
|
+
maxLimit: 1000,
|
|
23
|
+
allowedPopulate: ["*"]
|
|
24
|
+
},
|
|
25
|
+
superAdmin: {
|
|
26
|
+
maxLimit: 1000,
|
|
27
|
+
allowedPopulate: ["*"]
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
}
|
|
31
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class HandleERROR extends Error{
|
|
2
|
+
constructor(message,statusCode){
|
|
3
|
+
super(message)
|
|
4
|
+
this.statusCode=statusCode
|
|
5
|
+
this.status=`${statusCode}`.startsWith('4')?'fail':'error'
|
|
6
|
+
this.isOperational=true
|
|
7
|
+
Error.captureStackTrace(this,this.constructor)
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export default HandleERROR
|
|
File without changes
|