📖 第21课:OpenAPI文档

阶段五:实战项目

OpenAPI(原名Swagger)是REST API描述的事实标准。一个好的OpenAPI文档就是API的完整契约——自动生成文档、客户端SDK、服务端存根、测试用例。本课从OpenAPI 3.1规范到Swagger UI集成,从设计先行到代码生成,全面掌握API文档工程化。

📋 OpenAPI 3.1规范结构

// openapi.yaml - 完整OpenAPI文档
openapi: "3.1.0"
info:
  title: 电商API
  description: |
    电商平台RESTful API文档。
    
    ## 认证方式
    所有需要认证的接口使用Bearer Token:
    ```
    Authorization: Bearer <token>
    ```
    
    ## 速率限制
    - 普通用户: 100请求/分钟
    - 高级用户: 1000请求/分钟
  version: "2.0.0"
  contact:
    name: API支持团队
    email: api@example.com
    url: https://docs.example.com
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: https://api.example.com/v2
    description: 生产环境
  - url: https://staging-api.example.com/v2
    description: 预发布环境
  - url: http://localhost:3000/v2
    description: 本地开发

tags:
  - name: Products
    description: 商品管理
  - name: Orders
    description: 订单管理
  - name: Auth
    description: 认证授权

# ===== 安全方案 =====
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
    OAuth2:
      type: oauth2
      flows:
        authorizationCode:
          authorizationUrl: https://auth.example.com/authorize
          tokenUrl: https://auth.example.com/token
          scopes:
            read: 读取权限
            write: 写入权限

  # ===== 可复用参数 =====
  parameters:
    PageParam:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
    LimitParam:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    ProductIdParam:
      name: id
      in: path
      required: true
      schema:
        type: integer
      description: 商品ID

  # ===== 可复用Schema =====
  schemas:
    Product:
      type: object
      required: [id, name, price, category, status]
      properties:
        id:
          type: integer
          example: 1
        name:
          type: string
          minLength: 1
          maxLength: 200
          example: 机械键盘
        price:
          type: number
          format: double
          minimum: 0
          exclusiveMinimum: true
          example: 599.00
        category:
          type: string
          enum: [电子产品, 服装, 食品, 图书, 家居]
          example: 电子产品
        stock:
          type: integer
          minimum: 0
          example: 120
        status:
          type: string
          enum: [active, inactive, discontinued]
          example: active
        tags:
          type: array
          items:
            type: string
          maxItems: 10
          example: [外设, 热销]
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CreateProductRequest:
      type: object
      required: [name, price, category]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        price:
          type: number
          format: double
          minimum: 0
          exclusiveMinimum: true
        category:
          type: string
          enum: [电子产品, 服装, 食品, 图书, 家居]
        stock:
          type: integer
          minimum: 0
          default: 0
        tags:
          type: array
          items:
            type: string
          maxItems: 10

    Error:
      type: object
      required: [success, error]
      properties:
        success:
          type: boolean
          example: false
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              example: VALIDATION_ERROR
            message:
              type: string
              example: 请求数据验证失败
            details:
              type: array
              items:
                type: object
                properties:
                  field:
                    type: string
                  message:
                    type: string

    Pagination:
      type: object
      properties:
        page:
          type: integer
        limit:
          type: integer
        total:
          type: integer
        totalPages:
          type: integer
        hasNext:
          type: boolean
        hasPrev:
          type: boolean

  # ===== 可复用响应 =====
  responses:
    BadRequest:
      description: 请求参数错误
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            success: false
            error:
              code: VALIDATION_ERROR
              message: 请求数据验证失败
    Unauthorized:
      description: 未认证
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: 资源不存在
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: 请求频率超限
      headers:
        Retry-After:
          schema:
            type: integer
          description: 重试等待秒数
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

# ===== 路由 =====
paths:
  /products:
    get:
      summary: 获取商品列表
      description: 支持过滤、排序和分页
      tags: [Products]
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/LimitParam'
        - name: category
          in: query
          schema:
            type: string
          description: 按分类过滤
        - name: minPrice
          in: query
          schema:
            type: number
          description: 最低价格
        - name: sort
          in: query
          schema:
            type: string
            enum: [price, -price, name, -name, createdAt, -createdAt]
          description: 排序字段(-前缀降序)
      responses:
        '200':
          description: 成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Product'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
        '400':
          $ref: '#/components/responses/BadRequest'

    post:
      summary: 创建商品
      tags: [Products]
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateProductRequest'
      responses:
        '201':
          description: 创建成功
          headers:
            Location:
              schema:
                type: string
              description: 新资源的URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/Product'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: 商品已存在
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /products/{id}:
    get:
      summary: 获取商品详情
      tags: [Products]
      parameters:
        - $ref: '#/components/parameters/ProductIdParam'
      responses:
        '200':
          description: 成功
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  data:
                    $ref: '#/components/schemas/Product'
        '404':
          $ref: '#/components/responses/NotFound'

    patch:
      summary: 部分更新商品
      tags: [Products]
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProductIdParam'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                price:
                  type: number
                stock:
                  type: integer
                status:
                  type: string
                  enum: [active, inactive]
      responses:
        '200':
          description: 更新成功
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      summary: 删除商品
      tags: [Products]
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProductIdParam'
      responses:
        '204':
          description: 删除成功
        '404':
          $ref: '#/components/responses/NotFound'

🛠️ 实战:Swagger UI集成

// swagger-server.js - 带Swagger UI的API服务
const express = require('express');
const swaggerUi = require('swagger-ui-express');
const yaml = require('yaml');
const fs = require('fs');

const app = express();
app.use(express.json());

// 加载OpenAPI文档
const openApiDoc = yaml.parse(fs.readFileSync('./openapi.yaml', 'utf8'));

// Swagger UI
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDoc, {
  customCss: '.swagger-ui .topbar { display: none }',
  customSiteTitle: '电商API文档',
}));

// API规范端点(供工具消费)
app.get('/openapi.json', (req, res) => res.json(openApiDoc));
app.get('/openapi.yaml', (req, res) => {
  res.setHeader('Content-Type', 'text/yaml');
  res.send(fs.readFileSync('./openapi.yaml'));
});

// 数据
const products = [
  { id: 1, name: '机械键盘', price: 599, category: '电子产品', stock: 120, status: 'active', tags: ['外设'], createdAt: '2025-01-01', updatedAt: '2025-01-15' },
  { id: 2, name: '无线鼠标', price: 199, category: '电子产品', stock: 250, status: 'active', tags: ['外设'], createdAt: '2025-01-02', updatedAt: '2025-01-14' },
  { id: 3, name: '设计模式', price: 69, category: '图书', stock: 500, status: 'active', tags: ['编程'], createdAt: '2025-01-03', updatedAt: '2025-01-03' },
];
let nextId = 4;

// ===== 路由(与OpenAPI文档一一对应)=====

app.get('/v2/products', (req, res) => {
  const { page = 1, limit = 20, category, minPrice, sort } = req.query;
  let data = [...products];
  if (category) data = data.filter(p => p.category === category);
  if (minPrice) data = data.filter(p => p.price >= Number(minPrice));
  if (sort) {
    const desc = sort.startsWith('-');
    const field = desc ? sort.slice(1) : sort;
    data.sort((a, b) => desc ? (b[field] > a[field] ? 1 : -1) : (a[field] > b[field] ? 1 : -1));
  }
  const p = Math.max(1, parseInt(page));
  const l = Math.min(100, parseInt(limit));
  const total = data.length;
  data = data.slice((p - 1) * l, p * l);
  res.json({ success: true, data, pagination: { page: p, limit: l, total, totalPages: Math.ceil(total / l), hasNext: p * l < total, hasPrev: p > 1 } });
});

app.get('/v2/products/:id', (req, res) => {
  const product = products.find(p => p.id === parseInt(req.params.id));
  if (!product) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: `商品 ${req.params.id} 不存在` } });
  res.json({ success: true, data: product });
});

app.post('/v2/products', (req, res) => {
  const { name, price, category, stock, tags } = req.body;
  if (!name || price === undefined || !category) {
    return res.status(400).json({ success: false, error: { code: 'VALIDATION_ERROR', message: 'name, price, category为必填项' } });
  }
  const product = { id: nextId++, name, price, category, stock: stock || 0, status: 'active', tags: tags || [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
  products.push(product);
  res.setHeader('Location', `/v2/products/${product.id}`);
  res.status(201).json({ success: true, data: product });
});

app.patch('/v2/products/:id', (req, res) => {
  const product = products.find(p => p.id === parseInt(req.params.id));
  if (!product) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: `商品 ${req.params.id} 不存在` } });
  Object.assign(product, req.body, { updatedAt: new Date().toISOString() });
  res.json({ success: true, data: product });
});

app.delete('/v2/products/:id', (req, res) => {
  const idx = products.findIndex(p => p.id === parseInt(req.params.id));
  if (idx === -1) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: `商品 ${req.params.id} 不存在` } });
  products.splice(idx, 1);
  res.status(204).end();
});

app.listen(3000, () => {
  console.log('📚 API文档服务运行在 http://localhost:3000');
  console.log('📖 Swagger UI: http://localhost:3000/docs');
  console.log('📋 OpenAPI规范: http://localhost:3000/openapi.json');
});

测试文档驱动API

# 安装依赖
npm install express swagger-ui-express yaml

# 启动服务
node swagger-server.js

# 访问Swagger UI
# 浏览器打开 http://localhost:3000/docs

# 用curl测试(与文档描述一致)
curl -s http://localhost:3000/v2/products | jq .
curl -s http://localhost:3000/v2/products?category=图书 | jq .
curl -s -X POST http://localhost:3000/v2/products \
  -H "Content-Type: application/json" \
  -d '{"name":"新商品","price":99,"category":"图书"}' | jq .
{
  "success": true,
  "data": {"id":4,"name":"新商品","price":99,"category":"图书","stock":0,"status":"active","tags":[]}
}

📝 本课小结

  1. OpenAPI 3.1是REST API描述的行业标准
  2. 文档即契约:驱动开发、测试、SDK生成
  3. components复用schemas/parameters/responses,保持DRY
  4. Swagger UI提供交互式文档,可直接测试API
  5. 设计先行(Design-First):先写OpenAPI再实现代码
  6. 提供JSON和YAML两种格式的规范端点

💪 练习

练习1:为本课API添加认证

在OpenAPI文档中添加BearerAuth安全方案,标记需要认证的端点,实现JWT中间件。

练习2:生成客户端SDK

使用openapi-generator从OpenAPI文档生成TypeScript客户端SDK:npx @openapitools/openapi-generator-cli generate -i openapi.yaml -g typescript-axios -o ./sdk

🏆 本课成就:文档工程师