10
OpenAPI 文档:utoipa + Swagger UI 全流程
Code-First OpenAPI · utoipa + Swagger UI
第 6 章你已经见过 utoipa 的四行示例。但那四行只能让你"跑起来",真实项目里要面对的是:十几个模块、几十个接口、带泛型的分页返回、需要鉴权的接口、要跟前端同步 schema 变更。这一章把 utoipa 从"能跑"推到"能交付":结构体怎么标注、handler 怎么注解、泛型怎么表达、Swagger UI 怎么挂、鉴权怎么在文档里体现,以及哪些地方会静默出错——OpenAPI 这类东西最怕的不是报错,而是"文档和代码不一致但没人发现"。
为什么必须"代码即文档":手写 YAML 的三重成本
很多团队的做法是先写 API 文档、再照着实现。这个流程在小规模时没问题,一旦接口超过 20 个,文档就必然开始漂移。
| 成本 | 手写 YAML / Markdown | 代码生成(utoipa) |
| 一致性 |
改字段名要同时改 Rust 结构体、YAML、前端 TS 类型,三处漏一处就出 bug |
结构体是唯一来源,改一次全同步;文档不可能与实现不一致 |
| 可验证性 |
文档对不对只能靠人看,测试无法覆盖 |
schema 是编译产物,CI 里能跑契约测试;前端可直接用它生成 TS 类型 |
| 维护动力 |
"反正没人看",越写越糊,最后变成废文件 |
顺手就写了(加一行 derive),而且 Swagger UI 能用能点,团队真的会用 |
| 前端对接 |
前端靠问、靠读代码猜字段是否可选 |
Option<T> 自动变成 nullable,#[serde(skip_serializing_if)] 也可表达,前端直接生成类型 |
论"代码即文档"的本质是消除重复的真相来源
① 一个事实只应该有一个权威表述。"用户名字段叫 user_name 还是 username"这件事,在系统里已经由 Rust 结构体 + serde 属性表达了一次。再在 YAML 里写第二遍,就是制造了一个必然会不一致的副本。
② 所以 utoipa 的设计不是"生成文档",而是"提取已有事实"。#[derive(ToSchema)] 读的是你的结构体定义和 serde 属性,把已经存在的信息翻译成 OpenAPI schema。这就是为什么它能和 serde 属性联动、为什么不需要你重复描述字段类型。
③ 剩下真正需要人补的是"意图"。字段类型的含义(为什么这个字段叫 total_cents)、错误码的语义、业务约束(金额不能为负),这些类型系统表达不了,才需要 #[schema(example = ..., description = ...)] 手写。好的 API 文档 = 自动提取的 90% + 手工补充的关键 10%,而不是从零手写 100%。
ToSchema:让结构体变成 schema
Cargo.toml(版本要对齐:utoipa-swagger-ui 的主版本按 crate 文档的兼容表选)
[dependencies]
# axum_extras 让 utoipa 认识 axum 的 Path/Query/Json 等提取器
# chrono / uuid 让这些类型在 schema 里正确呈现为 date-time / uuid 格式
utoipa = { version = "5", features = ["axum_extras", "chrono", "uuid"] }
utoipa-swagger-ui = { version = "9", features = ["axum"] }
# 可选:直接把 axum Router 与 OpenAPI 绑定,少写一份 paths 列表
utoipa-axum = "0.2"
axum = "0.8"
serde = { version = "1", features = ["derive"] }
给 DTO 加 ToSchema,并让 serde 属性一起生效
use utoipa::ToSchema;
use serde::{Deserialize, Serialize};
/// 创建用户的请求体
// ToSchema 会读 #[serde(...)] 属性,所以命名策略、可选性都能自动同步
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct NewUser {
/// 登录名,3~32 个字符
// 文档注释会自动变成 schema 的 description,不用另写一遍
#[schema(example = "alice", min_length = 3, max_length = 32)]
pub user_name: String,
// Option -> nullable 且非 required;这是"前端最需要知道"的信息
#[schema(example = "alice@example.com")]
pub email: Option<String>,
// 枚举字段:Rust 枚举 + ToSchema 会自动生成 enum 约束
pub plan: Plan,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Plan {
Free,
Pro,
// 带数据的枚举会生成 oneOf 结构,语义比"字符串枚举"精确得多
Enterprise { seat_count: u32 },
}
/// 用户响应
#[derive(Debug, Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct UserDto {
#[schema(example = 42)]
pub id: i64,
pub user_name: String,
// chrono 类型的默认表示不友好,用 value_type 显式指定成字符串 + 格式
#[schema(value_type = String, format = DateTime, example = "2026-09-20T10:00:00Z")]
pub created_at: chrono::DateTime<chrono::Utc>,
}
统一错误响应:一个 schema 覆盖所有错误码
#[derive(Debug, Serialize, ToSchema)]
pub struct ApiError {
/// 业务错误码,前端按它做分支
#[schema(example = "USER_NOT_FOUND")]
pub code: String,
/// 给用户看的提示(可直接展示)
#[schema(example = "用户不存在")]
pub message: String,
}
// 用 utoipa 的 responses! 宏复用"一类响应",避免每个接口都写一遍 401/500
use utoipa::openapi::response::Response;
// 实际项目里更常见的做法是定义 type alias + 在 #[utoipa::path] 里重复引用
// components(schemas(ApiError)) 只登记一次,各接口用 body = ApiError 引用
#[utoipa::path]:给 handler 打注解
注解的内容要和函数的真实签名保持一致——utoipa 不会校验这两者是否匹配,写错了不会编译失败,只会让文档说谎。这正是后面"常见坑"要重点讲的地方。
一个完整的 handler 注解
use axum::{extract::{Path, Query, State}, Json};
use utoipa::{IntoParams, ToSchema};
// 查询参数单独定义一个结构体,并 derive IntoParams
#[derive(Debug, Deserialize, IntoParams)]
#[into_params(parameter_in = Query)]
pub struct ListQuery {
/// 页码,从 1 开始
#[param(example = 1, minimum = 1)]
pub page: Option<u32>,
/// 每页条数,最大 100
#[param(example = 20, minimum = 1, maximum = 100)]
pub size: Option<u32>,
}
// 注解的每个键都要和函数真实行为对上
#[utoipa::path(
// 1) HTTP 方法 + 路径。axum 0.8 的路径参数写法是 {id},不是 :id
get,
path = "/api/users/{id}",
// 2) 路径参数:名字必须与 path 里的占位符完全一致
params(
("id" = i64, Path, description = "用户 ID", example = 42),
ListQuery, // 结构体形式直接引用 IntoParams
),
// 3) 响应:status 可以是数字,body 必须是登记过 schema 的类型
responses(
(status = 200, description = "查询成功", body = UserDto),
(status = 404, description = "用户不存在", body = ApiError),
(status = 500, description = "服务内部错误", body = ApiError),
),
// 4) 声明该接口需要 bearer 鉴权,[] 是 scopes(这里为空)
security(("bearer_auth" = [])),
// 5) tag 决定 Swagger UI 里的分组,同一模块用同一个 tag
tag = "users",
)]
// 函数签名必须与上面的注解一致:Path<i64> 对应 params 里的 id
pub async fn get_user(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> Result<Json<UserDto>, (axum::http::StatusCode, Json<ApiError>)> {
let user = state.repo.find(id).await.map_err(internal_err)?;
user.map(|u| Json(u.into())).ok_or_else(|| not_found("USER_NOT_FOUND", "用户不存在"))
}
带请求体的 POST(Json body 与文件上传的写法差别)
#[utoipa::path(
post,
path = "/api/users",
// request_body 必须显式写:默认 utoipa 不会自动推断你收的是什么 body
request_body = NewUser,
responses(
(status = 201, description = "创建成功", body = UserDto),
(status = 422, description = "参数校验失败", body = ApiError),
),
security(("bearer_auth" = [])),
tag = "users",
)]
pub async fn create_user(
State(state): State<AppState>,
Json(req): Json<NewUser>,
) -> Result<(axum::http::StatusCode, Json<UserDto>), ApiError> { /* ... */ }
// multipart 上传要写 request_body(content = "multipart/form-data")
#[utoipa::path(
post, path = "/api/files",
request_body(content = "multipart/form-data", description = "上传文件"),
responses((status = 200, body = FileDto)),
)]
坑:注解与函数签名"看起来对",其实对不上,而且不会报错
#[utoipa::path] 与函数体是两次独立的声明,utoipa 不会交叉校验。所以下面这些错误都能编译通过,但文档会骗人:
① path 里的参数名与 params 里的名字不一致。比如 path = "/api/users/{id}" 但写成了 ("user_id" = i64, Path)。Swagger UI 上这个参数会变成"必填但无法映射",前端按文档写就 404。
② 忘了写 request_body。POST 接口在 Swagger UI 里就没有请求体输入框,调用方只能看源码。
③ responses 里写了 body = UserDto,但函数实际返回 Result<Json<UserListDto>, _>。这类最致命:文档说返回单个对象,实际返回列表,前端照着写直接运行时报错。防它的办法是把 handler 的返回类型写成 type 别名,注解里也用同一个别名,改的时候一处改。
④ axum 0.8 的路径语法变更没跟上。axum 0.8 把路径参数从 /:id 改成了 /{id}(通配符从 /*rest 改成 /{*rest})。如果 #[utoipa::path(path = ...)] 还写旧语法,Swagger UI 里生成的 URL 和真实路由不匹配,点击"Try it out"必然失败。
#[derive(OpenApi)]:把散落的接口组装成一份文档
主文档结构:paths / components / security / servers 一个都不能少
use utoipa::{
openapi::{OpenApi, SecurityScheme},
openapi::security::{Http, HttpAuthScheme},
Modify, OpenApi as OpenApiDerive,
};
// 用 Modify 钩子补充"derive 语法写不出来"的部分(比如 securitySchemes)
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut OpenApi) {
if let Some(components) = openapi.components.as_mut() {
// 这个 key "bearer_auth" 就是各接口 security(("bearer_auth" = [])) 引用的名字
components.add_security_scheme(
"bearer_auth",
// type: http, scheme: bearer, bearerFormat: JWT
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
);
}
}
}
#[derive(OpenApiDerive)]
#[openapi(
// 1) info:标题版本,前端生成的客户端会带上
info(
title = "订单服务 API",
version = "0.1.0",
description = "订单服务的对外 HTTP 接口",
contact(name = "平台组", email = "platform@example.com"),
),
// 2) paths:列出所有需要出现在文档里的 handler
// 漏写一个 handler 不会报错,只会让它从文档里消失 —— 所以要靠测试兜底
paths(
crate::api::users::get_user,
crate::api::users::create_user,
crate::api::users::list_users,
crate::api::orders::create_order,
),
// 3) components(schemas(...)):登记所有会被引用的类型
// 被 paths 里 body = X 引用却没登记的类型,会导致 schema 引用断裂
components(schemas(
UserDto, NewUser, Plan, ApiError,
OrderDto, NewOrder,
PageUser, // 泛型实例的别名,见下一段
)),
// 4) tags:给 Swagger UI 里的分组补说明
tags(
(name = "users", description = "用户管理"),
(name = "orders", description = "订单管理"),
),
// 5) 全局默认安全要求:写在这里等于所有接口都要鉴权
// 注意:不想鉴权的接口(如 /login)需要在它自己的 security() 里显式覆盖
modifiers(&SecurityAddon),
servers(
(url = "http://localhost:3000", description = "本地开发"),
(url = "https://api.example.com", description = "生产环境"),
),
)]
pub struct ApiDoc;
泛型返回类型:必须用 #[aliases] 显式实例化
use utoipa::ToSchema;
// 泛型结构体本身不能直接当 schema 用,因为 T 是不确定的
#[derive(Debug, Serialize, ToSchema)]
pub struct Page<T> {
// value_type 告诉 utoipa:"这里的 T 在 schema 里就写成它的实际类型"
#[schema(value_type = Vec<Object>)]
pub items: Vec<T>,
pub total: u64,
pub page: u32,
}
// 关键:用 #[aliases] 给每个具体实例起个名字并登记到 components
#[derive(Debug, Serialize, ToSchema)]
#[aliases(PageUser = Page<UserDto>, PageOrder = Page<OrderDto>)]
pub struct Page<T> { /* 同上 */ }
// 之后在 handler 注解里就能直接引用 PageUser
// responses((status = 200, body = PageUser))
// 漏掉 aliases 的典型报错是 Swagger UI 里 $ref 指向一个不存在的 schema,页面直接白屏
挂载 Swagger UI 与 openapi.json
三行代码就能把交互式文档挂上去。但"挂在什么路径、生产环境要不要挂"是要做决策的。
挂载(推荐 swagger-ui 用一个独立前缀,openapi.json 单独暴露)
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
let app = axum::Router::new()
.route("/api/users", axum::routing::post(create_user))
.route("/api/users/{id}", axum::routing::get(get_user))
// SwaggerUi 本身是一个 Router,直接 merge 进来
.merge(
SwaggerUi::new("/swagger-ui")
// 第一个参数是"文档 JSON 的访问路径",Swagger UI 会去这里拉 spec
.url("/api-docs/openapi.json", ApiDoc::openapi())
// 加多个 spec 时就是多个 .url(),UI 右上角会出现下拉切换
.url("/api-docs/internal.json", InternalApiDoc::openapi()),
)
.with_state(state);
// 访问 http://localhost:3000/swagger-ui 即可看到交互式文档
// 访问 http://localhost:3000/api-docs/openapi.json 拿到原始 spec
// 单独暴露 spec 的另一种写法(给自己写构建脚本/CI 用)
async fn openapi_json() -> axum::Json<utoipa::openapi::OpenApi> {
axum::Json(ApiDoc::openapi())
}
// .route("/api-docs/openapi.json", get(openapi_json))
用构建脚本把 spec 落盘,接进 CI 与前端代码生成
// examples/dump_openapi.rs —— 一个可执行示例,把 spec 写成文件
// cargo run --example dump_openapi 之后,CI 可以拿这个文件做两件事:
// 1) 与上一版本做 diff,提醒"有破坏性变更"
// 2) 交给 openapi-typescript / openapi-generator 生成前端类型
fn main() {
let spec = my_crate::ApiDoc::openapi();
// pretty() 让 diff 可读,务必不要用紧凑输出
let json = spec.to_pretty_json().expect("序列化 OpenAPI 失败");
std::fs::write("openapi.json", json).expect("写入失败");
// 退出码 0 表示成功,CI 可以根据文件是否变化决定失败或提交
}
# CI 里检查"文档是否被忘记更新"
# cargo run --quiet --example dump_openapi
# git diff --exit-code openapi.json || (echo "openapi.json 未同步,请重新生成" && exit 1)
坑:Swagger UI 生产环境不关,等于把内网结构图挂在门口
① /swagger-ui 会暴露全部接口、全部参数、全部错误码。对攻击者来说这是一份免费的"目标清单":哪些接口没写 security、哪个参数是 i64(可以试 SQL 注入)、有没有内部管理接口。生产环境的正确做法是二选一:只在内网/办公网可访问(网关层做 IP 白名单或只绑内网网卡),或者用环境变量开关决定是否 merge 进来:
let mut app = axum::Router::new()
.route("/api/users/{id}", axum::routing::get(get_user));
// 只有非生产环境才挂 Swagger UI;spec 本身仍然可以保留(供前端生成代码用)
if !cfg.is_production() {
app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
} else {
// 生产环境只暴露 spec 文件,且要求管理员鉴权
app = app.route("/api-docs/openapi.json", axum::routing::get(openapi_json));
}
鉴权在文档里怎么表达:security scheme 与按接口覆盖
如果文档里不写"这个接口需要带 token",前端接的时候会漏掉认证;如果全局写了鉴权却忘了给 /login 开豁免,Swagger UI 上就无法调登录接口,形成死锁。
三种粒度的写法
// 粒度 1:全局 —— 在 #[openapi(modifiers(&SecurityAddon))] 里登记 scheme 后,
// 再给每个接口单独写 security(("bearer_auth" = []),这是最清晰的方式(显式 > 隐式)。
// 粒度 2:接口级 —— 需要鉴权的接口
#[utoipa::path(get, path = "/api/me", security(("bearer_auth" = [])), responses((status = 200, body = UserDto)))]
// 粒度 3:公开接口 —— 显式声明"不需要鉴权"
// security(()) 表示空的安全要求,覆盖掉全局设置
#[utoipa::path(post, path = "/api/login", security(()), request_body = LoginReq, responses((status = 200, body = TokenPair)))]
// 如果你的鉴权是"API Key 放在 X-API-Key 头",scheme 定义换成 ApiKey:
// use utoipa::openapi::security::{ApiKey, ApiKeyValue};
// components.add_security_scheme("api_key",
// SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("X-API-Key"))));
// OAuth2 / OIDC 也可以在文档里表达,Swagger UI 会显示 "Authorize" 按钮并支持授权码流程:
// use utoipa::openapi::security::{AuthorizationCode, Flow, OAuth2, Scopes};
// components.add_security_scheme("oauth2", SecurityScheme::OAuth2(OAuth2::with_description(
// [Flow::AuthorizationCode(AuthorizationCode::new(
// "https://idp.example.com/authorize",
// "https://idp.example.com/token",
// Scopes::from_iter([("read:orders", "读取订单"), ("write:orders", "创建订单")]),
// ))],
// "本服务使用 OIDC 授权码流程",
// )));
更省事的集成方式:utoipa-axum 与 axum-extra
前面那种"在 #[openapi(paths(...))] 里手写一遍 handler 列表"的做法,痛点很明显:新加接口时容易忘登记,而这个疏忽不会报错,只会让接口从文档里消失。utoipa-axum 用 routes! 宏把"注册路由"和"登记文档"合并成一次操作。
OpenApiRouter:路由即文档
use utoipa_axum::{router::OpenApiRouter, routes};
// 先像平时一样建 router,但用 OpenApiRouter 的类型
let (router, api) = OpenApiRouter::new()
// routes! 宏直接从 handler 上读取 #[utoipa::path] 注解,自动登记
.routes(routes!(get_user, create_user, list_users))
.routes(routes!(create_order))
// 嵌套子模块时同样适用,避免 paths 列表与模块结构脱节
.nest("/api/v1", v1_router)
// 补 metadata 与 security scheme
.with_state(state)
// split_for_parts 一次性拿到 "axum Router" 与 "OpenApi 文档"
.split_for_parts();
// 给文档补上 info / servers 等 derive 写不了的部分
let mut api = api;
api.merge(ApiDocMetadata);
let app = router
.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api));
axum-extra 与「类型改名」的正确姿势
// TypedHeader 让"自定义头"在 schema 里也有位置(比如 X-Request-Id)
use axum_extra::{headers::{Authorization, Bearer}, TypedHeader};
pub async fn with_header(
// TypedHeader 提取的头参数不会被 utoipa 自动记录,需要在 params() 里手动补
TypedHeader(auth): TypedHeader<Authorization<Bearer>>,
) -> String { /* ... */ }
// 注解里补上:params(("Authorization" = String, Header, description = "Bearer token"))
// ---- 类型改名导致的 schema 引用断裂,怎么彻底避免 ----
// 场景:把 struct User 重命名为 UserDto,但只改了定义、忘了改 components(schemas(User))
// 结果:openapi.json 里出现 $ref: "#/components/schemas/User",而 components 里没有 User
// Swagger UI 会白屏或报 "Could not resolve reference"
//
// 工程上的解法:
// 1) 用 IDE 的重命名重构,而不是手改字符串
// 2) 在 CI 里加一条"文档自洽性测试"(见下)
// 3) 每个 DTO 只在一个 mod 里定义并导出,components 列表集中一处写,方便 Review
// ---- 文档自洽性测试:把"引用断裂"变成 CI 里会红的测试 ----
#[cfg(test)]
mod spec_tests {
#[test]
fn openapi_has_no_dangling_refs() {
let spec = super::ApiDoc::openapi();
let json = spec.to_json().expect("序列化失败");
// 把所有 $ref 里的 schema 名抽出来逐个比对 components
let defined: std::collections::HashSet<String> =
spec.components.as_ref().map(|c| c.schemas.keys().cloned().collect()).unwrap_or_default();
for name in collect_refs(&json) {
assert!(defined.contains(&name), "悬空引用: {name} 未登记到 components(schemas(...))");
}
}
fn collect_refs(json: &str) -> Vec<String> {
// 简单实现:正则抓 "#/components/schemas/Xxx" 并去重
let re = regex::Regex::new(r"#/components/schemas/([A-Za-z0-9_]+)").unwrap();
let mut v: Vec<String> = re.captures_iter(json).map(|c| c[1].to_string()).collect();
v.sort(); v.dedup(); v
}
}
论为什么"文档测试"比"文档写得好"更重要
① 文档的失效是静默的。代码写错会编译失败、会测试失败;文档写错只会让下一个读它的人浪费时间。在成本模型里,静默失效的问题必须用"会红的门禁"来抓,靠人细心是抓不住的。
② 最值钱的三条门禁:一是上面的"悬空 $ref 检查",抓类型改名漏改;二是"所有 handler 都必须在文档里出现"(用 routes! 就能天然满足,因为它和路由注册同源);三是"openapi.json 与仓库里的产物一致",抓"改了代码忘了重新生成"。
③ 更上一层是把文档接进契约测试。openapi.json 是机器可读的契约,前端可以用 openapi-typescript 生成 TS 类型,也可以在 CI 里跑 schemathesis 之类的工具对着 schema 自动生成请求去轰你的服务,检查实际响应是否符合声明。到这一步,文档才从"给人看的"变成"给机器验证的"——这才是 code-first 的完整价值。
常见坑清单
| 现象 | 原因 | 修法 |
| Swagger UI 白屏 / "Could not resolve reference" |
某个 body = X 引用的类型没登记进 components(schemas(...)),$ref 悬空 |
补齐 schemas 列表;加"悬空引用"测试;泛型记得写 #[aliases] |
| 接口在代码里能用,文档里找不到 |
#[openapi(paths(...))] 里漏登记;或 handler 没有 #[utoipa::path] |
改用 utoipa-axum 的 routes!,让注册与文档同源 |
分页接口的 schema 是空的 / items 是 object |
泛型 Page<T> 没实例化,utoipa 无法知道 T 是什么 |
用 #[aliases(PageUser = Page<UserDto>)],并在注解里引用 PageUser |
DateTime / Uuid / Decimal 字段 schema 报错或不显示 |
对应 crate 的 utoipa feature 没开,或需要 value_type 映射 |
开 chrono/uuid feature;不支持的第三方类型用 #[schema(value_type = String)] |
| Swagger UI 上点 "Try it out" 一直 404 |
path 写的是 axum 0.7 的 /:id,而 axum 0.8 实际路由是 /{id} |
注解路径与 Router::route 的字符串保持完全一致(含版本语法) |
security 写了但 UI 上没有 Authorize 按钮 |
只写了接口级 security(("bearer_auth" = [])),没有通过 Modify 登记 securityScheme |
实现 Modify 并 add_security_scheme,确保名字完全一致 |
| 前端生成的 TS 类型字段名不对 |
结构体上用了 #[serde(rename_all = "camelCase")],但 ToSchema 的 rename 没生效 |
确认 serde feature 已开、属性写在结构体层级;#[schema(rename_all = ...)] 也能单独覆盖 |
| 生产环境被别人扫到接口清单 |
/swagger-ui 无条件挂载 |
用环境变量控制是否 merge;或只在内网网关后可访问 |
坑:把文档当"上线前补一次"的活,它一定会烂
即使上了 utoipa,也有一类团队会把它写坏:注解随手糊一个 responses((status = 200)) 不带 body、path 字符串和真实路由对不上、params 里写 description 但参数名是错的。这些都不会报错,只会让文档逐渐退化成"不能信的东西"——而一旦团队认定文档不可信,就再也没人维护它了。
所以纪律比工具重要:① 每个 DTO 必须有 ToSchema 且登记进 components;② 每个 handler 必须有 #[utoipa::path],responses 里的 status 覆盖所有真实返回分支;③ CI 里跑"悬空引用 + spec 与仓库一致"两条门禁;④ 把 openapi.json 的 diff 纳入 Code Review——在 MR 里看到"某个字段从必填变可选",比上线后被前端投诉便宜得多。
记
本章小结
① 代码即文档的本质是"消除重复的真相来源":类型事实从结构体提取,人只补意图(example/description/业务约束)。
② DTO 加 #[derive(ToSchema)],#[serde(...)] 会一起生效;/// 文档注释自动变成 description;Option 自动 nullable。
③ handler 加 #[utoipa::path(...)],四件事必写:path、params、responses、tag;有 body 的再加 request_body。
④ 组装用 #[derive(OpenApi)]:paths + components(schemas(...)) + Modify(securityScheme)+ servers;泛型必须 #[aliases] 实例化。
⑤ 挂载只三行:SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi());生产环境要关或加访问控制。
⑥ 鉴权用 security(("bearer_auth" = [])) 表达,公开接口用 security(()) 显式豁免。
⑦ 用 utoipa-axum 的 routes! 让"注册路由"与"登记文档"同源,从根上消灭"漏写 paths"。
⑧ 一定要加"文档门禁":悬空 $ref 检查 + openapi.json 与仓库一致,否则文档必然静默失效。
小练习 · 五道 OpenAPI 自测题(点开看答案)
1.(排错题)Swagger UI 打开报 "Could not resolve reference: #/components/schemas/User"。怎么查?
查看答案
典型是"引用存在但 schema 没登记"。检查两点:#[openapi(components(schemas(...)))] 里是否包含所有被 body = X 引用到的类型;如果是泛型(Page<T>),是否用 #[aliases(PageUser = Page<UserDto>)] 实例化并登记。最稳的办法是加一条 CI 测试:正则抓 $ref 里的名字,逐个断言在 components 里存在。
2.(概念题)为什么 #[utoipa::path] 写错了不会编译失败?怎么降低风险?
查看答案
因为注解与函数签名是两份独立声明,宏只生成文档数据、不参与类型检查。降低风险的办法:① 用 utoipa-axum 的 routes! 让路径与路由注册同源;② handler 的返回类型抽成 type 别名,注解与实现共用;③ 用 schemathesis 之类的工具拿 spec 自动发请求做契约校验,让"文档与实现不符"变成测试失败。
3.(进阶题)分页接口返回 Page<UserDto>,文档里 items 却显示成 object[],为什么?
查看答案
泛型没被实例化,utoipa 只知道有个 T,只能退化成 object。解决:在泛型结构体上加 #[aliases(PageUser = Page<UserDto>)],把 PageUser 登记进 components(schemas(...)),并在 responses 里 body = PageUser。注意要写成 #[aliases(...)] 而不是给字段写死 value_type,后者会让所有实例长得一样。
4.(安全题)生产环境暴露 /swagger-ui 的实际风险是什么?
查看答案
它是一份"给攻击者的接口清单":暴露所有路由、参数类型、必填项、错误码,还标出哪些接口没写 security(即未鉴权)。攻击者可以据此快速定位攻击面,甚至用 "Try it out" 直接探测(如果服务可达)。做法:用环境变量控制是否挂载,或用网关做 IP 白名单;至少要保证 /swagger-ui 不在公网可达。
5.(工程题)怎样保证"改了代码但忘了更新 openapi.json"这件事不会漏?
查看答案
把 spec 落盘 + CI 校验。写一个 --example dump_openapi,用 to_pretty_json() 输出到仓库里的 openapi.json;CI 里执行后 git diff --exit-code openapi.json,有差异就失败并提示"请重新生成"。这样 spec 的每一次变更都会出现在 Code Review 的 diff 里,破坏性变更(字段变必填、类型变化)能被人工拦下。