UNPKG

vanta-api

Version:

Advanced API features and security configuration for Node.js/MongoDB.

1,130 lines (837 loc) 19.3 kB
# 🚀 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