如何让 FastAPI 接口文档真正可用
先说结论
FastAPI 自带的文档能力是它最被低估的优势之一。很多项目只是"能自动生成一个 Swagger 页面",但离"前端拿着文档就能直接对接"还差得很远。
一个真正规范的接口文档至少要覆盖三件事:
请求侧:参数有描述、有约束、有示例,前端不用猜
响应侧:成功响应有
response_model,错误响应有responses组织层面:接口有分组、有摘要,模型放在该放的位置
这篇就是按这个顺序来讲:先把请求侧的文档补齐,再把响应侧的契约理顺,最后处理元信息和模型组织。
请求侧:让参数自解释
很多项目的请求参数在文档里长这样:一个参数名,一个类型,没了。前端要么靠猜,要么去翻代码,要么来问你。
FastAPI 提供了足够多的方式让请求参数在文档里自解释,但需要你主动去用。
给路径参数和查询参数加描述
最直接的方式是用 Path() 和 Query() 的 description 和 example:
from fastapi import Path, Query
@router.get("/posts/{post_id}/comments")
async def list_comments(
post_id: int = Path(description="文章 ID"),
page: int = Query(default=1, ge=1, description="页码,从 1 开始"),
size: int = Query(default=20, ge=1, le=100, description="每页数量,最大 100"),
):
...
对比不加描述的写法:
# 文档里只显示 post_id: int, page: int, size: int
# 前端不知道 page 从 0 还是 1 开始,不知道 size 上限是多少
@router.get("/posts/{post_id}/comments")
async def list_comments(post_id: int, page: int = 1, size: int = 20):
...
差别不大,但文档的可用性差很多。ge=1、le=100 这类约束不只是校验,它们也会体现在 OpenAPI schema 里,前端可以直接用来做表单校验。
用 Enum 让可选值自文档化
如果一个参数只有几个合法值,用字符串接收再手动校验是最差的选择——文档里完全看不出有哪些选项:
# 文档里只显示 status: str,前端不知道能传什么
@router.get("/posts")
async def list_posts(status: str = "published"):
...
用 Enum 替代后,合法值会自动出现在文档里:
from enum import Enum
class PostStatus(str, Enum):
draft = "draft"
published = "published"
archived = "archived"
@router.get("/posts")
async def list_posts(status: PostStatus = PostStatus.published):
...
Swagger UI 会自动渲染成下拉选择框,前端从文档里就能直接知道所有合法值。
给请求体模型加描述和示例
请求体的字段也是一样——Field() 里的 description 和约束会直接体现在文档里:
from pydantic import BaseModel, EmailStr, Field
class UserCreate(BaseModel):
username: str = Field(
min_length=3,
max_length=50,
description="用户名,3-50 个字符",
examples=["alice"],
)
email: EmailStr = Field(
description="邮箱地址,必须唯一",
examples=["alice@example.com"],
)
password: str = Field(
min_length=8,
description="密码,至少 8 个字符",
examples=["strongpassword"],
)
如果想给整个模型一个完整的请求示例,可以用 model_config 里的 json_schema_extra:
class UserCreate(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [
{
"username": "alice",
"email": "alice@example.com",
"password": "strongpassword",
}
]
}
)
username: str = Field(min_length=3, max_length=50)
email: EmailStr
password: str = Field(min_length=8)
两种方式的区别:
Field(examples=[...])是字段级示例,适合单个字段的含义不够直观时使用json_schema_extra是模型级示例,适合展示一个完整的请求体长什么样
不需要两种都用。如果字段名本身已经够清楚(username、email),字段级 examples 加不加都行。但如果请求体结构比较复杂,给一个完整的模型级示例会让文档好用很多。
请求侧文档的原则
请求侧的文档目标很简单:让前端开发者不用离开文档页面就能写出正确的请求。
具体来说:
每个参数的含义是什么(
description)有效范围是什么(
ge、le、min_length、max_length)有哪些合法选项(
Enum)一个正确的请求长什么样(
examples)
这些信息不加在文档里,就会加在 Slack 消息、会议记录和口头沟通里。
响应侧:response_model 是文档的基础
请求侧解决的是"怎么调",响应侧解决的是"会返回什么"。
在 FastAPI 里,响应侧文档的基础就是 response_model。它同时做三件事:
约束输出结构:路由返回的数据会被 Pydantic 验证,不符合模型定义的字段会被拦住
过滤敏感字段:返回值里多余的字段(比如
hashed_password)会被自动丢弃生成文档:模型结构会被写进 OpenAPI schema,前端工具可以直接从中生成类型定义
理解了这三件事,下面的实践就会更清楚了。
怎样定义一个好的响应模型
响应模型的核心职责是描述 API 对外暴露的结构,不是描述内部数据存储。这个区别很重要,因为它决定了模型里该包什么字段。
原则 1:包含该暴露给客户端的字段
# ORM 模型(数据库结构)
class User(Base):
__tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str]
email: Mapped[str]
hashed_password: Mapped[str] # 内部字段,不应该暴露
created_at: Mapped[datetime]
updated_at: Mapped[datetime]
# 响应模型(API 契约)
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
email: str
# ❌ 不要包含 hashed_password
# ❌ 通常也不需要 created_at/updated_at,除非接口明确需要
hashed_password 永远不应该出现在任何响应里,这是安全底线。created_at/updated_at 通常也不需要,除非接口明确承诺返回这些信息。
原则 2:字段加描述,让文档更完整
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int = Field(description="用户 ID")
username: str = Field(description="用户名")
email: EmailStr = Field(description="邮箱地址")
is_active: bool = Field(description="账户是否激活")
这些 description 会直接显示在 OpenAPI 文档里,帮助前端理解每个字段的含义。
原则 3:用 from_attributes=True 兼容 ORM 对象
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
这个配置让 Pydantic 可以直接从 ORM 对象读取属性,不需要手动转换成字典。路由里可以直接返回 ORM 对象,框架会自动用 response_model 做转换和过滤。
小模型可以留在 router.py
并非所有响应模型都需要放进 schemas.py。如果一个响应模型满足这些条件:
只在一个路由里使用
结构很小(通常 2-4 个字段)
短期内不会被其他地方复用
模型的变化只影响这个接口
那么直接定义在 router.py 里通常更合理,这样接口逻辑和数据结构在一起,更容易维护:
# posts/router.py
from pydantic import BaseModel, Field
# 这个小模型只在发布接口用,不需要进 schemas.py
class PublishResult(BaseModel):
post_id: int = Field(description="文章 ID")
published_at: datetime = Field(description="发布时间")
url: str = Field(description="访问 URL")
@router.post("/{post_id}/publish", response_model=PublishResult)
async def publish_post(post_id: int):
post = await posts_repo.get(post_id)
if not post:
raise HTTPException(status_code=404)
post.status = "published"
post.published_at = datetime.now()
await posts_repo.save(post)
return {
"post_id": post.id,
"published_at": post.published_at,
"url": f"/posts/{post.id}",
}
对比:模型复用时就应该进 schemas.py
# ❌ 如果多个接口都返回用户信息,就不应该在各自的 router.py 里重复定义
# users/router.py
class UserResponse(BaseModel): ...
# posts/router.py
class UserResponse(BaseModel): ... # 重复定义,维护麻烦
# ✅ 正确做法:进 schemas.py,两个 router 都导入
# users/schemas.py
class UserResponse(BaseModel):
id: int
username: str
email: str
# users/router.py
from users.schemas import UserResponse
@router.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
user = await users_repo.get(user_id)
return user
# posts/router.py
from users.schemas import UserResponse
@router.get("/posts/{post_id}", response_model=dict)
async def get_post(post_id: int):
post = await posts_repo.get(post_id)
author = await users_repo.get(post.author_id)
return {
"id": post.id,
"title": post.title,
"author": UserResponse.model_validate(author),
}
判断标准很简单:一次性的小模型留在 router.py,可复用的或会增长的模型进 schemas.py。不要为了"保持风格统一"就把一个 2-3 行的模型塞进 schemas.py,这样反而增加了认知负担。
不要手动重复框架的工作
很多项目会顺手多做一步:先手动把数据库查出的数据构造成 Pydantic 对象,再交给 FastAPI 返回。
例如,下面这种写法在很多项目里都很常见:
@router.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
user = await users_repo.get(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# 手动把 ORM 对象转成 Pydantic 响应模型
return UserResponse.model_validate(user)
这不是错,但这一步 model_validate 通常是多余的。
原因是:只要你声明了 response_model=UserResponse,FastAPI 在返回响应时一定会拿 UserResponse 对返回值做一次验证和序列化——过滤多余字段、转换类型、生成最终 JSON。这是框架自动做的,你拦不住,也跳不过。
也就是说,你手动做了一次 ORM → Pydantic 的转换,框架紧接着又对这个 Pydantic 对象再走一遍验证和序列化。同样的事情做了两次,第一次完全没有必要。
直接返回原始数据就够了:
@router.get("/users/{user_id}", response_model=UserResponse)
async def get_user(user_id: int):
user = await users_repo.get(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# 直接返回字典或 ORM 对象,让 response_model 负责对外结构
return user
即使是从多个数据源组装的场景,道理也一样——返回字典就够了:
@router.get("/dashboard", response_model=DashboardResponse)
async def get_dashboard():
user = await users_repo.get_current_user()
stats = await analytics_service.get_user_stats(user.id)
# 返回字典,让 response_model 负责验证和序列化
return {
"username": user.username,
"post_count": stats.post_count,
"follower_count": stats.follower_count,
}
所以原则只有一条:只要声明了 response_model,就让框架去做验证和序列化,路由只负责返回原始数据。无论数据来自单个 ORM 对象、一个字典,还是从多个来源组装出来的结果,都不需要你自己先手动构造一遍 Pydantic 对象。
把响应链路理解为两层会更清楚:
业务层决定"返回什么数据"(ORM 对象、字典、组装结果)
API 层决定"对外暴露成什么结构"(
response_model负责)
这样你就不会把 response_model 用成内部业务模型,也不会为了"类型统一"到处做重复转换。
response_model 不适用的场景
response_model 的前提是路由返回的是可以被 Pydantic 序列化的数据。当你返回一个 Response 对象的子类时,FastAPI 会完全跳过 response_model 的序列化步骤——即使你声明了,也不会生效。
最常见的两类情况:
文件下载:返回 FileResponse 时,response_model 无效。用 response_class 告知文档类型即可:
@router.get("/exports/{file_id}", response_class=FileResponse)
async def download_export(file_id: int):
path = await exports_service.get_file_path(file_id)
return FileResponse(path, media_type="application/octet-stream")
流式响应:LLM 推理、Agent 对话流、实时日志推送这类场景需要流式输出,Pydantic 无法对流的整体做序列化。用 response_class=StreamingResponse,chunk 的数据结构可以单独定义 Pydantic 模型用于文档参考,但不要绑定到 response_model:
from fastapi.responses import StreamingResponse
@router.post(
"/chat/completions",
response_class=StreamingResponse,
summary="流式对话补全",
description="每个 SSE chunk 以 `data: ...\\n\\n` 格式推送,最后一条为 `data: [DONE]`。",
)
async def stream_chat(payload: ChatRequest):
async def token_generator():
async for chunk in llm_service.stream(payload.messages):
yield f"data: {chunk.model_dump_json()}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
token_generator(),
media_type="text/event-stream",
)
这两类场景的共同点是:响应内容不经过 Pydantic 序列化,所以 response_model 没有用武之地,改用 response_class 即可。
用 responses 补全错误契约
response_model 解决的是成功响应的文档。但一个完整的接口文档还需要回答另一个问题:这个接口除了成功之外,还可能返回哪些状态码、这些状态码代表什么、错误响应体长什么样。
这就是 responses 的作用。
很多人第一次看到 responses 会以为只是"顺手给错误写个说明"。但它真正做的事更有价值——把错误状态码、错误语义和错误响应结构都写进 OpenAPI schema,让前端可以直接从文档生成完整的错误处理逻辑:
@router.post(
"/users",
response_model=UserResponse,
status_code=201,
summary="创建用户",
responses={
409: {
"description": "邮箱已存在",
"model": ErrorResponse,
"content": {
"application/json": {
"example": {"detail": "Email already exists"}
}
},
}
},
)
async def create_user(payload: UserCreate):
...
response_model 和 responses 的分工是这样的:
response_model负责成功响应的类型和结构responses负责错误状态码、错误语义和错误响应结构两者配合起来,前后端共享的接口契约才会完整
OpenAPI 契约越完整,前端越容易配合 hey-api、openapi-ts 这类工具生成 SDK 和类型,而不必每次手写 fetch 和临时接口类型。
responses 写在哪里
responses 大多数情况下更适合写在单个路由上。
不同接口虽然可能共享一部分错误状态码,但它们的语义通常并不完全一样。比如这两个接口都可能返回 404:
GET /users/{user_id}:用户不存在GET /posts/{post_id}:文章不存在
状态码一样,但描述、示例、上下文都不一样。如果把这些粗暴提到 router 级别统一声明,文档会很快失真。
更稳的默认做法是:
和具体接口强相关的响应:写在单个路由上
通用且语义明确的响应:可以抽成公共字典复用
# 局部写法:最清晰,推荐默认用这种
@router.get(
"/posts/{post_id}",
response_model=PostResponse,
responses={
404: {"description": "文章不存在"},
},
)
async def get_post(post_id: int):
...
# 公共配置:只适合真正通用的部分
COMMON_AUTH_RESPONSES = {
401: {"description": "未登录或 token 无效"},
403: {"description": "没有权限执行该操作"},
}
@router.patch(
"/posts/{post_id}",
response_model=PostResponse,
responses={
**COMMON_AUTH_RESPONSES,
404: {"description": "文章不存在"},
},
)
async def update_post(post_id: int, payload: PostUpdate):
...
不要为了"统一"把所有响应都抽到公共层。一旦描述脱离了具体接口语义,文档就会开始变得表面整齐、实际难用。
responses 和错误处理是连着的。如果你的项目已经有统一的错误响应格式(比如包含 code、message 的结构),responses 里直接引用同一个 ErrorResponse model,文档里的契约和代码里的错误处理策略才是一致的。关于错误格式的完整设计——错误分类、分层边界和全局 handler,详见第 6 篇:把错误处理做系统。
接口元信息:summary、description 和 tags 分组
参数有了描述,响应有了契约,接下来是让整个文档页面可导航、可浏览。
summary 和 description
summary 是文档列表页里最先被看到的东西,应该简短有力——一句话说清接口做什么。好的 summary 让你不用点进去就知道接口的用途:
# ✅ 好:一眼就知道做什么
@router.post("/posts", summary="创建文章")
@router.get("/posts", summary="获取文章列表")
@router.post("/posts/{post_id}/publish", summary="发布文章")
# ❌ 差:和路径重复,或者太模糊
@router.post("/posts", summary="Post posts")
@router.get("/posts", summary="获取数据")
@router.post("/posts/{post_id}/publish", summary="操作文章")
description 适合补充 summary 没法表达的业务细节——前置条件、副作用、特殊行为,可以使用 markdown 格式:
@router.post(
"/posts/{post_id}/publish",
summary="发布文章",
description="""
将草稿状态的文章发布。
**前置条件:**
- 文章必须为草稿状态
- 当前用户必须是文章作者
**副作用:**
- 已发布的文章对所有用户可见
- 会触发订阅用户的通知
**限制:**
- 已归档的文章无法发布
""",
)
async def publish_post(post_id: int):
...
用 docstring 自动提取 summary 和 description
比起每次都显式写 summary 和 description,更好的做法是直接用路由函数的 docstring。FastAPI 会自动提取:
@router.post("/posts")
async def create_post(payload: PostCreate) -> PostResponse:
"""
创建文章
创建一篇新文章。创建者可以设置初始状态为草稿或已发布。
**参数说明:**
- `title`: 文章标题,最多 200 字符
- `content`: 正文内容,支持 markdown 格式
- `status`: 发布状态,可选值:draft(草稿)、published(已发布)
**返回说明:**
- 成功时返回创建的文章对象,包含自动分配的 ID
- 发布后的文章会立即对所有用户可见
"""
...
FastAPI 会自动用:
第一行("创建文章")作为
summary显示在文档列表后续内容(包括 markdown)作为
description显示在接口详情页
这样做的好处很直接:
文档和代码在一起,改接口时不容易遗漏文档
不需要重复写
summary=和description=的样板代码新人更容易看清接口的完整约定
不是每个接口都需要详细 description。如果 summary 加上参数和响应模型已经足够清楚,就不必非得补一段 description。只在有值得补充的业务语义、前置条件或边界情况时才写。
用 tags 组织文档结构
当接口超过十几个,Swagger 页面就会变成一个很长的列表。tags 可以把接口按业务领域分组,和第 1 篇讲的"按领域组织代码"是同一个思路:
# 路由级别打 tag
router = APIRouter(prefix="/posts", tags=["文章"])
# 或者单个接口打 tag
@router.post("/posts", tags=["文章"])
async def create_post(): ...
如果想控制分组在文档里的顺序和描述,可以在 FastAPI() 初始化时配置 openapi_tags:
app = FastAPI(
openapi_tags=[
{"name": "用户", "description": "用户注册、登录和资料管理"},
{"name": "文章", "description": "文章的增删改查和发布"},
{"name": "评论", "description": "文章评论管理"},
]
)
文档页面会按这个顺序展示分组,每个分组有自己的描述。这对接口数量多的项目来说,是把文档从"能看"变成"好找"的关键一步。
文档展示工具
FastAPI 默认自带的 Swagger UI 已经很实用,尤其适合开发期调试。如果更重视阅读体验和对外展示效果,Scalar 通常更值得考虑。前者更像调试工具,后者更像成品化文档页面。
生产环境的文档边界
文档是否对外暴露,不应该写死在代码里。更合理的方式是按环境控制:
from src.config import get_app_settings
settings = get_app_settings()
app = FastAPI(
docs_url="/docs" if settings.debug else None,
redoc_url="/redoc" if settings.debug else None,
openapi_url="/openapi.json" if settings.debug else None,
)
这样开发环境自动打开文档,生产环境默认关闭。如果生产环境需要内网可见,可以把控制条件从 debug 改成更细粒度的配置项,比如 settings.enable_docs。
接口文档本身不是安全漏洞,但它会暴露你的 API 结构、参数约束和错误码设计。对外服务默认关闭是更稳的选择。
落地清单
给路径参数和查询参数补
description,有限选项的参数用Enum。请求体模型的关键字段加
Field(description=...),复杂模型加模型级examples。响应模型只包含应该暴露给客户端的字段,永远不包含
hashed_password这类内部字段。响应模型的关键字段加
Field(description=...),让 OpenAPI 文档更清晰。响应模型用
ConfigDict(from_attributes=True)兼容 ORM 对象。声明
response_model后,路由直接返回原始数据,不要手动构造 Pydantic 对象。一次性的小模型(2-4 字段,只在一个路由用)留在
router.py;可复用或会增长的模型提到schemas.py。文件下载和流式响应用
response_class替代response_model。给核心接口补
responses,默认写在单个路由上,只有真正通用的部分才抽成公共字典。用路由函数的 docstring 自动提取
summary和description,description支持 markdown 格式。用
tags按业务领域分组,配合openapi_tags控制顺序和描述。按环境控制文档暴露:开发环境打开,生产环境默认关闭或内网可见。