楼层: 首页/ 软件技术/ Rust 后端技术栈/ OpenAPI 文档:utoipa + Swagger UI 全流程
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 里,破坏性变更(字段变必填、类型变化)能被人工拦下。