兩個方向:Code-First vs Contract-First

Code-First:先寫 code,從 code annotation 生成 OpenAPI 文件。

// NestJS 的 Code-First
@ApiOperation({ summary: '建立用戶' })
@ApiResponse({ status: 201, type: UserResponse })
@Post('/users')
async createUser(@Body() dto: CreateUserDto) {
  return this.userService.create(dto);
}

Swagger UI 從這些 annotation 自動生成——文件和 code 住在一起,不容易漏更新。

Contract-First:先寫 OpenAPI spec(YAML/JSON),再從 spec 生成 code skeleton 或型別定義。

# openapi.yaml 先寫
paths:
  /users:
    post:
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserResponse'

怎麼選

  • Code-First:適合小團隊、已有 code 要補文件、前後端是同一個人
  • Contract-First:適合前後端分離開發(spec 先定好,前後端並行開發)、多個 client 消費(mobile、web、第三方)、API 設計本身就是產品(公開 API)

Express + Code-First(swagger-jsdoc)

Express 沒有內建文件生成,用 JSDoc 風格的 annotation:

import swaggerJSDoc from 'swagger-jsdoc';
import swaggerUi from 'swagger-ui-express';
 
const swaggerOptions = {
  definition: {
    openapi: '3.0.0',
    info: { title: 'My API', version: '1.0.0' },
    components: {
      securitySchemes: {
        bearerAuth: {
          type: 'http',
          scheme: 'bearer',
          bearerFormat: 'JWT',
        },
      },
    },
  },
  apis: ['./src/routes/**/*.ts'],  // 掃描這些檔案裡的 JSDoc
};
 
const swaggerSpec = swaggerJSDoc(swaggerOptions);
app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
/**
 * @openapi
 * /api/users:
 *   post:
 *     summary: 建立用戶
 *     tags: [Users]
 *     security:
 *       - bearerAuth: []
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: '#/components/schemas/CreateUserRequest'
 *     responses:
 *       201:
 *         description: 建立成功
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/UserResponse'
 *       400:
 *         description: 驗證錯誤
 */
router.post('/users', validate(createUserSchema), createUserHandler);

缺點:annotation 是字串,不能靜態型別檢查。Zod schema 和 OpenAPI schema 要各自維護,容易不同步。


Zod → OpenAPI 自動生成(更好的做法)

zod-to-openapi@asteasolutions/zod-to-openapi,讓 Zod schema 同時成為 OpenAPI schema:

import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi';
import { z } from 'zod';
 
const registry = new OpenAPIRegistry();
 
// 定義 schema(同時用在 validation 和文件)
const CreateUserSchema = registry.register(
  'CreateUserRequest',
  z.object({
    name: z.string().min(1).max(100).openapi({ example: 'Alice' }),
    email: z.string().email().openapi({ example: 'alice@example.com' }),
    role: z.enum(['admin', 'viewer']).openapi({ example: 'viewer' }),
  })
);
 
const UserResponseSchema = registry.register(
  'UserResponse',
  z.object({
    id: z.string().uuid(),
    name: z.string(),
    email: z.string().email(),
    createdAt: z.string().datetime(),
  })
);
 
// 定義 route
registry.registerPath({
  method: 'post',
  path: '/users',
  summary: '建立用戶',
  tags: ['Users'],
  security: [{ bearerAuth: [] }],
  request: { body: { content: { 'application/json': { schema: CreateUserSchema } } } },
  responses: {
    201: {
      description: '建立成功',
      content: { 'application/json': { schema: UserResponseSchema } },
    },
  },
});
 
// 生成 OpenAPI spec
const generator = new OpenApiGeneratorV3(registry.definitions);
const spec = generator.generateDocument({
  openapi: '3.0.0',
  info: { title: 'My API', version: '1.0.0' },
});

好處:Zod schema 改了,OpenAPI spec 自動跟著改,永遠同步。


Contract-First 工作流程

1. 前後端一起設計 → 寫 openapi.yaml
2. 前端從 spec 生成型別(openapi-typescript)
3. 後端從 spec 生成路由骨架或用作 request validation
4. 前後端並行開發,用 mock server(Prism)模擬 API
5. API 實作完成後接上真實後端

前端從 OpenAPI spec 生成型別

npx openapi-typescript openapi.yaml -o src/types/api.d.ts

生成的型別:

// 自動生成,不要手動改
export interface paths {
  '/users': {
    post: {
      requestBody: { content: { 'application/json': components['schemas']['CreateUserRequest'] } };
      responses: {
        201: { content: { 'application/json': components['schemas']['UserResponse'] } };
      };
    };
  };
}

後端用 spec 驗 requestexpress-openapi-validator):

import OpenApiValidator from 'express-openapi-validator';
 
app.use(OpenApiValidator.middleware({
  apiSpec: './openapi.yaml',
  validateRequests: true,   // request 不符合 spec → 400
  validateResponses: true,  // response 不符合 spec → 500(開發時找 bug 用)
}));

這樣一份 spec 同時驗前端送來的 request 和後端回傳的 response,任何人的實作出軌都會在開發時被抓到。


Swagger UI 的實際設定

// 只在非 production 開放 Swagger UI
if (process.env.NODE_ENV !== 'production') {
  app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
    swaggerOptions: {
      persistAuthorization: true,  // 重整頁面不清 auth token
    },
  }));
}
 
// 輸出 spec(讓 CI 可以 diff)
app.get('/docs/spec.json', (req, res) => res.json(swaggerSpec));

Production 要不要開放 Swagger UI?

  • 公開 API(給第三方開發者):要,這是文件本身
  • 內部 API:不要——暴露所有 endpoint 細節是安全風險

Versioning + OpenAPI

API 版本和 OpenAPI spec 版本要對應:

/docs/v1/spec.json   → v1 的 spec
/docs/v2/spec.json   → v2 的 spec

或者用一份 spec,用 x-api-version extension 標記哪些 endpoint 在哪個版本出現。

舊版 spec 不要刪——外部開發者可能還在查舊版文件。


延伸閱讀