vanta-api 1.1.1 → 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.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
- "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,8 +1,10 @@
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(
@@ -13,338 +15,220 @@ const logger = winston.createLogger({
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
- 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 limit = Math.min(parseInt(this.query.limit, 10) || 10, maxLimit);
79
+ const lim = Math.min(Math.max(parseInt(this.query.limit, 10) || 10, 1), maxLimit);
72
80
 
73
- this.pipeline.push({ $skip: (page - 1) * limit }, { $limit: limit });
81
+ this.pipeline.push({ $skip: (page - 1) * lim }, { $limit: lim });
74
82
  return this;
75
83
  }
76
84
 
77
85
  populate(input = "") {
78
- let populateOptions = [];
79
-
80
- if (Array.isArray(input)) {
81
- input.forEach((item) => {
82
- if (typeof item === "object" && item.path) {
83
- populateOptions.push(item);
84
- } else if (typeof item === "string") {
85
- populateOptions.push(item);
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
- if (this.query.populate) {
100
- this.query.populate
101
- .split(",")
102
- .filter(Boolean)
103
- .forEach((item) => {
104
- populateOptions.push(item.trim());
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
- const uniqueMap = new Map();
109
- populateOptions.forEach((item) => {
110
- if (typeof item === "object" && item.path) {
111
- uniqueMap.set(item.path, item);
112
- } else if (typeof item === "string") {
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
- field = field.trim();
133
- const { collection, isArray } = this.#getCollectionInfo(field);
134
-
135
- let lookupStage = {};
136
- if (Object.keys(projection).length > 0) {
137
- lookupStage = {
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.pipeline.push(lookupStage);
164
- this.pipeline.push({
165
- $unwind: {
166
- path: `$${field}`,
167
- preserveNullAndEmptyArrays: true,
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 === true) {
185
- this.useCursor = true;
186
- }
187
- const [countResult, dataResult] = await Promise.all([
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
- return {
210
- success: true,
211
- count,
212
- data,
213
- };
214
- } catch (error) {
215
- this.#handleError(error);
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
+ });
163
+ }
164
+ return result;
165
+ } catch (err) {
166
+ this._handleError(err);
216
167
  }
217
168
  }
218
169
 
219
- // ---------- Security and Sanitization Methods ----------
220
- #initialSanitization() {
221
- ["$where", "$accumulator", "$function"].forEach((op) => {
170
+ // ---------- Private Helpers ----------
171
+
172
+ _sanitization() {
173
+ // Remove unsafe ops
174
+ ['$', '$where', '$accumulator', '$function'].forEach(op => {
222
175
  delete this.query[op];
223
- delete this.manualFilters[op];
224
176
  });
225
- ["page", "limit"].forEach((field) => {
226
- if (this.query[field] && !/^\d+$/.test(this.query[field])) {
227
- 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);
228
181
  }
229
182
  });
230
183
  }
231
184
 
232
- #parseQueryFilters() {
233
- const queryObj = { ...this.query };
234
- ["page", "limit", "sort", "fields", "populate"].forEach(
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
- }
185
+ _parseQueryFilters() {
186
+ const obj = { ...this.query };
187
+ ['page','limit','sort','fields','populate'].forEach(k => delete obj[k]);
255
188
 
256
- return result;
257
- }
258
-
259
- #sanitizeNestedObjects(obj) {
260
- return Object.entries(obj).reduce((acc, [key, value]) => {
261
- // Handle ObjectId fields with nested operators
262
- if (
263
- key.endsWith("Id") &&
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);
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];
285
194
  } else {
286
- acc[key] = this.#sanitizeValue(key, value);
195
+ out[k] = typeof v === 'string' && v.includes(',')
196
+ ? v.split(',')
197
+ : v;
287
198
  }
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
199
  }
301
- return value;
200
+ return out;
302
201
  }
303
202
 
304
- #isVowel(char) {
305
- return ["a", "e", "i", "o", "u"].includes(char.toLowerCase());
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
+ });
306
212
  }
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
213
 
319
- if (
320
- lastTwoChars === "ch" ||
321
- lastTwoChars === "sh" ||
322
- lastChar === "s" ||
323
- lastChar === "x" ||
324
- lastChar === "z"
325
- ) {
326
- return lowerName + "es";
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;
327
219
  }
328
-
329
- return lowerName + "s";
220
+ return res;
330
221
  }
331
- #getCollectionInfo(field) {
332
- const schemaPath = this.Model.schema.path(field);
333
- if (!schemaPath?.options?.ref) {
334
- throw new HandleERROR(`Invalid populate field: ${field}`, 400);
335
- }
336
- return {
337
- collection: this.#getCollectionName(schemaPath?.options?.ref),
338
- isArray: schemaPath.instance === "Array",
339
- };
222
+
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' };
340
227
  }
341
228
 
342
- #handleError(error) {
343
- // ثبت خطا در logger همراه با stack trace
344
- logger.error(`[API Features Error]: ${error.message}`, {
345
- stack: error.stack,
346
- });
347
- throw error;
229
+ _handleError(err) {
230
+ logger.error(`[ApiFeatures] ${err.message}`, { stack: err.stack });
231
+ throw err;
348
232
  }
349
233
  }
350
234