本文 AI 產出,尚未審核

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,保持文件與程式碼 同步可維護

本文將以兩個主流工具 tsoazod‑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.xTypeScript 5.x,並假設已使用 npm i express @types/express 安裝基礎套件。

2.1 使用 tsoa 的完整流程

  1. 安裝套件
npm i -D tsoa ts-node typescript @types/node
npm i express @types/express
  1. 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"
  }
}
  1. 建立一個簡易的 DTO(Data Transfer Object)
// src/models/UserDto.ts
export interface UserDto {
  /** 使用者唯一 ID */
  id: number;
  /** 使用者名稱 */
  name: string;
  /** Email,符合 RFC5322 格式 */
  email: string;
}
  1. 撰寫 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) };
  }
}
  1. 生成路由與 OpenAPI 文件
npx tsoa routes   # 產生 src/routes/*.ts
npx tsoa spec     # 產生 dist/swagger.json
  1. 在 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)

  1. 安裝套件
npm i -D zod @types/zod zod-to-openapi ts-node typescript
npm i express @types/express
  1. 定義 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>;
  1. 建立 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;
  1. 產生 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();
  1. 在 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 進階範例:整合 tsoaclass-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 的 minimummaxLengthformat: 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 routesexpress-zod-api 自動掛載),或在 CI 中 檢查 swagger.json 是否與程式碼匹配。
大型專案產生的 spec 過大 所有路由一次生成會讓文件龐大,影響 UI 載入速度 使用 分段 (components/schemas 放在獨立檔案) 或 分版本(v1、v2)管理;tsoa 支援 spec.outputDirectory 分別生成多個檔案。
錯誤訊息不友善 Zod 的錯誤結構過於技術化 在 Express 中把 zod 錯誤轉為 JSON API 格式,並在 OpenAPI 的 responses 中描述 400 錯誤模型。

建議的工作流程

  1. 設計階段:先在 TypeScript 中定義 DTO / Schema(單一來源)。
  2. 實作階段:使用 tsoazod-to-openapi 同時產生路由與文件。
  3. 測試階段:將產生的 swagger.json 與自動化測試(如 swagger-cli validate)結合,確保文件符合規範。
  4. 部署階段:在 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 UIPostmanPrism 等工具,形成完整的 API 開發、生產與測試生態系。

只要掌握 單一真相來源(SSOT)的概念,未來在擴充功能或升級版本時,OpenAPI 文件就會像影子一樣自動跟隨,讓開發者可以把更多心力放在業務邏輯本身,而不是手動維護繁瑣的說明文件。祝你在 ExpressJS + TypeScript 的旅程中,寫出乾淨、同步、易於協作的 API! 🚀