Appearance
实战: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' } } }这份文件体现的写作原则:
- 义务头部(
Location/ETag/If-Match/Retry-After/WWW-Authenticate)全部显式声明——文档即合同; - 约束写进 Schema(maxLength/maximum/pattern/required/additionalProperties)——校验规则只定义一次;
- 枚举值全小写;
default与实现一致; operationId用listArticles/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: false、Retry-After: required、security: []这些细节,正是"规范落地到机器可读"的样子。
6. 下一步
全部六篇学完,用附录两张表做日常速查 → API 设计检查清单