vanta-api
Version:
Advanced API features and security configuration for Node.js/MongoDB.
1,130 lines (837 loc) • 19.3 kB
Markdown
# 🚀 Vanta API
**Vanta API** is a lightweight, reusable API utility toolkit for **Express.js** and **Mongoose** applications.
It helps you build clean, secure, and production-friendly API endpoints with advanced filtering, search, sorting, pagination, populate, centralized error handling, and async route handling.
---
## ✨ Features
- Advanced query filtering
- Manual server-side filters
- Recursive `$and`, `$or`, `$nor` filter support
- Automatic `ObjectId` conversion
- Search using `q`
- Case-insensitive regex search
- Sorting
- Field limiting / projection
- Pagination
- Aggregation-based populate
- Nested populate support
- Role-based security limits
- Forbidden field protection
- Async route wrapper
- Centralized Express error handler
- Custom operational error class
---
## 📦 Installation
```bash
npm install vanta-api
```
Required dependencies in your app:
```bash
npm install express mongoose
```
---
## 📁 Project Files
```txt
src/
api-features.js
catchAsync.js
config.js
errorHandler.js
handleError.js
security-default-config.js
```
| File | Purpose |
|---|---|
| `api-features.js` | Main API query builder for filtering, search, sorting, pagination, populate, and execution |
| `catchAsync.js` | Wraps async Express route handlers and forwards errors to `next()` |
| `errorHandler.js` | Global Express error middleware |
| `handleError.js` | Custom operational error class |
| `config.js` | Loads and merges security configuration |
| `security-default-config.js` | Default security rules and role-based limits |
---
# 🇬🇧 English Documentation
## Quick Start
```js
import ApiFeatures,{catchAsync,catchError,HandleERROR} from "vanta-api";
```
Example controller:
```js
export const getProducts = catchAsync(async (req, res, next) => {
const result = await new ApiFeatures(Product, req.query, req.user?.role)
.filter()
.search(["name", "description"])
.sort()
.limitFields()
.paginate()
.execute();
res.status(200).json(result);
});
```
---
# ApiFeatures
`ApiFeatures` is the main class of this package. It converts request query parameters and manual backend filters into a MongoDB aggregation pipeline.
## Constructor
```js
new ApiFeatures(model, query, userRole)
```
| Parameter | Type | Required | Description |
|---|---|---|---|
| `model` | Mongoose Model | Yes | The Mongoose model used to run aggregation |
| `query` | Object | No | Usually `req.query` |
| `userRole` | String | No | Role name used for security rules |
Example:
```js
const features = new ApiFeatures(Product, req.query, req.user?.role);
```
If `userRole` is missing or invalid, the default role is `guest`.
---
## Recommended Chain Order
```js
const result = await new ApiFeatures(Model, req.query, req.user?.role)
.addManualFilters(serverSideFilters)
.filter()
.populate(populateOptions)
.search(["name", "description"])
.sort()
.limitFields()
.paginate()
.execute();
```
Why this order?
1. `addManualFilters()` adds backend-controlled filters.
2. `filter()` creates the base `$match`.
3. `populate()` joins referenced documents.
4. `search()` searches normal or populated fields.
5. `sort()` sorts final results.
6. `limitFields()` controls output fields.
7. `paginate()` applies paging.
8. `execute()` runs the aggregation.
---
## `filter()`
Builds a MongoDB `$match` stage from `req.query`.
```js
new ApiFeatures(Product, req.query)
.filter()
.execute();
```
### Simple Filter
```txt
GET /api/products?category=phone
```
Generated filter:
```js
{
category: "phone"
}
```
### Comparison Operators
```txt
GET /api/products?price[gte]=100&price[lte]=500
```
Generated filter:
```js
{
price: {
$gte: 100,
$lte: 500
}
}
```
### Boolean / Null / Number Conversion
```txt
GET /api/products?isActive=true&deletedAt=null&price=100
```
Generated values:
```js
{
isActive: true,
deletedAt: null,
price: 100
}
```
Strings with leading zero are preserved:
```txt
GET /api/users?code=0012
```
```js
{
code: "0012"
}
```
### ObjectId Conversion
Fields like `_id`, `id`, and fields ending with `id` are converted to `ObjectId` when the value is a strict MongoDB ObjectId.
```txt
GET /api/products?_id=665f0f6f4e7d9a2e2c123456
```
```js
{
_id: ObjectId("665f0f6f4e7d9a2e2c123456")
}
```
Reserved query keys are excluded from normal filtering:
```js
["page", "limit", "sort", "fields", "populate", "q"]
```
---
## `addManualFilters(filters)`
Adds backend-controlled filters manually. This is useful when you want to enforce conditions that users should not control from the URL.
```js
new ApiFeatures(Order, req.query)
.addManualFilters({ user: req.user._id })
.filter()
.execute();
```
### `$and` Example
```js
const result = await new ApiFeatures(Product, req.query)
.addManualFilters({
$and: [
{ _id: "665f0f6f4e7d9a2e2c123456" },
{ isActive: true }
]
})
.filter()
.execute();
```
The `_id` inside `$and` is recursively converted to `ObjectId`.
### `$or` Example
```js
const result = await new ApiFeatures(Product, req.query)
.addManualFilters({
$or: [
{ ownerId: "665f0f6f4e7d9a2e2c123456" },
{ createdById: "665f0f6f4e7d9a2e2c654321" }
]
})
.filter()
.execute();
```
### `$nor` Example
```js
const result = await new ApiFeatures(Product, req.query)
.addManualFilters({
$nor: [
{ status: "blocked" },
{ isDeleted: true }
]
})
.filter()
.execute();
```
### `$in` Example
```js
const result = await new ApiFeatures(Product, req.query)
.addManualFilters({
_id: {
$in: [
"665f0f6f4e7d9a2e2c123456",
"665f0f6f4e7d9a2e2c654321"
]
}
})
.filter()
.execute();
```
---
## `search(fields)`
Searches using the `q` key from `req.query`. It always uses case-insensitive regex search.
```js
.search(["name", "description"])
```
```txt
GET /api/products?q=iphone
```
```js
const result = await new ApiFeatures(Product, req.query)
.filter()
.search(["name", "description", "brand"])
.paginate()
.execute();
```
Generated condition:
```js
{
$or: [
{ name: { $regex: "iphone", $options: "i" } },
{ description: { $regex: "iphone", $options: "i" } },
{ brand: { $regex: "iphone", $options: "i" } }
]
}
```
`q` is reserved and is not treated as a normal filter.
---
## `sort()`
Sorts results using the `sort` query key.
```txt
GET /api/products?sort=-createdAt,price
```
```js
{
createdAt: -1,
price: 1
}
```
Only fields existing in the model schema are accepted.
---
## `limitFields(input)`
Controls returned fields using projection.
```txt
GET /api/products?fields=name,price,category
```
or:
```js
.limitFields("name,price")
```
Include mode:
```js
{ name: 1, price: 1 }
```
Exclude mode:
```txt
GET /api/products?fields=-description
```
```js
{ description: 0 }
```
Mixed include/exclude is not allowed:
```txt
GET /api/products?fields=name,-password
```
Throws:
```txt
Cannot mix include and exclude fields
```
---
## `paginate()`
Adds `$skip` and `$limit`.
```txt
GET /api/products?page=2&limit=10
```
Pipeline:
```js
[
{ $skip: 10 },
{ $limit: 10 }
]
```
Limits are capped by role.
---
## `populate(input)`
Performs aggregation-based populate using `$lookup`.
```js
.populate("user")
```
From query:
```txt
GET /api/posts?populate=user
```
Multiple populate paths:
```js
.populate(["user", "category"])
```
Nested populate:
```js
.populate({
path: "user",
populate: {
path: "company",
populate: {
path: "country"
}
}
})
```
Dot notation:
```js
.populate("user.company.country")
```
Populate with select:
```js
.populate({
path: "user",
select: "name email"
})
```
Exclude fields:
```js
.populate({
path: "user",
select: "-password"
})
```
Mixed include/exclude is not allowed.
---
## `execute(options)`
Runs the aggregation pipeline.
```js
const result = await features.execute();
```
Returns:
```js
{
success: true,
count: 25,
data: [...]
}
```
Options:
```js
.execute({
debug: true,
useCursor: false,
batchSize: 100,
maxTimeMS: 10000,
allowDiskUse: true,
readConcern: "majority"
})
```
| Option | Type | Default | Description |
|---|---|---|---|
| `debug` | Boolean | `false` | Logs the pipeline |
| `useCursor` | Boolean | `false` | Uses aggregation cursor |
| `batchSize` | Number | `100` | Cursor batch size |
| `maxTimeMS` | Number | `10000` | Max execution time |
| `allowDiskUse` | Boolean | `false` | Allows disk usage |
| `readConcern` | String | `majority` | MongoDB read concern |
---
# Full Controller Example
```js
import ApiFeatures,{catchAsync} from "vanta-api";
import Product from "../models/productModel.js";
export const getProducts = catchAsync(async (req, res, next) => {
const result = await new ApiFeatures(Product, req.query, req.user?.role)
.addManualFilters({ isDeleted: false })
.filter()
.populate([
{
path: "category",
select: "name slug"
},
{
path: "createdBy",
select: "name email"
}
])
.search(["name", "description", "category.name"])
.sort()
.limitFields()
.paginate()
.execute();
res.status(200).json(result);
});
```
---
# Error Handling
## `HandleERROR`
Custom operational error class.
```js
import {HandleERROR} from "vanta-api";
throw new HandleERROR("Product not found", 404);
```
Example properties:
```js
{
message: "Product not found",
statusCode: 404,
status: "fail",
isOperational: true
}
```
For `4xx` errors, `status` is `fail`. For `5xx` errors, `status` is `error`.
## `catchAsync`
Wraps async Express handlers and removes repetitive `try/catch`.
```js
import {catchAsync} from "vanta-api";
app.get(
"/products",
catchAsync(async (req, res, next) => {
const products = await Product.find();
res.json(products);
})
);
```
Any rejected promise is forwarded to Express `next()`.
## `errorHandler`
Global Express error middleware.
```js
import {catchError} from "vanta-api";
app.use(catchError);
```
Example response:
```json
{
"status": "fail",
"success": false,
"message": "Product not found"
}
```
Use it after all routes:
```js
app.use("/api/products", productRouter);
app.use(catchError);
```
---
# Security Configuration
Default security settings are stored in:
```txt
src/security-default-config.js
```
You can override them by creating a `security-config.js` file in your project root.
```js
export const securityConfig = {
allowedOperators: [
"eq",
"ne",
"gt",
"gte",
"lt",
"lte",
"in",
"nin",
"regex",
"exists"
],
forbiddenFields: ["password", "refreshToken", "resetPasswordToken"],
maxPipelineStages: 50,
accessLevels: {
guest: {
maxLimit: 20,
allowedPopulate: ["category"]
},
user: {
maxLimit: 100,
allowedPopulate: ["category", "createdBy"]
},
admin: {
maxLimit: 1000,
allowedPopulate: ["*"]
}
}
};
```
---
# Express Setup Example
```js
import express from "express";
import {catchError} from "vanta-api";
import productRouter from "./routes/productRoutes.js";
const app = express();
app.use(express.json());
app.use("/api/products", productRouter);
app.use(catchError);
export default app;
```
---
# 🇮🇷 مستندات فارسی
## شروع سریع
```js
import ApiFeatures,{catchAsync,catchError,HandleERROR} from "vanta-api";
```
مثال controller:
```js
export const getProducts = catchAsync(async (req, res, next) => {
const result = await new ApiFeatures(Product, req.query, req.user?.role)
.filter()
.search(["name", "description"])
.sort()
.limitFields()
.paginate()
.execute();
res.status(200).json(result);
});
```
---
# ApiFeatures چیست؟
`ApiFeatures` کلاس اصلی پکیج است. این کلاس از روی `req.query` و فیلترهای دستی سمت سرور، یک MongoDB aggregation pipeline میسازد.
## Constructor
```js
new ApiFeatures(model, query, userRole)
```
| ورودی | نوع | اجباری | توضیح |
|---|---|---|---|
| `model` | Mongoose Model | بله | مدلی که aggregation روی آن اجرا میشود |
| `query` | Object | خیر | معمولاً همان `req.query` |
| `userRole` | String | خیر | نقش کاربر برای قوانین امنیتی |
اگر `userRole` داده نشود یا معتبر نباشد، role پیشفرض `guest` استفاده میشود.
---
## ترتیب پیشنهادی استفاده
```js
const result = await new ApiFeatures(Model, req.query, req.user?.role)
.addManualFilters(serverSideFilters)
.filter()
.populate(populateOptions)
.search(["name", "description"])
.sort()
.limitFields()
.paginate()
.execute();
```
---
## `filter()`
از روی `req.query` مرحلهی `$match` میسازد.
```txt
GET /api/products?category=phone
```
```js
{
category: "phone"
}
```
operatorهای مقایسهای:
```txt
GET /api/products?price[gte]=100&price[lte]=500
```
```js
{
price: {
$gte: 100,
$lte: 500
}
}
```
تبدیلهای خودکار:
```txt
GET /api/products?isActive=true&deletedAt=null&price=100
```
```js
{
isActive: true,
deletedAt: null,
price: 100
}
```
کلیدهایی مثل `_id`, `id` و کلیدهایی که به `id` ختم میشوند، اگر مقدارشان ObjectId معتبر باشد، به `ObjectId` تبدیل میشوند.
---
## `addManualFilters(filters)`
برای اضافه کردن فیلترهای دستی سمت سرور استفاده میشود.
```js
new ApiFeatures(Order, req.query)
.addManualFilters({ user: req.user._id })
.filter()
.execute();
```
استفاده از `$and`:
```js
const result = await new ApiFeatures(Product, req.query)
.addManualFilters({
$and: [
{ _id: "665f0f6f4e7d9a2e2c123456" },
{ isActive: true }
]
})
.filter()
.execute();
```
در این حالت `_id` داخل `$and` هم به صورت recursive به `ObjectId` تبدیل میشود.
استفاده از `$or`:
```js
const result = await new ApiFeatures(Product, req.query)
.addManualFilters({
$or: [
{ ownerId: "665f0f6f4e7d9a2e2c123456" },
{ createdById: "665f0f6f4e7d9a2e2c654321" }
]
})
.filter()
.execute();
```
استفاده از `$in`:
```js
const result = await new ApiFeatures(Product, req.query)
.addManualFilters({
_id: {
$in: [
"665f0f6f4e7d9a2e2c123456",
"665f0f6f4e7d9a2e2c654321"
]
}
})
.filter()
.execute();
```
---
## `search(fields)`
از کلید `q` داخل `req.query` مقدار را میگیرد و با regex جستجو میکند. جستجو همیشه case-insensitive است.
```txt
GET /api/products?q=iphone
```
```js
const result = await new ApiFeatures(Product, req.query)
.filter()
.search(["name", "description", "brand"])
.paginate()
.execute();
```
نکته: `q` جزو کلیدهای رزرو شده است و وارد filter معمولی نمیشود.
---
## `sort()`
از `sort` داخل query برای مرتبسازی استفاده میکند.
```txt
GET /api/products?sort=-createdAt,price
```
```js
{
createdAt: -1,
price: 1
}
```
---
## `limitFields(input)`
برای کنترل فیلدهای خروجی استفاده میشود.
```txt
GET /api/products?fields=name,price,category
```
یا مستقیم:
```js
.limitFields("name,price")
```
حالت include:
```js
{ name: 1, price: 1 }
```
حالت exclude:
```js
{ description: 0 }
```
حالت mixed مجاز نیست:
```txt
GET /api/products?fields=name,-password
```
---
## `paginate()`
برای صفحهبندی استفاده میشود.
```txt
GET /api/products?page=2&limit=10
```
```js
[
{ $skip: 10 },
{ $limit: 10 }
]
```
---
## `populate(input)`
با استفاده از aggregation و `$lookup` دادههای مرتبط را join میکند.
```js
.populate("user")
```
از query:
```txt
GET /api/posts?populate=user
```
چند populate:
```js
.populate(["user", "category"])
```
nested populate:
```js
.populate({
path: "user",
populate: {
path: "company",
populate: {
path: "country"
}
}
})
```
یا با dot notation:
```js
.populate("user.company.country")
```
select در populate:
```js
.populate({
path: "user",
select: "name email"
})
```
exclude:
```js
.populate({
path: "user",
select: "-password"
})
```
---
## `execute(options)`
pipeline را اجرا میکند.
```js
const result = await features.execute();
```
خروجی:
```js
{
success: true,
count: 25,
data: [...]
}
```
---
# مدیریت خطاها
## `HandleERROR`
کلاس خطای اختصاصی.
```js
throw new HandleERROR("Product not found", 404);
```
```js
{
message: "Product not found",
statusCode: 404,
status: "fail",
isOperational: true
}
```
## `catchAsync`
برای حذف `try/catch` تکراری در route handlerهای async.
```js
app.get(
"/products",
catchAsync(async (req, res, next) => {
const products = await Product.find();
res.json(products);
})
);
```
## `errorHandler`
middleware مرکزی خطاها:
```js
app.use(catchError);
```
نمونه خروجی:
```json
{
"status": "fail",
"success": false,
"message": "Product not found"
}
```
---
# تنظیمات امنیتی
برای override کردن تنظیمات پیشفرض، در root پروژه فایل زیر را بسازید:
```txt
security-config.js
```
```js
export const securityConfig = {
allowedOperators: ["eq", "ne", "gt", "gte", "lt", "lte", "in", "nin", "regex", "exists"],
forbiddenFields: ["password", "refreshToken", "resetPasswordToken"],
maxPipelineStages: 50,
accessLevels: {
guest: {
maxLimit: 20,
allowedPopulate: ["category"]
},
user: {
maxLimit: 100,
allowedPopulate: ["category", "createdBy"]
},
admin: {
maxLimit: 1000,
allowedPopulate: ["*"]
}
}
};
```
---
# License
MIT