vanta-api 1.2.0 → 1.2.1
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/README.md +264 -191
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,290 +1,363 @@
|
|
|
1
|
-
# VantaApi
|
|
2
|
-
|
|
3
|
-
  
|
|
1
|
+
# VantaApi :: Advanced MongoDB API Utilities
|
|
4
2
|
|
|
5
3
|
**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
4
|
|
|
7
5
|
---
|
|
8
6
|
|
|
9
|
-
##
|
|
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
|
|
7
|
+
## 📦 Installation
|
|
33
8
|
|
|
34
|
-
|
|
9
|
+
Install via npm or Yarn:
|
|
35
10
|
|
|
36
11
|
```bash
|
|
37
12
|
npm install vanta-api
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
or
|
|
41
|
-
|
|
42
|
-
```bash
|
|
13
|
+
# or
|
|
43
14
|
yarn add vanta-api
|
|
44
15
|
```
|
|
45
16
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
```bash
|
|
49
|
-
npm install mongoose winston pluralize
|
|
50
|
-
```
|
|
17
|
+
A `postinstall` hook will scaffold a `security-config.js` file in your project root.
|
|
51
18
|
|
|
52
19
|
---
|
|
53
20
|
|
|
54
|
-
##
|
|
21
|
+
## ⚙️ Setup & Initialization
|
|
55
22
|
|
|
56
|
-
|
|
23
|
+
### 1. Importing the Package
|
|
57
24
|
|
|
58
|
-
|
|
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
|
-
],
|
|
25
|
+
#### ECMAScript Module (ESM)
|
|
64
26
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
};
|
|
27
|
+
```js
|
|
28
|
+
import express from 'express';
|
|
29
|
+
import mongoose from 'mongoose';
|
|
30
|
+
import ApiFeatures, { catchAsync, catchError, HandleERROR } from 'vanta-api';
|
|
79
31
|
```
|
|
80
32
|
|
|
81
|
-
|
|
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
|
|
33
|
+
#### CommonJS (CJS)
|
|
91
34
|
|
|
92
35
|
```js
|
|
93
|
-
|
|
36
|
+
const express = require('express');
|
|
37
|
+
const mongoose = require('mongoose');
|
|
38
|
+
const { default: ApiFeatures, catchAsync, catchError, HandleERROR } = require('vanta-api');
|
|
94
39
|
```
|
|
95
40
|
|
|
96
|
-
### 2.
|
|
41
|
+
### 2. Basic Server Setup
|
|
97
42
|
|
|
98
43
|
```js
|
|
99
|
-
|
|
100
|
-
|
|
44
|
+
const app = express();
|
|
45
|
+
app.use(express.json());
|
|
46
|
+
|
|
47
|
+
// Connect to MongoDB
|
|
48
|
+
mongoose.connect(process.env.MONGO_URI, {
|
|
49
|
+
useNewUrlParser: true,
|
|
50
|
+
useUnifiedTopology: true
|
|
51
|
+
});
|
|
52
|
+
```
|
|
101
53
|
|
|
102
|
-
|
|
54
|
+
### 3. Route Definition & Error Handling
|
|
103
55
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
56
|
+
```js
|
|
57
|
+
// Example route with async handler
|
|
58
|
+
app.get(
|
|
59
|
+
'/api/v1/items',
|
|
60
|
+
catchAsync(async (req, res, next) => {
|
|
61
|
+
const result = await new ApiFeatures(ItemModel, req.query, req.user.role)
|
|
109
62
|
.filter()
|
|
110
63
|
.sort()
|
|
111
64
|
.limitFields()
|
|
112
65
|
.paginate()
|
|
113
66
|
.populate()
|
|
114
|
-
.execute(
|
|
115
|
-
|
|
116
|
-
res
|
|
117
|
-
.set('X-Total-Count', result.count)
|
|
118
|
-
.status(200)
|
|
119
|
-
.json(result);
|
|
67
|
+
.execute();
|
|
68
|
+
res.status(200).json(result);
|
|
120
69
|
})
|
|
121
70
|
);
|
|
122
71
|
|
|
123
|
-
// Global error handler
|
|
72
|
+
// Global error handler (after all routes)
|
|
124
73
|
app.use(catchError);
|
|
74
|
+
|
|
75
|
+
// Start server
|
|
76
|
+
app.listen(3000, () => {
|
|
77
|
+
console.log('Server running on port 3000');
|
|
78
|
+
});
|
|
125
79
|
```
|
|
126
80
|
|
|
127
|
-
|
|
81
|
+
---
|
|
128
82
|
|
|
129
|
-
|
|
83
|
+
## 🔍 API Reference
|
|
130
84
|
|
|
131
|
-
|
|
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
|
-
```
|
|
85
|
+
### 1. catchAsync(fn)
|
|
142
86
|
|
|
143
|
-
|
|
87
|
+
Wraps an async function to catch errors and pass them to Express error middleware.
|
|
144
88
|
|
|
145
|
-
|
|
89
|
+
* **Signature:** `catchAsync(fn: Function): Function`
|
|
146
90
|
|
|
147
|
-
|
|
91
|
+
* **Example:**
|
|
148
92
|
|
|
149
|
-
|
|
150
|
-
|
|
93
|
+
```js
|
|
94
|
+
app.post(
|
|
95
|
+
'/api/v1/users',
|
|
96
|
+
catchAsync(async (req, res) => {
|
|
97
|
+
// async logic
|
|
98
|
+
})
|
|
99
|
+
);
|
|
100
|
+
```
|
|
151
101
|
|
|
152
|
-
|
|
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`.
|
|
102
|
+
### 2. catchError(err, req, res, next)
|
|
157
103
|
|
|
158
|
-
|
|
104
|
+
Express error-handling middleware that returns standardized JSON errors.
|
|
159
105
|
|
|
160
|
-
* **
|
|
161
|
-
* **Behavior**:
|
|
106
|
+
* **Response:**
|
|
162
107
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"status": "error" | "fail",
|
|
111
|
+
"message": "Error description",
|
|
112
|
+
"errors": [ /* optional array of details */ ]
|
|
113
|
+
}
|
|
114
|
+
```
|
|
168
115
|
|
|
169
|
-
|
|
116
|
+
* **Usage:** Place after all routes
|
|
170
117
|
|
|
171
|
-
|
|
172
|
-
* **Behavior**:
|
|
118
|
+
### 3. HandleERROR
|
|
173
119
|
|
|
174
|
-
|
|
175
|
-
2. Exclude `forbiddenFields`.
|
|
176
|
-
3. Validate against schema paths.
|
|
177
|
-
4. Push `{ $project: {...} }`.
|
|
178
|
-
* **Returns**: `this`.
|
|
120
|
+
Custom `Error` subclass for operational errors.
|
|
179
121
|
|
|
180
|
-
|
|
122
|
+
* **Constructor:** `new HandleERROR(message: string, statusCode: number)`
|
|
123
|
+
* **Example:**
|
|
181
124
|
|
|
182
|
-
|
|
183
|
-
|
|
125
|
+
```js
|
|
126
|
+
if (!user) {
|
|
127
|
+
throw new HandleERROR('User not found', 404);
|
|
128
|
+
}
|
|
129
|
+
```
|
|
184
130
|
|
|
185
|
-
|
|
186
|
-
2. Enforce `maxLimit` per role.
|
|
187
|
-
3. Push `{ $skip }` and `{ $limit }`.
|
|
188
|
-
* **Returns**: `this`.
|
|
131
|
+
---
|
|
189
132
|
|
|
190
|
-
|
|
133
|
+
## 🚀 ApiFeatures Class
|
|
191
134
|
|
|
192
|
-
|
|
193
|
-
* **Input Types**: `string`, `{ path, select, populate? }`, `array`
|
|
194
|
-
* **Behavior**:
|
|
135
|
+
Chainable class that translates HTTP query parameters into a secure MongoDB aggregation pipeline.
|
|
195
136
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
137
|
+
```js
|
|
138
|
+
const features = new ApiFeatures(
|
|
139
|
+
Model, // Mongoose model
|
|
140
|
+
req.query, // HTTP query object
|
|
141
|
+
req.user.role // User role for security (guest|user|admin|superAdmin)
|
|
142
|
+
)
|
|
143
|
+
.filter() // Filtering
|
|
144
|
+
.sort() // Sorting
|
|
145
|
+
.limitFields() // Field limiting
|
|
146
|
+
.paginate() // Pagination
|
|
147
|
+
.populate() // Population
|
|
148
|
+
.addManualFilters({ isActive: true }) // Manual filters
|
|
149
|
+
;
|
|
150
|
+
const result = await features.execute({ allowDiskUse: true });
|
|
151
|
+
```
|
|
199
152
|
|
|
200
|
-
|
|
201
|
-
* Build `$lookup` with optional `pipeline` for projections.
|
|
202
|
-
* Add `$unwind` preserving nulls.
|
|
203
|
-
* **Returns**: `this`.
|
|
153
|
+
### Constructor
|
|
204
154
|
|
|
205
|
-
|
|
155
|
+
```ts
|
|
156
|
+
new ApiFeatures(
|
|
157
|
+
model: mongoose.Model,
|
|
158
|
+
queryParams: Record<string, any> = {},
|
|
159
|
+
userRole: string = 'guest'
|
|
160
|
+
)
|
|
161
|
+
```
|
|
206
162
|
|
|
207
|
-
* **
|
|
208
|
-
* **
|
|
209
|
-
* **
|
|
163
|
+
* **model**: Mongoose model.
|
|
164
|
+
* **queryParams**: Typically `req.query`.
|
|
165
|
+
* **userRole**: Role key for security rules.
|
|
166
|
+
|
|
167
|
+
### Chainable Methods
|
|
168
|
+
|
|
169
|
+
| Method | Description |
|
|
170
|
+
| ------------------------ | ---------------------------------------------------------------------------------------------------- |
|
|
171
|
+
| `.filter()` | Applies MongoDB operators. Supported operators: |
|
|
172
|
+
| | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `regex` (e.g. `?price[gt]=100&name[regex]=Book`). |
|
|
173
|
+
| `.sort()` | Sorting (e.g. `?sort=price,-name`). |
|
|
174
|
+
| `.limitFields()` | Field selection (e.g. `?fields=name,price`). |
|
|
175
|
+
| `.paginate()` | Pagination (e.g. `?page=2&limit=10`). |
|
|
176
|
+
| `.populate(paths?)` | Populate referenced documents. Accepts various input types (see below). |
|
|
177
|
+
| `.addManualFilters(obj)` | Add programmatic filters (e.g. `.addManualFilters({ isActive: true })`). |
|
|
178
|
+
|
|
179
|
+
> All methods return `this` for chaining.
|
|
180
|
+
|
|
181
|
+
#### Populate Input Formats
|
|
182
|
+
|
|
183
|
+
The `.populate()` method supports multiple ways to specify which paths to populate:
|
|
184
|
+
|
|
185
|
+
1. **String (comma-separated)**
|
|
186
|
+
|
|
187
|
+
```js
|
|
188
|
+
.populate('author,comments')
|
|
189
|
+
```
|
|
190
|
+
2. **Array of strings**
|
|
191
|
+
|
|
192
|
+
```js
|
|
193
|
+
.populate(['author', 'comments'])
|
|
194
|
+
```
|
|
195
|
+
3. **Dot notation for nested paths**
|
|
196
|
+
|
|
197
|
+
```js
|
|
198
|
+
// Populating nested field 'comments.author'
|
|
199
|
+
.populate('comments.author')
|
|
200
|
+
```
|
|
201
|
+
4. **Object with options**
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
.populate({
|
|
205
|
+
path: 'author', // field to populate
|
|
206
|
+
select: 'name email', // include only name and email
|
|
207
|
+
match: { isActive: true}, // only active authors
|
|
208
|
+
options: { limit: 5 } // limit populated docs
|
|
209
|
+
})
|
|
210
|
+
```
|
|
211
|
+
5. **Array of objects**
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
.populate([
|
|
215
|
+
{ path: 'author', select: 'name' },
|
|
216
|
+
{ path: 'comments', match: { flagged: false } }
|
|
217
|
+
])
|
|
218
|
+
```
|
|
210
219
|
|
|
211
|
-
|
|
220
|
+
---
|
|
212
221
|
|
|
213
|
-
|
|
214
|
-
* **Options**:
|
|
222
|
+
### execute(options)
|
|
215
223
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
* `maxTimeMS` (number): Timeout for aggregation.
|
|
219
|
-
* `debug` (boolean): Log the pipeline.
|
|
220
|
-
* `projection` (object): Final projection on returned documents.
|
|
221
|
-
* **Behavior**:
|
|
224
|
+
Executes aggregation pipeline.
|
|
225
|
+
(options)
|
|
222
226
|
|
|
223
|
-
|
|
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 }`.
|
|
227
|
+
Executes aggregation pipeline.
|
|
229
228
|
|
|
230
|
-
|
|
229
|
+
* **Signature:**
|
|
231
230
|
|
|
232
|
-
|
|
231
|
+
````ts
|
|
232
|
+
async execute(options?: {
|
|
233
|
+
useCursor?: boolean; // return cursor if true
|
|
234
|
+
allowDiskUse?: boolean; // enable disk use
|
|
235
|
+
projection?: Record<string, any>; // manual projection map
|
|
236
|
+
}): Promise<{
|
|
237
|
+
success: boolean;
|
|
238
|
+
count: number;
|
|
239
|
+
data: any[];
|
|
240
|
+
}>```
|
|
233
241
|
|
|
234
|
-
|
|
242
|
+
````
|
|
243
|
+
* **Example Response:**
|
|
235
244
|
|
|
236
|
-
|
|
245
|
+
```json
|
|
246
|
+
{
|
|
247
|
+
"success": true,
|
|
248
|
+
"count": 50,
|
|
249
|
+
"data": [ /* documents */ ]
|
|
250
|
+
}
|
|
251
|
+
```
|
|
237
252
|
|
|
238
|
-
|
|
239
|
-
throw new HandleERROR('Not Found', 404);
|
|
240
|
-
```
|
|
253
|
+
---
|
|
241
254
|
|
|
242
|
-
|
|
255
|
+
## 🔐 Security Configuration
|
|
243
256
|
|
|
244
|
-
|
|
257
|
+
Customize rules in `security-config.js` at your project root (auto-generated):
|
|
245
258
|
|
|
246
259
|
```js
|
|
247
|
-
|
|
260
|
+
// security-config.js
|
|
261
|
+
export const securityConfig = {
|
|
262
|
+
allowedOperators: [
|
|
263
|
+
'eq','ne','gt','gte','lt','lte','in','nin','regex'
|
|
264
|
+
],
|
|
265
|
+
forbiddenFields: ['password','__v'],
|
|
266
|
+
accessLevels: {
|
|
267
|
+
guest: {
|
|
268
|
+
maxLimit: 20,
|
|
269
|
+
allowedPopulate: []
|
|
270
|
+
},
|
|
271
|
+
user: {
|
|
272
|
+
maxLimit: 100,
|
|
273
|
+
allowedPopulate: ['orders','profile']
|
|
274
|
+
},
|
|
275
|
+
admin: {
|
|
276
|
+
maxLimit: 1000,
|
|
277
|
+
allowedPopulate: ['*']
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
};
|
|
248
281
|
```
|
|
249
282
|
|
|
250
|
-
|
|
283
|
+
* **allowedOperators**: Operators users can use in queries.
|
|
284
|
+
* **forbiddenFields**: Fields excluded from results.
|
|
285
|
+
* **accessLevels**: Per-role limits and populate permissions.
|
|
286
|
+
|
|
287
|
+
> If you omit `security-config.js`, defaults are applied from the package.
|
|
251
288
|
|
|
252
|
-
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## 🔍 Examples
|
|
292
|
+
|
|
293
|
+
### Filter with Multiple Operators
|
|
294
|
+
|
|
295
|
+
```http
|
|
296
|
+
GET /api/v1/products?price[gte]=50&price[lte]=200&category[in]=["books","electronics"]
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### Manual Filter & Projection
|
|
253
300
|
|
|
254
301
|
```js
|
|
255
|
-
|
|
302
|
+
const features = new ApiFeatures(Product, req.query, 'user')
|
|
303
|
+
.addManualFilters({ isActive: true })
|
|
304
|
+
.filter()
|
|
305
|
+
.limitFields()
|
|
306
|
+
.execute({ projection: { name: 1, price: 1 } });
|
|
256
307
|
```
|
|
257
308
|
|
|
258
|
-
|
|
309
|
+
### Populate Relations
|
|
259
310
|
|
|
260
|
-
|
|
311
|
+
```http
|
|
312
|
+
GET /api/v1/posts?populate=author,comments
|
|
313
|
+
```
|
|
261
314
|
|
|
262
|
-
|
|
315
|
+
### Complete Controller Example
|
|
263
316
|
|
|
264
|
-
|
|
317
|
+
```js
|
|
318
|
+
app.get(
|
|
319
|
+
'/api/v1/orders',
|
|
320
|
+
catchAsync(async (req, res) => {
|
|
321
|
+
const result = await new ApiFeatures(Order, req.query, req.user.role)
|
|
322
|
+
.filter()
|
|
323
|
+
.sort()
|
|
324
|
+
.limitFields()
|
|
325
|
+
.paginate()
|
|
326
|
+
.populate()
|
|
327
|
+
.execute();
|
|
265
328
|
|
|
266
|
-
|
|
329
|
+
res.json(result);
|
|
330
|
+
})
|
|
331
|
+
);
|
|
332
|
+
```
|
|
267
333
|
|
|
268
|
-
|
|
334
|
+
---
|
|
335
|
+
|
|
336
|
+
## 🧪 Testing
|
|
269
337
|
|
|
270
338
|
```bash
|
|
271
339
|
npm test
|
|
272
340
|
```
|
|
273
341
|
|
|
274
|
-
|
|
342
|
+
Tests use Jest. Add tests for your controllers and ApiFeatures behaviors.
|
|
275
343
|
|
|
276
|
-
|
|
344
|
+
---
|
|
277
345
|
|
|
278
|
-
|
|
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
|
|
346
|
+
## 📜 License
|
|
283
347
|
|
|
284
|
-
|
|
348
|
+
MIT © [Alireza Aghaee](https://github.com/alirezaaghaee)
|
|
285
349
|
|
|
286
350
|
---
|
|
287
351
|
|
|
288
352
|
## 📜 License
|
|
289
353
|
|
|
290
|
-
|
|
354
|
+
MIT © 2024 Alireza Aghaee
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## ✒️ Author
|
|
359
|
+
|
|
360
|
+
**Alireza Aghaee**
|
|
361
|
+
|
|
362
|
+
* GitHub: [AlirezaAghaee1996](https://github.com/AlirezaAghaee1996)
|
|
363
|
+
* LinkedIn: [alireza-aghaee-mern-dev](https://www.linkedin.com/in/alireza-aghaee-mern-dev)
|