ExpressJS (TypeScript) ─ Swagger / OpenAPI 文件化:用 TypeScript 自動生成 OpenAPI Schema(tsoa、zod‑to‑openapi)
簡介
在現代的 Web API 開發流程中,文件化 已不再是事後補救的工作,而是與程式碼同等重要的第一級產出。
良好的 OpenAPI(舊稱 Swagger)規範可以讓前端、測試、第三方服務快速取得 API 的結構、參數與回傳格式,甚至直接產生 SDK、測試腳本與 API Mock 伺服器。
然而手動編寫 *.yaml 或 *.json 文件既繁瑣又容易與實際程式碼脫節。幸好 TypeScript 本身的型別系統提供了 靜態資訊,只要搭配適當的程式庫,就能在編譯階段自動產生符合 OpenAPI 3.0 標準的 schema,保持文件與程式碼 同步、可維護。
本文將以兩個主流工具 tsoa 與 zod‑to‑openapi 為例,說明如何在 ExpressJS + TypeScript 專案中自動產生 OpenAPI 文件,並分享常見陷阱與最佳實踐,幫助你在實務上快速上手。
核心概念
1. 為什麼要「從 TypeScript 產生」OpenAPI?
| 手動撰寫 | 從 TypeScript 產生 |
|---|---|
| 易遺漏參數、型別錯誤 | 型別即為單一真相來源 (SSOT) |
| 改動程式碼後忘記同步文件 | 編譯時自動同步 |
| 產生文件耗時且易出錯 | 一鍵生成、CI/CD 可自動化 |
| 難以維護大型 API | 可使用 decorator / schema 驅動的方式模組化 |
結論:只要把型別資訊「搬」到 OpenAPI,就能省下大量維護成本。
2. 兩大策略:Decorator‑Based vs Schema‑Based
| 策略 | 代表工具 | 特色 |
|---|---|---|
| Decorator‑Based(以類別、方法、參數加上裝飾器) | tsoa |
直接在 controller 中寫註解,類似 Swagger‑UI 的 UI 風格;支援自動產生路由與驗證 |
| Schema‑Based(以外部驗證 schema 為中心) | zod-to-openapi + express-zod-api |
把驗證與文件分離,使用 Zod 這類型安全的驗證庫;適合已有 Zod 驗證的專案 |
兩者都能產生符合 OpenAPI 3.0 的 JSON/YAML,選擇哪一種取決於團隊習慣與現有代碼基礎。
程式碼範例
以下範例均基於 Express 4.x、TypeScript 5.x,並假設已使用 npm i express @types/express 安裝基礎套件。
2.1 使用 tsoa 的完整流程
- 安裝套件
npm i -D tsoa ts-node typescript @types/node
npm i express @types/express
- tsoa 設定檔
tsoa.json
{
"entryFile": "src/server.ts",
"controllerPathGlobs": ["src/controllers/*.ts"],
"spec": {
"outputDirectory": "dist",
"specVersion": 3,
"basePath": "/api",
"securityDefinitions": {
"api_key": {
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
}
},
"routes": {
"basePath": "/api",
"routesDir": "src/routes"
}
}
- 建立一個簡易的 DTO(Data Transfer Object)
// src/models/UserDto.ts
export interface UserDto {
/** 使用者唯一 ID */
id: number;
/** 使用者名稱 */
name: string;
/** Email,符合 RFC5322 格式 */
email: string;
}
- 撰寫 Controller 並加上 tsoa 裝飾器
// src/controllers/UserController.ts
import { Controller, Get, Route, Path, Post, Body, SuccessResponse } from "tsoa";
import { UserDto } from "../models/UserDto";
@Route("users") // 產生路徑 /api/users
export class UserController extends Controller {
/** 取得單一使用者 */
@Get("{id}")
public async getUser(@Path() id: number): Promise<UserDto> {
// 模擬 DB 讀取
return { id, name: "Alice", email: "alice@example.com" };
}
/** 建立新使用者 */
@SuccessResponse("201", "Created") // 回傳 201
@Post()
public async createUser(@Body() request: UserDto): Promise<UserDto> {
// 直接回傳輸入資料作為示範
return { ...request, id: Math.floor(Math.random() * 1000) };
}
}
- 生成路由與 OpenAPI 文件
npx tsoa routes # 產生 src/routes/*.ts
npx tsoa spec # 產生 dist/swagger.json
- 在 Express 中掛載產生的路由與 Swagger UI
// src/server.ts
import express from "express";
import swaggerUi from "swagger-ui-express";
import * as swaggerDocument from "./swagger.json";
import "./routes/UserControllerRoutes";
const app = express();
app.use(express.json());
// Swagger UI
app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerDocument));
app.listen(3000, () => console.log("Server listening on http://localhost:3000"));
重點:只要修改
UserDto或 controller 方法,重新執行tsoa spec即可得到最新的 OpenAPI 文件。
2.2 使用 zod + zod-to-openapi 的方式(Schema‑Based)
- 安裝套件
npm i -D zod @types/zod zod-to-openapi ts-node typescript
npm i express @types/express
- 定義 Zod Schema 並轉換為 OpenAPI
// src/schemas/UserSchema.ts
import { z } from "zod";
import { OpenAPIObject, extendZodWithOpenApi } from "zod-to-openapi";
extendZodWithOpenApi(z); // 啟用 .openapi() 方法
export const UserSchema = z.object({
id: z.number().int().positive().openapi({ description: "使用者唯一 ID" }),
name: z.string().min(1).openapi({ description: "使用者名稱" }),
email: z.string().email().openapi({ description: "有效的 Email 地址" })
});
export type User = z.infer<typeof UserSchema>;
- 建立 Express 路由,並使用 Zod 進行驗證
// src/routes/user.ts
import express from "express";
import { UserSchema, User } from "../schemas/UserSchema";
const router = express.Router();
/** GET /api/users/:id */
router.get("/:id", (req, res) => {
const id = Number(req.params.id);
// 假資料
const user: User = { id, name: "Bob", email: "bob@example.com" };
res.json(user);
});
/** POST /api/users */
router.post("/", (req, res) => {
const parseResult = UserSchema.safeParse(req.body);
if (!parseResult.success) {
return res.status(400).json(parseResult.error.format());
}
const newUser: User = { ...parseResult.data, id: Date.now() };
res.status(201).json(newUser);
});
export default router;
- 產生 OpenAPI 文件
// src/openapi.ts
import { OpenApiGeneratorV3 } from "zod-to-openapi";
import { UserSchema } from "./schemas/UserSchema";
const generator = OpenApiGeneratorV3.create({
title: "Demo API",
version: "1.0.0",
description: "使用 zod-to-openapi 產生的文件"
});
generator.addSchema("User", UserSchema); // 註冊 schema
// 手動描述兩個路由(簡化示例)
generator.addPath("/users/{id}", {
get: {
operationId: "getUser",
summary: "取得單一使用者",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "integer", format: "int64" }
}
],
responses: {
"200": {
description: "成功回傳使用者資料",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/User" }
}
}
}
}
}
});
generator.addPath("/users", {
post: {
operationId: "createUser",
summary: "建立新使用者",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/User" }
}
}
},
responses: {
"201": {
description: "使用者建立成功",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/User" }
}
}
}
}
}
});
export const openapiDocument = generator.generateDocument();
- 在 Express 中掛載 Swagger UI
// src/server.ts
import express from "express";
import swaggerUi from "swagger-ui-express";
import userRouter from "./routes/user";
import { openapiDocument } from "./openapi";
const app = express();
app.use(express.json());
app.use("/api/users", userRouter);
app.use("/docs", swaggerUi.serve, swaggerUi.setup(openapiDocument));
app.listen(3000, () => console.log("Server running at http://localhost:3000"));
技巧:
zod-to-openapi只需要在 Zod schema 上呼叫.openapi()即可補充描述,與驗證邏輯完全共用,避免重複定義。
2.3 進階範例:整合 tsoa 與 class-validator(混合驗證)
若專案已使用
class-validator進行 DTO 驗證,仍可透過tsoa產生文件,只要在 DTO 上加上@Validate裝飾器即可。
// src/dto/CreateUserDto.ts
import { IsEmail, IsInt, Min, Length } from "class-validator";
export class CreateUserDto {
@IsInt()
@Min(1)
id!: number;
@Length(1, 50)
name!: string;
@IsEmail()
email!: string;
}
// src/controllers/UserCtrl.ts
import { Controller, Post, Route, Body, SuccessResponse } from "tsoa";
import { CreateUserDto } from "../dto/CreateUserDto";
@Route("users")
export class UserCtrl extends Controller {
@SuccessResponse("201", "Created")
@Post()
public async create(@Body() body: CreateUserDto) {
// 這裡仍可使用 class-validator 的 validate() 函式
return { ...body, id: Math.floor(Math.random() * 1000) };
}
}
執行 tsoa spec 後,CreateUserDto 內的驗證規則會自動映射為 OpenAPI 的 minimum、maxLength、format: email 等屬性。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解法 / 最佳實踐 |
|---|---|---|
| 型別不一致 | TypeScript 介面與 Zod schema 分別定義,易出現不匹配 | 使用 單一來源:若採 Zod,直接 type X = z.infer<typeof Schema>;若採 tsoa,所有 DTO 都以 class / interface 為主,避免重複定義。 |
| 自訂格式遺失 | OpenAPI 只支援有限的 format,自訂驗證(如正則)不會自動轉換 |
在 tsoa 中使用 @Example、@Pattern,或在 Zod .openapi({ pattern: "..." }) 手動補上。 |
| 路由與文件不同步 | 手動新增路由但忘記在 spec 中註冊 | 把路由生成交給工具(tsoa routes、express-zod-api 自動掛載),或在 CI 中 檢查 swagger.json 是否與程式碼匹配。 |
| 大型專案產生的 spec 過大 | 所有路由一次生成會讓文件龐大,影響 UI 載入速度 | 使用 分段 (components/schemas 放在獨立檔案) 或 分版本(v1、v2)管理;tsoa 支援 spec.outputDirectory 分別生成多個檔案。 |
| 錯誤訊息不友善 | Zod 的錯誤結構過於技術化 | 在 Express 中把 zod 錯誤轉為 JSON API 格式,並在 OpenAPI 的 responses 中描述 400 錯誤模型。 |
建議的工作流程
- 設計階段:先在 TypeScript 中定義 DTO / Schema(單一來源)。
- 實作階段:使用
tsoa或zod-to-openapi同時產生路由與文件。 - 測試階段:將產生的
swagger.json與自動化測試(如swagger-cli validate)結合,確保文件符合規範。 - 部署階段:在 CI 中跑
npm run build && tsoa spec(或node generate-openapi.ts),將產出的文件上傳至 API Gateway 或放在/docs供前端即時查閱。
實際應用場景
| 場景 | 為何需要自動生成 OpenAPI | 推薦工具 | 實作要點 |
|---|---|---|---|
| 微服務間的合約管理 | 各服務需要共享 API contract,避免版本衝突 | tsoa(自動產生路由 + spec) |
以 monorepo 方式統一 tsoa.json,每次發佈自動產出 swagger.yaml,供其他服務作為依賴。 |
| 前端與後端同步開發 | 前端使用 Swagger Codegen 產生 TypeScript SDK | zod-to-openapi(與 Zod 共用驗證) |
只要更新 Zod schema,就能即時產生新 SDK,降低前後端溝通成本。 |
| 第三方合作夥伴 | 必須提供完整的 API 文件與測試用 Mock 伺服器 | tsoa + swagger-ui-express |
把 /docs 直接部署在子域名,並結合 prism(OpenAPI Mock)提供即時測試環境。 |
| 內部開發者工具 | 需要自動生成 API 測試腳本或 Postman collection | 任一工具皆可,搭配 openapi-to-postmanv2 |
在 CI 中把 swagger.json 轉成 Postman collection,讓 QA 直接匯入使用。 |
總結
- 自動化產生 OpenAPI 是提升 API 可維護性、降低溝通成本的關鍵。
- tsoa 以 decorator 為主,適合想一次解決路由、驗證與文件的團隊;zod‑to‑openapi 則以 schema 為核心,適合已經使用 Zod 進行驗證的專案。
- 兩者皆能在 TypeScript 中直接取得型別資訊,保證 程式碼 ⇔ 文件 的同步性。
- 實務上,建議將產生文件的步驟納入 CI/CD,並結合 Swagger UI、Postman、Prism 等工具,形成完整的 API 開發、生產與測試生態系。
只要掌握 單一真相來源(SSOT)的概念,未來在擴充功能或升級版本時,OpenAPI 文件就會像影子一樣自動跟隨,讓開發者可以把更多心力放在業務邏輯本身,而不是手動維護繁瑣的說明文件。祝你在 ExpressJS + TypeScript 的旅程中,寫出乾淨、同步、易於協作的 API! 🚀