Skip to content

实战:OpenAPI 文档与工具链

本章导读:OpenAPI Specification(OAS)3.1 是 REST 契约的"机器可读形态"——写好它,文档、Mock、客户端代码、契约测试、安全扫描全部免费。本章演示:一份规范的 OAS 文件怎么写、Spring Boot 如何自动生成、如何用 spectral/oasdiff 把它变成"质量门禁"。

1. OAS 是什么

  • 当前版本:3.1.x(OpenAPI Initiative / Linux Foundation)。与 3.0 的最大区别:字段定义完全对齐 JSON Schema 2020-12(可用 patternProperties、真正的 type: [string, "null"])。
  • 定位:描述"接口长什么样",不描述实现——与 RFC 9110 的关系类似"海报与法律"。
  • 一个文档 = info + servers + paths/webhooks + components(schemas/parameters/headers/securitySchemes/responses/examples)。

2. 手写一份高质量 OAS(节选博客系统)

yaml
# openapi.yaml
openapi: 3.1.0
info:
  title: 博客系统 API
  version: 1.0.0
  description: >
    遵循 RFC 9110 语义的 REST API。所有错误响应为 application/problem+json
    (RFC 9457)。集合端点强制分页。POST 创建要求 Idempotency-Key 头。
  contact: { name: 麻瓜教程, url: https://book.anysome.cn }
  license: { name: MIT }

servers:
  - url: https://api.example.com
    description: 生产
  - url: https://staging.api.example.com
    description: 预发

tags:
  - { name: articles, description: 文章资源 }
  - { name: jobs,     description: 异步作业 }

security:
  - bearerAuth: []

paths:
  /v1/articles:
    get:
      tags: [articles]
      operationId: listArticles
      summary: 分页查询文章列表(公开)
      security: []                       # 覆盖全局:允许匿名
      parameters:
        - { name: page,     in: query, schema: { type: integer, minimum: 1, default: 1 } }
        - { name: pageSize, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
        - { name: status,   in: query, schema: { $ref: '#/components/schemas/ArticleStatus' } }
        - { name: tag,      in: query, schema: { type: string } }
        - name: sort
          in: query
          description: 逗号分隔;- 前缀为降序。默认 -createdAt
          schema: { type: string, pattern: '^-?(createdAt|viewCount|updatedAt)(,-?(createdAt|viewCount|updatedAt))*$', default: '-createdAt' }
      responses:
        '200':
          description: 文章页
          headers:
            Cache-Control: { schema: { type: string }, description: public, max-age=30 }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ArticlePage' }
        '400': { $ref: '#/components/responses/BadRequest' }
        default: { $ref: '#/components/responses/UnexpectedError' }

    post:
      tags: [articles]
      operationId: createArticle
      summary: 创建文章草稿
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: 客户端生成的唯一键;相同键重放首次结果
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateArticleRequest' }
      responses:
        '201':
          description: 创建成功
          headers:
            Location:
              required: true
              schema: { type: string, format: uri-reference }
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Article' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  /v1/articles/{articleId}:
    get:
      tags: [articles]
      operationId: getArticle
      parameters:
        - { $ref: '#/components/parameters/ArticleId' }
        - in: header
          name: If-None-Match
          required: false
          schema: { type: string }
      responses:
        '200':
          description: 文章
          headers:
            ETag: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Article' }
        '304': { description: 表示未变化(条件 GET 命中) }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { description: 文章已永久删除 }
    put:
      tags: [articles]
      operationId: replaceArticle
      parameters:
        - { $ref: '#/components/parameters/ArticleId' }
        - in: header
          name: If-Match
          required: true                  # 规范强制乐观锁的文档化落点
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReplaceArticleRequest' }
      responses:
        '200': { description: 替换成功,返回新表示, headers: { ETag: { schema: { type: string } } },
                 content: { application/json: { schema: { $ref: '#/components/schemas/Article' } } } }
        '403': { $ref: '#/components/responses/Forbidden' }
        '412': { description: 版本冲突(version-conflict) }
        '428': { description: 缺少 If-Match }
    delete:
      tags: [articles]
      operationId: deleteArticle
      parameters: [ { $ref: '#/components/parameters/ArticleId' } ]
      responses:
        '204': { description: 已删除 }
        '403': { $ref: '#/components/responses/Forbidden' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    ArticleId:
      name: articleId
      in: path
      required: true
      schema: { type: string, format: uuid }

  schemas:
    ArticleStatus:
      type: string
      enum: [draft, inReview, published, archived]
      x-extensible-enum-note: 客户端必须容忍未知枚举值

    CreateArticleRequest:
      type: object
      required: [title, content]
      additionalProperties: false        # 字段白名单在契约层的表达
      properties:
        title:   { type: string, minLength: 1, maxLength: 200 }
        excerpt: { type: [string, "null"], maxLength: 500 }
        content: { type: string }
        tagIds:
          type: array
          maxItems: 10
          items: { type: string, format: uuid }

    Article:
      type: object
      required: [id, title, status, createdAt, updatedAt, links]
      properties:
        id: { type: string, format: uuid }
        title: { type: string }
        excerpt: { type: [string, "null"] }
        content: { type: string }
        status: { $ref: '#/components/schemas/ArticleStatus' }
        tagIds: { type: array, items: { type: string, format: uuid } }
        authorId: { type: string, format: uuid }
        viewCount: { type: integer, format: int64 }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        publishedAt: { type: [string, "null"], format: date-time }
        version: { type: integer }
        links:
          type: object
          additionalProperties: { type: string, format: uri-reference }

    Problem:                              # RFC 9457 全组件复用
      type: object
      properties:
        type:   { type: string, format: uri, default: about:blank }
        title:  { type: string }
        status: { type: integer, format: int32, minimum: 400, maximum: 599 }
        detail: { type: [string, "null"] }
        instance: { type: [string, "null"], format: uri-reference }
        code: { type: string }
        errors:
          type: array
          items:
            type: object
            properties:
              field: { type: string }
              constraint: { type: string }
              message: { type: string }
        traceId: { type: string }

    ArticlePage:
      type: object
      required: [items, page, pageSize]
      properties:
        items: { type: array, items: { $ref: '#/components/schemas/Article' } }
        page: { type: integer }
        pageSize: { type: integer }
        total: { type: [integer, "null"] }
        links:
          type: object
          properties:
            next: { type: [string, "null"], format: uri-reference }
            prev: { type: [string, "null"], format: uri-reference }

  responses:
    BadRequest:
      description: 请求无效
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    Unauthorized:
      description: 未认证
      headers:
        WWW-Authenticate: { schema: { type: string } }
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    Forbidden:
      description: 禁止访问
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    NotFound:
      description: 资源不存在
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    ValidationError:
      description: 语义校验失败
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    TooManyRequests:
      description: 超出配额
      headers:
        Retry-After: { required: true, schema: { type: integer } }
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
    UnexpectedError:
      description: 意外错误
      content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }

这份文件体现的写作原则:

  1. 义务头部(Location/ETag/If-Match/Retry-After/WWW-Authenticate)全部显式声明——文档即合同;
  2. 约束写进 Schema(maxLength/maximum/pattern/required/additionalProperties)——校验规则只定义一次;
  3. 枚举值全小写;default 与实现一致;
  4. operationIdlistArticles/getArticle/createArticle/replaceArticle/deleteArticle——它决定生成代码的方法名。

3. Spring Boot 自动生成 OAS(springdoc)

xml
<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
  <version>2.8.x</version>
</dependency>
  • 启动后 /v3/api-docs(JSON/YAML)与 /swagger-ui.html;CI 里 curl /v3/api-docs.yaml > openapi.yaml 导出入库;
  • 注解补充手写规范无法表达的信息:
java
@Operation(summary = "分页查询文章列表",
           security = {},                              // 匿名
           responses = {
               @ApiResponse(responseCode = "200", description = "文章页",
                   headers = @Header(name = "Cache-Control", description = "public, max-age=30")),
               @ApiResponse(responseCode = "400", description = "非法分页参数")
           })
@GetMapping
public Page<ArticleResponse> list(/* … */) { … }

两种工作流:

路线适合风险
Design-first:先写/评审 openapi.yaml,再生成桩代码对外契约、跨团队手写与实现漂移——必须 CI diff
Code-first:实现 + 注解导出文档内部快速迭代注解遗漏导致契约失真——必须启用"未知字段/枚举"校验兜底

4. 工具链全景

openapi.yaml ──┬─► 文档站:Redoc / Scalar / Stoplight(渲染 + 在线调试)
               ├─► Mock:Prism(mock 服务,前端并行开发)、Apifox
               ├─► 代码生成:openapi-generator(Java/Kotlin/TS 客户端与骨架)
               ├─► 契约测试:Pact / Dredd / Schemathesis(属性化 fuzz 测试!)
               ├─► Lint:spectral(命名/安全规则自定义)
               └─► 变更审查:oasdiff(breaking diff + 弃用报告)

4.1 spectral 自定义规则(团队规范固化示例)

yaml
# .spectral.yml
extends: ["spectral:oas"]
rules:
  path-must-be-lowercase-kebab:
    description: 路径必须小写 kebab-case
    given: "$.paths[*]~"
    severity: error
    then: { function: pattern, functionOptions: { match: "^/[a-z0-9/_:{}.-]*$" } }
  write-ops-must-define-error-responses:
    description: 写操作必须声明 4xx problem 响应
    given: "$.paths[*][?(@property === 'post' || @property === 'put' || @property === 'patch' || @property === 'delete')]"
    severity: warn
    then:
      field: responses
      function: schema
      functionOptions:
        schema:
          anyOf:
            - { required: ["400"] }
            - { required: ["403"] }
            - { required: ["422"] }
            - { required: ["429"] }

4.2 oasdiff 作为破坏性变更门禁

bash
oasdiff breaking release-1.4.yaml openapi.yaml   # 退出码非零 = 存在破坏性变更 → CI 红灯
oasdiff deprecated base.yaml head.yaml           # 弃用报告

5. 本章小结

  • OAS 是"活的设计文档":义务头部、参数约束、错误形状全部写进 YAML,一次定义、六处复用(文档/Mock/代码/测试/Lint/Diff)。
  • Design-first 适合对外契约,Code-first 适合内部迭代,两者都必须接 CI 门禁(spectral + oasdiff)。
  • additionalProperties: falseRetry-After: requiredsecurity: [] 这些细节,正是"规范落地到机器可读"的样子。

6. 下一步

全部六篇学完,用附录两张表做日常速查 → API 设计检查清单