vanta-api 1.1.0 → 1.1.2

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 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
- # 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. [ApiFeatures Class Methods](#ApiFeatures-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 **ApiFeatures** 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
- ## ApiFeatures 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 ApiFeatures(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 ApiFeatures(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 ApiFeatures(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 ApiFeatures(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 ApiFeatures(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 ApiFeatures(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 ApiFeatures(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 ApiFeatures(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 ApiFeatures(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
- Vanta-API provides a centralized error handling system featuring three components:
262
-
263
- ### 1. handleError
264
-
265
- **Purpose:**
266
- A custom error class that extends the native JavaScript `Error`.
267
- It adds:
268
- - **`statusCode`:** HTTP status code.
269
- - **`status`:** Determines if the error is a `"fail"` (client error) or `"error"` (server error).
270
- - **`isOperational`:** Flags if the error is an expected operational error.
271
- - **Stack Trace:** Captures the call stack for easier debugging.
272
-
273
- **Usage Example in an Async Function:**
274
-
275
- Inside an asynchronous function wrapped by **catchAsync**, you can use:
276
-
277
- ```javascript
278
- // Inside your async route handler
279
- if (someConditionFails) {
280
- return next(new handleError("Custom error message", 400));
281
- }
282
- ```
283
-
284
- ### 2. catchAsync
285
-
286
- **Purpose:**
287
- A helper function that wraps asynchronous route handlers.
288
- It automatically catches any thrown errors and forwards them via `next()`, avoiding repetitive try/catch blocks.
289
-
290
- **Usage Example:**
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
+ ![npm](https://img.shields.io/npm/v/vanta-api) ![license](https://img.shields.io/github/license/yourusername/vanta-api) ![downloads](https://img.shields.io/npm/dm/vanta-api)
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.0",
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
- "winston": "^3.0.0"
24
- },
25
- "devDependencies": {
26
- "jest": "^29.0.0"
27
- },
28
- "type": "module"
29
- }
1
+ {
2
+ "name": "vanta-api",
3
+ "version": "1.1.2",
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
+ }
@@ -1,307 +1,234 @@
1
1
  import mongoose from "mongoose";
2
2
  import winston from "winston";
3
- import { securityConfig } from "./config.js";
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(
9
11
  winston.format.timestamp(),
10
12
  winston.format.json()
11
13
  ),
12
- transports: [new winston.transports.Console()]
14
+ transports: [new winston.transports.Console()],
13
15
  });
14
16
 
15
17
  export class ApiFeatures {
16
- constructor(model, query, userRole = "guest") {
17
- this.Model = model;
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
- this.#initialSanitization();
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
- if (Object.keys(safeFilters).length > 0) {
34
- this.pipeline.push({ $match: safeFilters });
35
- this.countPipeline.push({ $match: safeFilters });
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
- const sortObject = this.query.sort.split(",").reduce((acc, field) => {
43
- const [key, order] = field.startsWith("-")
44
- ? [field.slice(1), -1]
45
- : [field, 1];
46
- acc[key] = order;
47
- return acc;
48
- }, {});
49
- this.pipeline.push({ $sort: sortObject });
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
- const allowedFields = this.query.fields
57
- .split(",")
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
- this.pipeline.push({ $project: allowedFields });
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
77
  const { maxLimit } = securityConfig.accessLevels[this.userRole] || { maxLimit: 100 };
68
78
  const page = Math.max(parseInt(this.query.page, 10) || 1, 1);
69
- const limit = Math.min(
70
- parseInt(this.query.limit, 10) || 10,
71
- maxLimit
72
- );
73
-
74
- this.pipeline.push(
75
- { $skip: (page - 1) * limit },
76
- { $limit: limit }
77
- );
79
+ const lim = Math.min(Math.max(parseInt(this.query.limit, 10) || 10, 1), maxLimit);
80
+
81
+ this.pipeline.push({ $skip: (page - 1) * lim }, { $limit: lim });
78
82
  return this;
79
83
  }
80
84
 
81
85
  populate(input = "") {
82
- let populateOptions = [];
83
-
84
- if (Array.isArray(input)) {
85
- input.forEach(item => {
86
- if (typeof item === "object" && item.path) {
87
- populateOptions.push(item);
88
- } else if (typeof item === "string") {
89
- populateOptions.push(item);
90
- }
91
- });
92
- } else if (typeof input === "object" && input.path) {
93
- populateOptions.push(input);
94
- } else if (typeof input === "string" && input.trim().length > 0) {
95
- input.split(",").filter(Boolean).forEach(item => {
96
- populateOptions.push(item.trim());
97
- });
98
- }
99
-
100
- if (this.query.populate) {
101
- this.query.populate.split(",").filter(Boolean).forEach(item => {
102
- populateOptions.push(item.trim());
103
- });
104
- }
105
-
106
- const uniqueMap = new Map();
107
- populateOptions.forEach(item => {
108
- if (typeof item === "object" && item.path) {
109
- uniqueMap.set(item.path, item);
110
- } else if (typeof item === "string") {
111
- uniqueMap.set(item, item);
112
- }
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);
113
94
  });
114
- const uniquePopulateOptions = Array.from(uniqueMap.values());
115
-
116
- uniquePopulateOptions.forEach(option => {
117
- let field, projection = {};
118
- if (typeof option === "object") {
119
- field = option.path;
120
- if (option.select) {
121
- option.select.split(" ").forEach(fieldName => {
122
- if (fieldName) projection[fieldName.trim()] = 1;
123
- });
124
- }
125
- } else if (typeof option === "string") {
126
- field = option;
127
- }
128
-
129
- field = field.trim();
130
- const { collection, isArray } = this.#getCollectionInfo(field);
131
-
132
- let lookupStage = {};
133
- if (Object.keys(projection).length > 0) {
134
- lookupStage = {
135
- $lookup: {
136
- from: collection,
137
- let: { localId: `$${field}` },
138
- pipeline: [
139
- {
140
- $match: {
141
- $expr: { $eq: ["$_id", "$$localId"] }
142
- }
143
- },
144
- { $project: projection }
145
- ],
146
- as: field
147
- }
148
- };
149
- } else {
150
- lookupStage = {
151
- $lookup: {
152
- from: collection,
153
- localField: field,
154
- foreignField: "_id",
155
- as: field
156
- }
157
- };
158
- }
159
-
160
- this.pipeline.push(lookupStage);
161
- this.pipeline.push({
162
- $unwind: {
163
- path: `$${field}`,
164
- preserveNullAndEmptyArrays: true
165
- }
166
- });
95
+
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
+ });
102
+
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);
167
108
  });
168
-
109
+
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
+ : {};
116
+
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
+ }
125
+
169
126
  return this;
170
127
  }
171
-
128
+
172
129
  addManualFilters(filters) {
173
- if (filters) {
174
- this.manualFilters = { ...this.manualFilters, ...filters };
175
- }
130
+ if (filters) this.manualFilters = { ...this.manualFilters, ...filters };
176
131
  return this;
177
132
  }
178
133
 
179
134
  async execute(options = {}) {
180
135
  try {
181
- if (options.useCursor === true) {
182
- this.useCursor = true;
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);
183
140
  }
184
- const [countResult, dataResult] = await Promise.all([
185
- this.Model.aggregate([...this.countPipeline, { $count: "total" }]),
186
- (this.useCursor
187
- ? this.Model.aggregate(this.pipeline).cursor({ batchSize: 100 }).exec()
188
- : this.Model.aggregate(this.pipeline)
189
- .allowDiskUse(options.allowDiskUse || false)
190
- .readConcern("majority")
191
- )
192
- ]);
193
-
194
- const count = countResult[0]?.total || 0;
195
- let data = [];
196
- if (this.useCursor) {
197
- const cursor = dataResult;
198
- for await (const doc of cursor) {
199
- data.push(doc);
200
- }
201
- } else {
202
- data = dataResult;
141
+
142
+ const agg = this.model.aggregate(this.pipeline)
143
+ .maxTimeMS(options.maxTimeMS || 10000);
144
+
145
+ const [cnt] = await this.model.aggregate([...this.countPipeline, { $count: 'total' }]);
146
+ const cursorOrData = this.useCursor
147
+ ? agg.cursor({ batchSize: 100 }).exec()
148
+ : agg.allowDiskUse(options.allowDiskUse || false).readConcern('majority');
149
+
150
+ const data = this.useCursor
151
+ ? await cursorOrData.toArray()
152
+ : await cursorOrData;
153
+
154
+ const result = { success: true, count: cnt?.total || 0, data };
155
+ if (options.projection) {
156
+ result.data = result.data.map(doc => {
157
+ const projDoc = {};
158
+ Object.keys(options.projection).forEach(f => {
159
+ if (options.projection[f]) projDoc[f] = doc[f];
160
+ });
161
+ return projDoc;
162
+ });
203
163
  }
204
-
205
- return {
206
- success: true,
207
- count,
208
- data
209
- };
210
- } catch (error) {
211
- this.#handleError(error);
164
+ return result;
165
+ } catch (err) {
166
+ this._handleError(err);
212
167
  }
213
168
  }
214
169
 
215
- // ---------- Security and Sanitization Methods ----------
216
- #initialSanitization() {
217
- ["$where", "$accumulator", "$function"].forEach(op => {
170
+ // ---------- Private Helpers ----------
171
+
172
+ _sanitization() {
173
+ // Remove unsafe ops
174
+ ['$', '$where', '$accumulator', '$function'].forEach(op => {
218
175
  delete this.query[op];
219
- delete this.manualFilters[op];
220
176
  });
221
- ["page", "limit"].forEach(field => {
222
- if (this.query[field] && !/^\d+$/.test(this.query[field])) {
223
- throw new HandleERROR(`Invalid value for ${field}`, 400);
177
+ // Validate numeric
178
+ ['page','limit'].forEach(f => {
179
+ if (this.query[f] && !/^[0-9]+$/.test(this.query[f])) {
180
+ throw new HandleERROR(`Invalid ${f}`,400);
224
181
  }
225
182
  });
226
183
  }
227
184
 
228
- #parseQueryFilters() {
229
- const queryObj = { ...this.query };
230
- ["page", "limit", "sort", "fields", "populate"].forEach(el => delete queryObj[el]);
231
-
232
- return JSON.parse(
233
- JSON.stringify(queryObj)
234
- .replace(/\b(gte|gt|lte|lt|in|nin|eq|ne|regex|exists|size)\b/g, "$$$&")
235
- );
236
- }
237
-
238
- #applySecurityFilters(filters) {
239
- let result = { ...filters };
240
-
241
- securityConfig.forbiddenFields.forEach(field => delete result[field]);
242
-
243
- if (this.userRole !== "admin" && this.Model.schema.path("isActive")) {
244
- result.isActive = true;
245
- result = this.#sanitizeNestedObjects(result);
246
- }
247
-
248
- return result;
249
- }
185
+ _parseQueryFilters() {
186
+ const obj = { ...this.query };
187
+ ['page','limit','sort','fields','populate'].forEach(k => delete obj[k]);
250
188
 
251
- #sanitizeNestedObjects(obj) {
252
- return Object.entries(obj).reduce((acc, [key, value]) => {
253
- // Handle ObjectId fields with nested operators
254
- if (key.endsWith("Id") && typeof value === "object" && !Array.isArray(value)) {
255
- const sanitizedObj = {};
256
- for (const [op, val] of Object.entries(value)) {
257
- if (["$eq", "$ne", "$gt", "$gte", "$lt", "$lte"].includes(op) && mongoose.isValidObjectId(val)) {
258
- sanitizedObj[op] = new mongoose.Types.ObjectId(val);
259
- } else if (["$in", "$nin"].includes(op) && Array.isArray(val)) {
260
- sanitizedObj[op] = val
261
- .filter(v => mongoose.isValidObjectId(v))
262
- .map(v => new mongoose.Types.ObjectId(v));
263
- } else {
264
- sanitizedObj[op] = val;
265
- }
266
- }
267
- acc[key] = sanitizedObj;
268
- } else if (typeof value === "object" && !Array.isArray(value)) {
269
- acc[key] = this.#sanitizeNestedObjects(value);
189
+ // Whitelist operators
190
+ const out = {};
191
+ for (const [k,v] of Object.entries(obj)) {
192
+ if (['or','and'].includes(k)) {
193
+ out[`$${k}`] = Array.isArray(v) ? v : [v];
270
194
  } else {
271
- acc[key] = this.#sanitizeValue(key, value);
195
+ out[k] = typeof v === 'string' && v.includes(',')
196
+ ? v.split(',')
197
+ : v;
272
198
  }
273
- return acc;
274
- }, {});
199
+ }
200
+ return out;
275
201
  }
276
202
 
277
- #sanitizeValue(key, value) {
278
- if (key.endsWith("Id") && mongoose.isValidObjectId(value)) {
279
- return new mongoose.Types.ObjectId(value);
280
- }
281
- if (typeof value === "string") {
282
- if (value === "true") return true;
283
- if (value === "false") return false;
284
- if (/^\d+$/.test(value)) return parseInt(value, 10);
285
- }
286
- return value;
203
+ _sanitizeFilters(filters) {
204
+ // Simple deep clone with ObjectId and boolean parsing
205
+ return JSON.parse(JSON.stringify(filters), (key,val) => {
206
+ if (key.endsWith('Id') && mongoose.isValidObjectId(val)) return new mongoose.Types.ObjectId(val);
207
+ if (val === 'true') return true;
208
+ if (val === 'false') return false;
209
+ if (/^[0-9]+$/.test(val)) return parseInt(val,10);
210
+ return val;
211
+ });
287
212
  }
288
213
 
289
- #getCollectionInfo(field) {
290
- const schemaPath = this.Model.schema.path(field);
291
- if (!schemaPath?.options?.ref) {
292
- throw new HandleERROR(`Invalid populate field: ${field}`, 400);
214
+ _applySecurityFilters(filters) {
215
+ let res = { ...filters };
216
+ securityConfig.forbiddenFields.forEach(f => delete res[f]);
217
+ if (this.userRole !== 'admin' && this.model.schema.path('isActive')) {
218
+ res.isActive = true;
293
219
  }
220
+ return res;
221
+ }
294
222
 
295
- return {
296
- collection: schemaPath.options.ref.toLowerCase()+'s',
297
- isArray: schemaPath.instance === "Array"
298
- };
223
+ _getCollectionInfo(field) {
224
+ const path = this.model.schema.path(field);
225
+ if (!path?.options?.ref) throw new HandleERROR(`Invalid populate: ${field}`,400);
226
+ return { collection: pluralize(path.options.ref), isArray: path.instance==='Array' };
299
227
  }
300
228
 
301
- #handleError(error) {
302
- // ثبت خطا در logger همراه با stack trace
303
- logger.error(`[API Features Error]: ${error.message}`, { stack: error.stack });
304
- throw error;
229
+ _handleError(err) {
230
+ logger.error(`[ApiFeatures] ${err.message}`, { stack: err.stack });
231
+ throw err;
305
232
  }
306
233
  }
307
234