nodejs-express-mongodb-backend-pattern
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseNode.js Express MongoDB Backend Pattern
Node.js Express MongoDB 后端模板
Purpose
用途
This skill helps the agent scaffold, explain, or adapt the nodejs-express-mongodb-backend-pattern template — a Node.js REST API boilerplate with observability, security, and resilience built in. The agent should use it when the user wants a backend with Express, MongoDB, Redis, Sentry, JWT authentication, and production-oriented middleware.
该技能帮助Agent生成、解释或适配 nodejs-express-mongodb-backend-pattern 模板——这是一个内置可观测性、安全性和弹性能力的Node.js REST API样板。当用户需要基于Express、MongoDB、Redis、Sentry、JWT鉴权和生产导向中间件搭建后端时,Agent应使用该技能。
When to use this skill
何时使用该技能
- The user wants to create a new REST API backend with Node.js and Express.
- The user asks for a production-ready or "observable" Node.js API template.
- The user mentions MongoDB/Mongoose, Redis, Sentry, rate limiting, or JWT auth and wants a starter.
- The user wants to clone, fork, or understand the structure of this repo (nodejs-express-mongodb-backend-pattern).
- The user needs steps to set up env vars, run the app, or add routes/controllers following this template's conventions.
- 用户想要使用Node.js和Express创建新的REST API后端。
- 用户需要生产就绪或「可观测」的Node.js API模板。
- 用户提到MongoDB/Mongoose、Redis、Sentry、限流或JWT鉴权,需要启动项目的基础模板。
- 用户想要克隆、复刻或理解该仓库(nodejs-express-mongodb-backend-pattern)的结构。
- 用户需要按照该模板的规范完成环境变量配置、启动应用、新增路由/控制器的步骤指导。
When NOT to use this skill
何时不使用该技能
- The user wants a frontend or full-stack framework (Next.js, Nuxt, etc.).
- The user explicitly wants a different DB (PostgreSQL, MySQL) without Mongoose.
- The user wants a serverless/lambda architecture instead of a long-running Express server.
- 用户想要前端或全栈框架(Next.js、Nuxt等)。
- 用户明确要求使用不包含Mongoose的其他数据库(PostgreSQL、MySQL)。
- 用户想要Serverless/Lambda架构,而非长期运行的Express服务。
How to use this template
如何使用该模板
1. Clone and install
1. 克隆并安装依赖
bash
git clone https://github.com/laskar-ksatria/building-observable-nodejs-api.git
cd building-observable-nodejs-api
npm installbash
git clone https://github.com/laskar-ksatria/building-observable-nodejs-api.git
cd building-observable-nodejs-api
npm install2. Environment setup
2. 环境配置
- Create a file in the project root.
.env - Required variables: ,
PORT,MONGODB_URI,PRIVATE_KEY. Optional:TOKEN_EXPIRED,SENTRY_DSN,REDIS_HOST,REDIS_PORT. App exits on startup if required vars are missing. Redis is used for optional caching (e.g. cache GET /api/user for 60s); if Redis is not set, the app runs without cache.REDIS_PASSWORD - Generate a JWT secret for :
PRIVATE_KEY- Node:
node -e "console.log(require('crypto').randomBytes(64).toString('base64'))" - OpenSSL:
openssl rand -hex 64
- Node:
- 在项目根目录创建 文件。
.env - 必需变量:、
PORT、MONGODB_URI、PRIVATE_KEY。可选变量:TOKEN_EXPIRED、SENTRY_DSN、REDIS_HOST、REDIS_PORT。如果缺少必需变量,应用启动时会自动退出。Redis用于可选缓存(例如将GET /api/user接口缓存60秒);如果未配置Redis,应用会跳过缓存正常运行。REDIS_PASSWORD - 为生成JWT密钥:
PRIVATE_KEY- Node命令:
node -e "console.log(require('crypto').randomBytes(64).toString('base64'))" - OpenSSL命令:
openssl rand -hex 64
- Node命令:
3. Run the app
3. 运行应用
- Development:
npm run dev - Build: then
npm run buildnpm start
- 开发环境:
npm run dev - 生产构建:后执行
npm run buildnpm start
4. API surface
4. API接口
- Base path:
/api - — register user (email, full_name, password)
POST /api/user/register - — login, returns JWT
POST /api/user/login - — current user (header:
GET /api/user)Authorization: Bearer <token> - — health check
GET /
- 基础路径:
/api - —— 用户注册(参数:email、full_name、password)
POST /api/user/register - —— 用户登录,返回JWT
POST /api/user/login - —— 获取当前用户信息(请求头:
GET /api/user)Authorization: Bearer <token> - —— 健康检查接口
GET /
Key features
核心特性
- Stack: Express 5, TypeScript, Mongoose, Redis (ioredis), Sentry, Helmet, CORS.
- Security: NoSQL injection prevention, rejection of HTML in input, Helmet, CORS.
- Auth: JWT + bcrypt; password is hashed on save and never returned in JSON.
- Resilience: Per-route rate limiting, overload detection (toobusy), structured HttpError and fallback to Sentry for unexpected errors.
- Structure: with config, controllers, errors, lib, middlewares, models, routes, services, types; entry in
src/with Sentry init.server.ts
- 技术栈:Express 5、TypeScript、Mongoose、Redis(ioredis)、Sentry、Helmet、CORS。
- 安全能力:NoSQL注入防护、输入内容HTML拦截、Helmet安全头、CORS配置。
- 鉴权体系:JWT + bcrypt;密码存储时自动哈希,永远不会在JSON响应中返回密码字段。
- 弹性能力:路由粒度限流、过载检测(toobusy)、结构化HttpError,未预期错误自动上报Sentry。
- 项目结构:目录下包含config、controllers、errors、lib、middlewares、models、routes、services、types等模块;入口文件为
src/,包含Sentry初始化逻辑。server.ts
Conventions (IMPORTANT — follow these when generating code)
开发规范(重要——生成代码时请严格遵守)
File naming
文件命名规范
- Models:
src/models/<entity>.model.ts - Controllers:
src/controllers/<entity>.controller.ts - Routes:
src/routes/<entity>.route.ts - Middlewares:
src/middlewares/<name>.ts - Services:
src/services/<name>.ts - All file names use kebab-case.
- 模型:
src/models/<实体名>.model.ts - 控制器:
src/controllers/<实体名>.controller.ts - 路由:
src/routes/<实体名>.route.ts - 中间件:
src/middlewares/<名称>.ts - 服务:
src/services/<名称>.ts - 所有文件名使用 短横线命名法(kebab-case)。
Response format
响应格式规范
Every API response follows this shape:
Success:
json
{ "success": true, "data": { ... } }Error:
json
{ "success": false, "error": { "error_id": 0, "message": "...", "errors": [] } }所有API响应都遵循以下结构:
成功响应:
json
{ "success": true, "data": { ... } }错误响应:
json
{ "success": false, "error": { "error_id": 0, "message": "...", "errors": [] } }Middleware order in app.ts
app.tsapp.ts
中的中间件顺序
app.tsOrder matters. Follow this exact sequence:
- — security headers
helmet() - — cross-origin config
cors() - — parse JSON body (with size limit)
express.json() - — parse URL-encoded body
express.urlencoded() - — NoSQL injection + HTML sanitization
securityMiddleware - check — overload detection (HTTP 529)
toobusy - Cache-Control header —
no-store - Routes —
app.use("/api", indexRoute) - Health check —
app.get("/") - — must be last (global error handler)
ErrorHandling
中间件的顺序会影响功能生效逻辑,请严格遵循以下顺序:
- —— 安全响应头
helmet() - —— 跨域配置
cors() - —— 解析JSON请求体(带大小限制)
express.json() - —— 解析URL编码的请求体
express.urlencoded() - —— NoSQL注入防护 + HTML内容清理
securityMiddleware - 检查 —— 服务过载检测(返回HTTP 529)
toobusy - Cache-Control响应头 —— 设置为
no-store - 路由注册 ——
app.use("/api", indexRoute) - 健康检查接口 ——
app.get("/") - —— 必须放在最后(全局错误处理器)
ErrorHandling
Error handling convention
错误处理规范
- Known/expected errors: throw . These return the configured HTTP code and message to the client.
new HttpError(errorStates.someError) - Adding a new error state: add an entry to in
errorStateswith a uniquesrc/errors/index.ts,error_id, andmessage.http_code - Sentry reporting: unexpected errors (anything that is NOT an ) are automatically sent to Sentry. For known errors that you still want to report, set
HttpErrorin the error state.sentry: true - Mongoose validation errors: automatically collected and returned in the array.
errors
- 已知/预期错误:抛出 。这类错误会返回配置好的HTTP状态码和提示信息给客户端。
new HttpError(errorStates.someError) - 新增错误状态:在的
src/errors/index.ts中新增条目,包含唯一的errorStates、error_id和message。http_code - Sentry上报:非类型的未预期错误会自动上报到Sentry。如果需要上报已知错误,可以在错误状态配置中设置
HttpError。sentry: true - Mongoose校验错误:会自动收集并返回在数组中。
errors
Adding a new environment variable
新增环境变量步骤
- Add the key to
.env - Add it to in the
src/env.tsobjectenv - Use it via then
import env from "../env"env.YOUR_VAR
- 在文件中添加变量名
.env - 在的
src/env.ts对象中添加对应配置env - 使用时通过 引入,再调用
import env from "../env"env.YOUR_VAR
Dependencies
依赖列表
json
{
"dependencies": {
"@sentry/node": "^8.x",
"bcrypt": "^5.x",
"cors": "^2.x",
"dotenv": "^17.x",
"express": "^5.x",
"express-rate-limit": "^8.x",
"helmet": "^8.x",
"ioredis": "^5.x",
"jsonwebtoken": "^9.x",
"mongoose": "^9.x",
"toobusy-js": "^0.5.x"
},
"devDependencies": {
"@types/bcrypt": "^5.x",
"@types/cors": "^2.x",
"@types/express": "^4.x",
"@types/ioredis": "^4.x",
"@types/jsonwebtoken": "^9.x",
"@types/toobusy-js": "^0.5.x",
"nodemon": "^3.x",
"ts-node": "^10.x",
"tsx": "^4.x",
"typescript": "^5.x"
}
}json
{
"dependencies": {
"@sentry/node": "^8.x",
"bcrypt": "^5.x",
"cors": "^2.x",
"dotenv": "^17.x",
"express": "^5.x",
"express-rate-limit": "^8.x",
"helmet": "^8.x",
"ioredis": "^5.x",
"jsonwebtoken": "^9.x",
"mongoose": "^9.x",
"toobusy-js": "^0.5.x"
},
"devDependencies": {
"@types/bcrypt": "^5.x",
"@types/cors": "^2.x",
"@types/express": "^4.x",
"@types/ioredis": "^4.x",
"@types/jsonwebtoken": "^9.x",
"@types/toobusy-js": "^0.5.x",
"nodemon": "^3.x",
"ts-node": "^10.x",
"tsx": "^4.x",
"typescript": "^5.x"
}
}How to add a new resource (step-by-step)
新增资源步骤(分步指南)
When the user asks to add a new entity (e.g. "Product"), follow these steps in order:
当用户要求新增实体(例如「Product」)时,按以下顺序操作:
Step 1 — Define types in src/types/index.ts
src/types/index.ts步骤1 —— 在src/types/index.ts
中定义类型
src/types/index.tsts
// Product
export interface IProduct {
name: string;
price: number;
description: string;
}
export interface IProductDocument extends IProduct, Document {}ts
// Product
export interface IProduct {
name: string;
price: number;
description: string;
}
export interface IProductDocument extends IProduct, Document {}Step 2 — Create model src/models/product.model.ts
src/models/product.model.ts步骤2 —— 创建模型文件src/models/product.model.ts
src/models/product.model.tsts
import { Schema, model } from "mongoose";
import { IProductDocument } from "../types";
const productSchema = new Schema<IProductDocument>(
{
name: { type: String, required: true },
price: { type: Number, required: true },
description: { type: String, required: true },
},
{ versionKey: false, timestamps: true },
);
export const ProductModel = model<IProductDocument>("Product", productSchema);ts
import { Schema, model } from "mongoose";
import { IProductDocument } from "../types";
const productSchema = new Schema<IProductDocument>(
{
name: { type: String, required: true },
price: { type: Number, required: true },
description: { type: String, required: true },
},
{ versionKey: false, timestamps: true },
);
export const ProductModel = model<IProductDocument>("Product", productSchema);Step 3 — Create controller src/controllers/product.controller.ts
src/controllers/product.controller.ts步骤3 —— 创建控制器文件src/controllers/product.controller.ts
src/controllers/product.controller.tsts
import { Request, Response, NextFunction } from "express";
import { ProductModel } from "../models/product.model";
import HttpError, { errorStates } from "../errors";
class ProductController {
static async create(req: Request, res: Response, next: NextFunction) {
try {
const product = await ProductModel.create(req.body);
return res.status(201).json({ success: true, data: { product } });
} catch (error) {
next(error);
}
}
static async getAll(req: Request, res: Response, next: NextFunction) {
try {
const products = await ProductModel.find();
return res.status(200).json({ success: true, data: { products } });
} catch (error) {
next(error);
}
}
}
export default ProductController;ts
import { Request, Response, NextFunction } from "express";
import { ProductModel } from "../models/product.model";
import HttpError, { errorStates } from "../errors";
class ProductController {
static async create(req: Request, res: Response, next: NextFunction) {
try {
const product = await ProductModel.create(req.body);
return res.status(201).json({ success: true, data: { product } });
} catch (error) {
next(error);
}
}
static async getAll(req: Request, res: Response, next: NextFunction) {
try {
const products = await ProductModel.find();
return res.status(200).json({ success: true, data: { products } });
} catch (error) {
next(error);
}
}
}
export default ProductController;Step 4 — Create route src/routes/product.route.ts
src/routes/product.route.ts步骤4 —— 创建路由文件src/routes/product.route.ts
src/routes/product.route.tsts
import ProductController from "../controllers/product.controller";
import { Router } from "express";
import { RateLimit } from "../services/rate-limit";
import Authentication from "../middlewares/auth";
const router = Router();
router.post("/", RateLimit({ max: 10, ms: 60000 }), Authentication, ProductController.create);
router.get("/", RateLimit({ max: 20, ms: 60000 }), ProductController.getAll);
export default router;ts
import ProductController from "../controllers/product.controller";
import { Router } from "express";
import { RateLimit } from "../services/rate-limit";
import Authentication from "../middlewares/auth";
const router = Router();
router.post("/", RateLimit({ max: 10, ms: 60000 }), Authentication, ProductController.create);
router.get("/", RateLimit({ max: 20, ms: 60000 }), ProductController.getAll);
export default router;Step 5 — Mount in src/routes/index.ts
src/routes/index.ts步骤5 —— 在src/routes/index.ts
中挂载路由
src/routes/index.tsts
import { Router } from "express";
import userRoute from "./user.route";
import productRoute from "./product.route";
const router = Router();
router.use("/user", userRoute);
router.use("/product", productRoute);
export default router;ts
import { Router } from "express";
import userRoute from "./user.route";
import productRoute from "./product.route";
const router = Router();
router.use("/user", userRoute);
router.use("/product", productRoute);
export default router;Code examples
代码示例
The following snippets are the actual code from this template. Use them as the reference pattern when scaffolding a new project.
以下代码片段是该模板的实际源码,生成新项目时可以作为参考规范使用。
Project structure
项目结构
src/
├── config/
│ └── mongodb.ts # MongoDB connection
├── controllers/
│ └── user.controller.ts # Request handlers
├── errors/
│ └── index.ts # HttpError, errorStates
├── lib/
│ └── utils.ts # bcrypt, emailRegex
├── middlewares/
│ ├── auth.ts # JWT verification
│ ├── error-handling.ts # Global error + Sentry
│ └── security.ts # NoSQL/HTML sanitization
├── models/
│ └── user.model.ts # Mongoose schema
├── routes/
│ ├── index.ts # Mounts sub-routes
│ └── user.route.ts # /api/user routes
├── services/
│ ├── jwt.ts # GenerateToken, VerifyToken
│ ├── rate-limit.ts # Per-route rate limiter
│ └── redis.ts # Optional cache (getCache, setCache, deleteCache)
├── types/
│ └── index.ts # Shared interfaces
├── app.ts # Express app + middleware
├── env.ts # Load and export env
└── server.ts # Entry, Sentry.init, listensrc/
├── config/
│ └── mongodb.ts # MongoDB连接配置
├── controllers/
│ └── user.controller.ts # 请求处理逻辑
├── errors/
│ └── index.ts # HttpError类、错误状态定义
├── lib/
│ └── utils.ts # bcrypt工具、邮箱正则校验
├── middlewares/
│ ├── auth.ts # JWT校验中间件
│ ├── error-handling.ts # 全局错误处理 + Sentry上报
│ └── security.ts # NoSQL/HTML内容清理
├── models/
│ └── user.model.ts # Mongoose schema定义
├── routes/
│ ├── index.ts # 子路由统一挂载
│ └── user.route.ts # /api/user相关路由
├── services/
│ ├── jwt.ts # 生成Token、校验Token方法
│ ├── rate-limit.ts # 路由粒度限流工具
│ └── redis.ts # 可选缓存方法(getCache、setCache、deleteCache)
├── types/
│ └── index.ts # 全局共享接口定义
├── app.ts # Express实例初始化 + 中间件注册
├── env.ts # 环境变量加载与导出
└── server.ts # 项目入口、Sentry初始化、服务启动Env config (src/env.ts
)
src/env.ts环境变量配置(src/env.ts
)
src/env.tsts
import { config } from "dotenv";
config();
const env = {
PORT: `${process.env.PORT}`,
REDIS_HOST: `${process.env.REDIS_HOST}`,
REDIS_PORT: `${process.env.REDIS_PORT}`,
SENTRY_DSN: `${process.env.SENTRY_DSN}`,
TOKEN_EXPIRED: `${process.env.TOKEN_EXPIRED}`,
PRIVATE_KEY: `${process.env.PRIVATE_KEY}`,
MONGODB_URI: process.env.MONGODB_URI!,
REDIS_PASSWORD: `${process.env.REDIS_PASSWORD}`,
};
export default env;ts
import { config } from "dotenv";
config();
const env = {
PORT: `${process.env.PORT}`,
REDIS_HOST: `${process.env.REDIS_HOST}`,
REDIS_PORT: `${process.env.REDIS_PORT}`,
SENTRY_DSN: `${process.env.SENTRY_DSN}`,
TOKEN_EXPIRED: `${process.env.TOKEN_EXPIRED}`,
PRIVATE_KEY: `${process.env.PRIVATE_KEY}`,
MONGODB_URI: process.env.MONGODB_URI!,
REDIS_PASSWORD: `${process.env.REDIS_PASSWORD}`,
};
export default env;Server entry (src/server.ts
)
src/server.ts服务入口(src/server.ts
)
src/server.tsts
import * as Sentry from "@sentry/node";
import { server } from "./app";
import toobusy from "toobusy-js";
import env from "./env";
import dbConnect from "./config/mongodb";
if (env.SENTRY_DSN) {
Sentry.init({ dsn: env.SENTRY_DSN, environment: process.env.NODE_ENV ?? "development" });
}
async function main() {
await dbConnect();
server.listen(env.PORT, () => {
console.log(`Server running on http://localhost:${env.PORT}`);
});
process.on("SIGINT", () => { toobusy.shutdown(); process.exit(); });
process.on("exit", () => toobusy.shutdown());
}
main().catch((err) => { console.error(err); process.exit(1); });ts
import * as Sentry from "@sentry/node";
import { server } from "./app";
import toobusy from "toobusy-js";
import env from "./env";
import dbConnect from "./config/mongodb";
if (env.SENTRY_DSN) {
Sentry.init({ dsn: env.SENTRY_DSN, environment: process.env.NODE_ENV ?? "development" });
}
async function main() {
await dbConnect();
server.listen(env.PORT, () => {
console.log(`Server running on http://localhost:${env.PORT}`);
});
process.on("SIGINT", () => { toobusy.shutdown(); process.exit(); });
process.on("exit", () => toobusy.shutdown());
}
main().catch((err) => { console.error(err); process.exit(1); });Database config (src/config/mongodb.ts
)
src/config/mongodb.ts数据库配置(src/config/mongodb.ts
)
src/config/mongodb.tsts
import mongoose from "mongoose";
import env from "../env";
import * as Sentry from "@sentry/node";
export default async function dbConnect(): Promise<void> {
try {
await mongoose.connect(env.MONGODB_URI);
mongoose.connection.on("error", (err) => {
Sentry.captureException(err);
console.error("MongoDB connection error:", err);
});
console.log("Connected to MongoDB");
} catch (error) {
console.error("Failed to connect to MongoDB:", error);
Sentry.captureException(error);
process.exit(1);
}
}ts
import mongoose from "mongoose";
import env from "../env";
import * as Sentry from "@sentry/node";
export default async function dbConnect(): Promise<void> {
try {
await mongoose.connect(env.MONGODB_URI);
mongoose.connection.on("error", (err) => {
Sentry.captureException(err);
console.error("MongoDB connection error:", err);
});
console.log("Connected to MongoDB");
} catch (error) {
console.error("Failed to connect to MongoDB:", error);
Sentry.captureException(error);
process.exit(1);
}
}Types (src/types/index.ts
)
src/types/index.ts类型定义(src/types/index.ts
)
src/types/index.tsts
import { Document, Types } from "mongoose";
import { Request } from "express";
export type TGenerateToken = { id: Types.ObjectId };
export interface IAuthRequest extends Request {
decoded?: TGenerateToken;
}
export interface IErrorMessage {
message: string;
error_id: number;
http_code: number;
sentry?: boolean;
}
export type CreateLimitType = { max: number; ms: number };
export interface IUser {
email: string;
full_name: string;
password: string;
}
export interface IUserModel extends IUser {
_id: Types.ObjectId;
}
export interface IUserDocument extends IUser, Document {}ts
import { Document, Types } from "mongoose";
import { Request } from "express";
export type TGenerateToken = { id: Types.ObjectId };
export interface IAuthRequest extends Request {
decoded?: TGenerateToken;
}
export interface IErrorMessage {
message: string;
error_id: number;
http_code: number;
sentry?: boolean;
}
export type CreateLimitType = { max: number; ms: number };
export interface IUser {
email: string;
full_name: string;
password: string;
}
export interface IUserModel extends IUser {
_id: Types.ObjectId;
}
export interface IUserDocument extends IUser, Document {}Model (src/models/user.model.ts
)
src/models/user.model.ts模型定义(src/models/user.model.ts
)
src/models/user.model.tsts
import { emailRegex, hashPassword } from "../lib/utils";
import { Schema, model } from "mongoose";
import { IUserDocument } from "../types";
const userSchema = new Schema<IUserDocument>(
{
email: {
type: String,
required: true,
unique: true,
index: true,
validate: {
validator: (value: string) => emailRegex.test(value),
message: "Invalid email address",
},
},
full_name: { type: String, required: true },
password: { type: String, required: true },
},
{ versionKey: false, timestamps: true },
);
userSchema.pre("save", async function () {
if (!this.isModified("password")) return;
this.password = await hashPassword(this.password);
});
export const UserModel = model<IUserDocument>("User", userSchema);ts
import { emailRegex, hashPassword } from "../lib/utils";
import { Schema, model } from "mongoose";
import { IUserDocument } from "../types";
const userSchema = new Schema<IUserDocument>(
{
email: {
type: String,
required: true,
unique: true,
index: true,
validate: {
validator: (value: string) => emailRegex.test(value),
message: "Invalid email address",
},
},
full_name: { type: String, required: true },
password: { type: String, required: true },
},
{ versionKey: false, timestamps: true },
);
userSchema.pre("save", async function () {
if (!this.isModified("password")) return;
this.password = await hashPassword(this.password);
});
export const UserModel = model<IUserDocument>("User", userSchema);Controller (src/controllers/user.controller.ts
)
src/controllers/user.controller.ts控制器定义(src/controllers/user.controller.ts
)
src/controllers/user.controller.tsts
import { Request, Response, NextFunction } from "express";
import { UserModel } from "../models/user.model";
import { comparePassword } from "../lib/utils";
import { IAuthRequest, IUserDocument } from "../types";
import HttpError, { errorStates } from "../errors";
import { GenerateToken } from "../services/jwt";
import { getCache, setCache, CACHE_USER } from "../services/redis";
class UserController {
static async createUser(req: Request, res: Response, next: NextFunction) {
try {
const { email, full_name, password } = req.body;
const user = (await UserModel.create({ email, full_name, password })) as IUserDocument;
const access_token = GenerateToken({ id: user._id });
return res.status(201).json({
success: true,
data: {
access_token,
user: { _id: user._id, full_name: user.full_name, email: user.email },
},
});
} catch (error) {
next(error);
}
}
static async loginUser(req: Request, res: Response, next: NextFunction) {
try {
const { email, password } = req.body;
const user = (await UserModel.findOne({ email })) as IUserDocument;
if (!user) throw new HttpError(errorStates.invalidEmailOrPassword);
const valid = await comparePassword(password, user.password);
if (!valid) throw new HttpError(errorStates.invalidEmailOrPassword);
const access_token = GenerateToken({ id: user._id });
const { password: _p, ...safeUser } = user.toObject();
return res.status(200).json({ success: true, data: { user: safeUser, access_token } });
} catch (error) {
next(error);
}
}
static async getUser(req: IAuthRequest, res: Response, next: NextFunction) {
try {
const userId = req?.decoded?.id;
if (!userId) throw new HttpError(errorStates.failedAuthentication);
const idStr = String(userId);
const cacheKey = CACHE_USER(idStr);
const cached = await getCache(cacheKey);
if (cached) {
const user = JSON.parse(cached);
return res.status(200).json({ success: true, data: { user } });
}
const user = await UserModel.findById(userId);
if (!user) throw new HttpError(errorStates.failedAuthentication);
const { password: _p, ...safeUser } = user.toObject();
await setCache(cacheKey, JSON.stringify(safeUser));
return res.status(200).json({ success: true, data: { user: safeUser } });
} catch (error) {
next(error);
}
}
}
export default UserController;ts
import { Request, Response, NextFunction } from "express";
import { UserModel } from "../models/user.model";
import { comparePassword } from "../lib/utils";
import { IAuthRequest, IUserDocument } from "../types";
import HttpError, { errorStates } from "../errors";
import { GenerateToken } from "../services/jwt";
import { getCache, setCache, CACHE_USER } from "../services/redis";
class UserController {
static async createUser(req: Request, res: Response, next: NextFunction) {
try {
const { email, full_name, password } = req.body;
const user = (await UserModel.create({ email, full_name, password })) as IUserDocument;
const access_token = GenerateToken({ id: user._id });
return res.status(201).json({
success: true,
data: {
access_token,
user: { _id: user._id, full_name: user.full_name, email: user.email },
},
});
} catch (error) {
next(error);
}
}
static async loginUser(req: Request, res: Response, next: NextFunction) {
try {
const { email, password } = req.body;
const user = (await UserModel.findOne({ email })) as IUserDocument;
if (!user) throw new HttpError(errorStates.invalidEmailOrPassword);
const valid = await comparePassword(password, user.password);
if (!valid) throw new HttpError(errorStates.invalidEmailOrPassword);
const access_token = GenerateToken({ id: user._id });
const { password: _p, ...safeUser } = user.toObject();
return res.status(200).json({ success: true, data: { user: safeUser, access_token } });
} catch (error) {
next(error);
}
}
static async getUser(req: IAuthRequest, res: Response, next: NextFunction) {
try {
const userId = req?.decoded?.id;
if (!userId) throw new HttpError(errorStates.failedAuthentication);
const idStr = String(userId);
const cacheKey = CACHE_USER(idStr);
const cached = await getCache(cacheKey);
if (cached) {
const user = JSON.parse(cached);
return res.status(200).json({ success: true, data: { user } });
}
const user = await UserModel.findById(userId);
if (!user) throw new HttpError(errorStates.failedAuthentication);
const { password: _p, ...safeUser } = user.toObject();
await setCache(cacheKey, JSON.stringify(safeUser));
return res.status(200).json({ success: true, data: { user: safeUser } });
} catch (error) {
next(error);
}
}
}
export default UserController;Routes
路由定义
Mount ():
src/routes/index.tsts
import { Router } from "express";
import userRoute from "./user.route";
const router = Router();
router.use("/user", userRoute);
export default router;User routes ():
src/routes/user.route.tsts
import UserController from "../controllers/user.controller";
import { Router } from "express";
import { RateLimit } from "../services/rate-limit";
import Authentication from "../middlewares/auth";
const router = Router();
router.post("/register", RateLimit({ max: 10, ms: 60000 }), UserController.createUser);
router.post("/login", RateLimit({ max: 10, ms: 60000 }), UserController.loginUser);
router.get("/", RateLimit({ max: 3, ms: 1000 }), Authentication, UserController.getUser);
export default router;路由挂载():
src/routes/index.tsts
import { Router } from "express";
import userRoute from "./user.route";
const router = Router();
router.use("/user", userRoute);
export default router;用户路由():
src/routes/user.route.tsts
import UserController from "../controllers/user.controller";
import { Router } from "express";
import { RateLimit } from "../services/rate-limit";
import Authentication from "../middlewares/auth";
const router = Router();
router.post("/register", RateLimit({ max: 10, ms: 60000 }), UserController.createUser);
router.post("/login", RateLimit({ max: 10, ms: 60000 }), UserController.loginUser);
router.get("/", RateLimit({ max: 3, ms: 1000 }), Authentication, UserController.getUser);
export default router;Errors (src/errors/index.ts
)
src/errors/index.ts错误定义(src/errors/index.ts
)
src/errors/index.tsts
import { IErrorMessage } from "../types";
export const errorStates = {
internalservererror: { message: "Oops! Something's off-track.", error_id: 0, http_code: 500 },
highTraffic: { message: "Too many steps at once! Try again soon.", error_id: 1, http_code: 503 },
rateLimit: { message: "Whoa, slow down! Try again later.", error_id: 2, http_code: 429 },
failedAuthentication: { message: "Not Authenticated", error_id: 3, http_code: 401 },
invalidEmailOrPassword: { message: "Invalid email or password", error_id: 4, http_code: 401 },
tokenExpired: { message: "Session expired—log in again!", error_id: 5, http_code: 401 },
} as const;
class HttpError extends Error {
statusCode: number;
error_id: number;
constructor(args: IErrorMessage) {
super(args.message);
this.statusCode = args.http_code;
this.error_id = args.error_id;
Object.setPrototypeOf(this, HttpError.prototype);
}
}
export default HttpError;ts
import { IErrorMessage } from "../types";
export const errorStates = {
internalservererror: { message: "Oops! Something's off-track.", error_id: 0, http_code: 500 },
highTraffic: { message: "Too many steps at once! Try again soon.", error_id: 1, http_code: 503 },
rateLimit: { message: "Whoa, slow down! Try again later.", error_id: 2, http_code: 429 },
failedAuthentication: { message: "Not Authenticated", error_id: 3, http_code: 401 },
invalidEmailOrPassword: { message: "Invalid email or password", error_id: 4, http_code: 401 },
tokenExpired: { message: "Session expired—log in again!", error_id: 5, http_code: 401 },
} as const;
class HttpError extends Error {
statusCode: number;
error_id: number;
constructor(args: IErrorMessage) {
super(args.message);
this.statusCode = args.http_code;
this.error_id = args.error_id;
Object.setPrototypeOf(this, HttpError.prototype);
}
}
export default HttpError;Middleware – Security (src/middlewares/security.ts
)
src/middlewares/security.ts安全中间件(src/middlewares/security.ts
)
src/middlewares/security.tsts
import { NextFunction, Request, Response } from "express";
const dangerousKeyPattern = /^\$|\.|\$/;
const htmlTagPattern = /<[^>]*>/;
const sanitizeObject = <T>(value: T): T => {
const inner = (val: unknown): unknown => {
if (Array.isArray(val)) return val.map(inner);
if (val && typeof val === "object") {
const obj = val as Record<string, unknown>;
const sanitized: Record<string, unknown> = {};
Object.keys(obj).forEach((key) => {
if (dangerousKeyPattern.test(key)) return;
sanitized[key] = inner(obj[key]);
});
return sanitized;
}
if (typeof val === "string") {
if (htmlTagPattern.test(val)) throw new Error("HTML content is not allowed in input.");
return val;
}
return val;
};
return inner(value) as T;
};
export const securityMiddleware = (req: Request, res: Response, next: NextFunction): void => {
try {
req.body = sanitizeObject(req.body);
const sanitizedQuery = sanitizeObject(req.query);
const sanitizedParams = sanitizeObject(req.params);
Object.keys(req.query).forEach((key) => delete (req.query as any)[key]);
Object.assign(req.query as any, sanitizedQuery as any);
Object.keys(req.params).forEach((key) => delete (req.params as any)[key]);
Object.assign(req.params as any, sanitizedParams as any);
next();
} catch (err) {
res.status(400).json({ message: (err as Error).message || "Invalid input." });
}
};ts
import { NextFunction, Request, Response } from "express";
const dangerousKeyPattern = /^\$|\.|\$/;
const htmlTagPattern = /<[^>]*>/;
const sanitizeObject = <T>(value: T): T => {
const inner = (val: unknown): unknown => {
if (Array.isArray(val)) return val.map(inner);
if (val && typeof val === "object") {
const obj = val as Record<string, unknown>;
const sanitized: Record<string, unknown> = {};
Object.keys(obj).forEach((key) => {
if (dangerousKeyPattern.test(key)) return;
sanitized[key] = inner(obj[key]);
});
return sanitized;
}
if (typeof val === "string") {
if (htmlTagPattern.test(val)) throw new Error("HTML content is not allowed in input.");
return val;
}
return val;
};
return inner(value) as T;
};
export const securityMiddleware = (req: Request, res: Response, next: NextFunction): void => {
try {
req.body = sanitizeObject(req.body);
const sanitizedQuery = sanitizeObject(req.query);
const sanitizedParams = sanitizeObject(req.params);
Object.keys(req.query).forEach((key) => delete (req.query as any)[key]);
Object.assign(req.query as any, sanitizedQuery as any);
Object.keys(req.params).forEach((key) => delete (req.params as any)[key]);
Object.assign(req.params as any, sanitizedParams as any);
next();
} catch (err) {
res.status(400).json({ message: (err as Error).message || "Invalid input." });
}
};Middleware – Error handling (src/middlewares/error-handling.ts
)
src/middlewares/error-handling.ts错误处理中间件(src/middlewares/error-handling.ts
)
src/middlewares/error-handling.tsts
import { Request, Response, NextFunction } from "express";
import * as Sentry from "@sentry/node";
import HttpError, { errorStates } from "../errors";
export const ErrorHandling = (error: unknown, req: Request, res: Response, next: NextFunction): void => {
if (error instanceof HttpError) {
const statusCode = error.statusCode ?? errorStates.internalservererror.http_code;
if ((error as any).sentry) Sentry.captureException(error);
res.status(statusCode).json({
success: false,
error: { error_id: error.error_id, message: error.message, errors: [] },
});
return;
}
const validationErrors: Array<Record<string, string>> = [];
if ((error as any)?.errors) {
Object.entries((error as any).errors).forEach(([key, value]: [string, any]) => {
validationErrors.push({ [key]: value.message });
});
}
Sentry.captureException(error);
const fallback = errorStates.internalservererror;
res.status(fallback.http_code).json({
success: false,
error: { error_id: fallback.error_id, message: fallback.message, errors: validationErrors },
});
};ts
import { Request, Response, NextFunction } from "express";
import * as Sentry from "@sentry/node";
import HttpError, { errorStates } from "../errors";
export const ErrorHandling = (error: unknown, req: Request, res: Response, next: NextFunction): void => {
if (error instanceof HttpError) {
const statusCode = error.statusCode ?? errorStates.internalservererror.http_code;
if ((error as any).sentry) Sentry.captureException(error);
res.status(statusCode).json({
success: false,
error: { error_id: error.error_id, message: error.message, errors: [] },
});
return;
}
const validationErrors: Array<Record<string, string>> = [];
if ((error as any)?.errors) {
Object.entries((error as any).errors).forEach(([key, value]: [string, any]) => {
validationErrors.push({ [key]: value.message });
});
}
Sentry.captureException(error);
const fallback = errorStates.internalservererror;
res.status(fallback.http_code).json({
success: false,
error: { error_id: fallback.error_id, message: fallback.message, errors: validationErrors },
});
};Middleware – Auth (src/middlewares/auth.ts
)
src/middlewares/auth.ts鉴权中间件(src/middlewares/auth.ts
)
src/middlewares/auth.tsts
import HttpError, { errorStates } from "../errors";
import { VerifyToken } from "../services/jwt";
import { Response, NextFunction } from "express";
import { IAuthRequest } from "../types";
export default function Authentication(req: IAuthRequest, res: Response, next: NextFunction) {
try {
const token = req?.headers?.authorization;
if (!token) throw new HttpError(errorStates.failedAuthentication);
const decoded = VerifyToken(token.split("Bearer ")[1]);
req.decoded = decoded;
next();
} catch (error: unknown) {
if ((error as { name?: string })?.name === "TokenExpiredError") {
return next(new HttpError(errorStates.tokenExpired));
}
next(error);
}
}ts
import HttpError, { errorStates } from "../errors";
import { VerifyToken } from "../services/jwt";
import { Response, NextFunction } from "express";
import { IAuthRequest } from "../types";
export default function Authentication(req: IAuthRequest, res: Response, next: NextFunction) {
try {
const token = req?.headers?.authorization;
if (!token) throw new HttpError(errorStates.failedAuthentication);
const decoded = VerifyToken(token.split("Bearer ")[1]);
req.decoded = decoded;
next();
} catch (error: unknown) {
if ((error as { name?: string })?.name === "TokenExpiredError") {
return next(new HttpError(errorStates.tokenExpired));
}
next(error);
}
}Services
服务工具
Rate limit ():
src/services/rate-limit.tsts
import rateLimit from "express-rate-limit";
import { errorStates } from "../errors";
import { CreateLimitType } from "../types";
export const RateLimit = ({ max, ms }: CreateLimitType) =>
rateLimit({
windowMs: ms,
max,
message: { error_id: errorStates.rateLimit.error_id, message: errorStates.rateLimit.message },
});JWT ():
src/services/jwt.tsts
import jwt from "jsonwebtoken";
import env from "../env";
import { TGenerateToken } from "../types";
export const GenerateToken = (payload: TGenerateToken): string =>
jwt.sign(payload, env.PRIVATE_KEY, { expiresIn: env.TOKEN_EXPIRED });
export const VerifyToken = (token: string): TGenerateToken =>
jwt.verify(token, env.PRIVATE_KEY) as TGenerateToken;Redis () — optional:
src/services/redis.tsRedis is used as a cache layer. If is not set, cache is skipped and the app runs without Redis.
REDIS_HOSTts
import Redis from "ioredis";
import env from "../env";
export const CACHE_USER = (id: string) => `user:${id}`;
const USER_CACHE_TTL_SEC = 60;
class RedisClient {
private static instance: Redis | null = null;
public static getInstance(): Redis | null {
if (this.instance !== null) return this.instance;
if (!env.REDIS_HOST || env.REDIS_HOST === "") return null;
try {
this.instance = new Redis({
host: env.REDIS_HOST,
port: Number(env.REDIS_PORT) || 6379,
password: env.REDIS_PASSWORD || undefined,
});
this.instance.on("error", (err) => console.error("Redis error:", err));
return this.instance;
} catch {
return null;
}
}
}
export const setCache = async (key: string, value: string, expirySeconds = 60): Promise<void> => {
const redis = RedisClient.getInstance();
if (!redis) return;
try {
await redis.set(key, value, "EX", expirySeconds);
} catch (err) {
console.error("Redis setCache error:", err);
}
};
export const getCache = async (key: string): Promise<string | null> => {
const redis = RedisClient.getInstance();
if (!redis) return null;
try {
return await redis.get(key);
} catch (err) {
console.error("Redis getCache error:", err);
return null;
}
};
export const deleteCache = async (key: string): Promise<void> => {
const redis = RedisClient.getInstance();
if (!redis) return;
try {
await redis.del(key);
} catch (err) {
console.error("Redis deleteCache error:", err);
}
};Sample: cache-aside in
getUser- Try . If hit, return cached JSON.
getCache(CACHE_USER(userId)) - Else load user from DB, then and return.
setCache(cacheKey, JSON.stringify(safeUser), 60) - When Redis is not configured, /
getCacheno-op; the app still works without cache.setCache
限流工具():
src/services/rate-limit.tsts
import rateLimit from "express-rate-limit";
import { errorStates } from "../errors";
import { CreateLimitType } from "../types";
export const RateLimit = ({ max, ms }: CreateLimitType) =>
rateLimit({
windowMs: ms,
max,
message: { error_id: errorStates.rateLimit.error_id, message: errorStates.rateLimit.message },
});JWT工具():
src/services/jwt.tsts
import jwt from "jsonwebtoken";
import env from "../env";
import { TGenerateToken } from "../types";
export const GenerateToken = (payload: TGenerateToken): string =>
jwt.sign(payload, env.PRIVATE_KEY, { expiresIn: env.TOKEN_EXPIRED });
export const VerifyToken = (token: string): TGenerateToken =>
jwt.verify(token, env.PRIVATE_KEY) as TGenerateToken;Redis工具() —— 可选:
src/services/redis.tsRedis作为缓存层使用。如果未配置,缓存逻辑会被跳过,应用无需Redis也可正常运行。
REDIS_HOSTts
import Redis from "ioredis";
import env from "../env";
export const CACHE_USER = (id: string) => `user:${id}`;
const USER_CACHE_TTL_SEC = 60;
class RedisClient {
private static instance: Redis | null = null;
public static getInstance(): Redis | null {
if (this.instance !== null) return this.instance;
if (!env.REDIS_HOST || env.REDIS_HOST === "") return null;
try {
this.instance = new Redis({
host: env.REDIS_HOST,
port: Number(env.REDIS_PORT) || 6379,
password: env.REDIS_PASSWORD || undefined,
});
this.instance.on("error", (err) => console.error("Redis error:", err));
return this.instance;
} catch {
return null;
}
}
}
export const setCache = async (key: string, value: string, expirySeconds = 60): Promise<void> => {
const redis = RedisClient.getInstance();
if (!redis) return;
try {
await redis.set(key, value, "EX", expirySeconds);
} catch (err) {
console.error("Redis setCache error:", err);
}
};
export const getCache = async (key: string): Promise<string | null> => {
const redis = RedisClient.getInstance();
if (!redis) return null;
try {
return await redis.get(key);
} catch (err) {
console.error("Redis getCache error:", err);
return null;
}
};
export const deleteCache = async (key: string): Promise<void> => {
const redis = RedisClient.getInstance();
if (!redis) return;
try {
await redis.del(key);
} catch (err) {
console.error("Redis deleteCache error:", err);
}
};示例:接口的旁路缓存实现
getUser- 先调用 ,如果命中缓存直接返回缓存的JSON数据。
getCache(CACHE_USER(userId)) - 未命中则从数据库加载用户数据,调用 写入缓存后再返回。
setCache(cacheKey, JSON.stringify(safeUser), 60) - 未配置Redis时,/
getCache方法为空实现,应用无需缓存仍可正常运行。setCache
Lib – utils (src/lib/utils.ts
)
src/lib/utils.ts工具函数(src/lib/utils.ts
)
src/lib/utils.tsts
import bcrypt from "bcrypt";
export const emailRegex = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/;
const SALT_ROUNDS = 10;
export const hashPassword = async (password: string): Promise<string> =>
bcrypt.hash(password, SALT_ROUNDS);
export const comparePassword = async (password: string, hashed: string): Promise<boolean> =>
bcrypt.compare(password, hashed);ts
import bcrypt from "bcrypt";
export const emailRegex = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/;
const SALT_ROUNDS = 10;
export const hashPassword = async (password: string): Promise<string> =>
bcrypt.hash(password, SALT_ROUNDS);
export const comparePassword = async (password: string, hashed: string): Promise<boolean> =>
bcrypt.compare(password, hashed);App (src/app.ts
)
src/app.ts应用初始化(src/app.ts
)
src/app.tsts
import express, { Express, NextFunction, Request, Response } from "express";
import cors from "cors";
import http from "http";
import toobusy from "toobusy-js";
import helmet from "helmet";
import { securityMiddleware } from "./middlewares/security";
import indexRoute from "./routes";
import { ErrorHandling } from "./middlewares/error-handling";
toobusy.maxLag(120);
const app: Express = express();
const server = http.createServer(app);
app.use(helmet());
app.use(cors({
origin: ["http://localhost:3005", "http://localhost:3000"],
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
}));
app.use(express.json({ limit: "100kb" }));
app.use(express.urlencoded({ extended: false }));
app.use(securityMiddleware);
app.use((req, res, next) => {
if (toobusy()) return res.status(529).json({ message: "High Traffic" });
else next();
});
app.use((req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
app.use("/api", indexRoute);
app.get("/", (req: Request, res: Response, next: NextFunction) => {
res.send("Our Backend Running Correctly");
});
app.use(ErrorHandling);
export { app, server };ts
import express, { Express, NextFunction, Request, Response } from "express";
import cors from "cors";
import http from "http";
import toobusy from "toobusy-js";
import helmet from "helmet";
import { securityMiddleware } from "./middlewares/security";
import indexRoute from "./routes";
import { ErrorHandling } from "./middlewares/error-handling";
toobusy.maxLag(120);
const app: Express = express();
const server = http.createServer(app);
app.use(helmet());
app.use(cors({
origin: ["http://localhost:3005", "http://localhost:3000"],
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
}));
app.use(express.json({ limit: "100kb" }));
app.use(express.urlencoded({ extended: false }));
app.use(securityMiddleware);
app.use((req, res, next) => {
if (toobusy()) return res.status(529).json({ message: "High Traffic" });
else next();
});
app.use((req, res, next) => {
res.setHeader("Cache-Control", "no-store");
next();
});
app.use("/api", indexRoute);
app.get("/", (req: Request, res: Response, next: NextFunction) => {
res.send("Our Backend Running Correctly");
});
app.use(ErrorHandling);
export { app, server };Repository and docs
仓库与文档
- GitHub: https://github.com/laskar-ksatria/building-observable-nodejs-api
- Full setup, env table, and API overview: see the repository README.
- GitHub地址: https://github.com/laskar-ksatria/building-observable-nodejs-api
- 完整配置说明、环境变量清单、API概览请查看仓库README。
Validation / done checklist
验证/完成检查清单
When helping the user run or extend this template, confirm:
- exists with at least
.env,PORT,MONGODB_URI,PRIVATE_KEY.TOKEN_EXPIRED - MongoDB is reachable (and Redis if used).
- is set if error monitoring is desired.
SENTRY_DSN - After ,
npm run devreturns a success message.GET / - Auth routes (,
/api/user/register,/api/user/login) respond correctly.GET /api/user - New resources follow the convention: types -> model -> controller -> route -> mount in index.
帮助用户运行或扩展该模板时,请确认以下事项:
- 文件存在,且至少包含
.env、PORT、MONGODB_URI、PRIVATE_KEY变量。TOKEN_EXPIRED - MongoDB可正常连接(如果使用Redis则Redis也需要可连接)。
- 如果需要错误监控功能,已配置。
SENTRY_DSN - 执行后,
npm run dev接口返回成功提示。GET / - 鉴权相关接口(、
/api/user/register、/api/user/login)可正常响应。GET /api/user - 新增资源遵循规范:定义类型 -> 创建模型 -> 实现控制器 -> 配置路由 -> 在路由入口挂载。