05-让 FastAPI 接口更专业:文档契约、response_model 和模型组织

Tomy
17 分钟阅读
16 次浏览
FastAPI 自带文档能力,但默认生成的文档远不够"规范"。从请求参数的描述和示例,到 response_model 的正确用法,再到 responses 补全错误契约,这篇讲怎么把接口文档做到前后端都能直接用。
FastAPIPydanticOpenAPI

如何让 FastAPI 接口文档真正可用

先说结论

FastAPI 自带的文档能力是它最被低估的优势之一。很多项目只是"能自动生成一个 Swagger 页面",但离"前端拿着文档就能直接对接"还差得很远。

一个真正规范的接口文档至少要覆盖三件事:

  • 请求侧:参数有描述、有约束、有示例,前端不用猜

  • 响应侧:成功响应有 response_model,错误响应有 responses

  • 组织层面:接口有分组、有摘要,模型放在该放的位置

这篇就是按这个顺序来讲:先把请求侧的文档补齐,再把响应侧的契约理顺,最后处理元信息和模型组织。

请求侧:让参数自解释

很多项目的请求参数在文档里长这样:一个参数名,一个类型,没了。前端要么靠猜,要么去翻代码,要么来问你。

FastAPI 提供了足够多的方式让请求参数在文档里自解释,但需要你主动去用。

给路径参数和查询参数加描述

最直接的方式是用 Path()Query()descriptionexample

python
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"),
):
    ...

对比不加描述的写法:

python
# 文档里只显示 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=1le=100 这类约束不只是校验,它们也会体现在 OpenAPI schema 里,前端可以直接用来做表单校验。

用 Enum 让可选值自文档化

如果一个参数只有几个合法值,用字符串接收再手动校验是最差的选择——文档里完全看不出有哪些选项:

python
# 文档里只显示 status: str,前端不知道能传什么
@router.get("/posts")
async def list_posts(status: str = "published"):
    ...

用 Enum 替代后,合法值会自动出现在文档里:

python
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 和约束会直接体现在文档里:

python
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

python
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 是模型级示例,适合展示一个完整的请求体长什么样

不需要两种都用。如果字段名本身已经够清楚(usernameemail),字段级 examples 加不加都行。但如果请求体结构比较复杂,给一个完整的模型级示例会让文档好用很多。

请求侧文档的原则

请求侧的文档目标很简单:让前端开发者不用离开文档页面就能写出正确的请求。

具体来说:

  • 每个参数的含义是什么(description

  • 有效范围是什么(gelemin_lengthmax_length

  • 有哪些合法选项(Enum

  • 一个正确的请求长什么样(examples

这些信息不加在文档里,就会加在 Slack 消息、会议记录和口头沟通里。

响应侧:response_model 是文档的基础

请求侧解决的是"怎么调",响应侧解决的是"会返回什么"。

在 FastAPI 里,响应侧文档的基础就是 response_model。它同时做三件事:

  • 约束输出结构:路由返回的数据会被 Pydantic 验证,不符合模型定义的字段会被拦住

  • 过滤敏感字段:返回值里多余的字段(比如 hashed_password)会被自动丢弃

  • 生成文档:模型结构会被写进 OpenAPI schema,前端工具可以直接从中生成类型定义

理解了这三件事,下面的实践就会更清楚了。

怎样定义一个好的响应模型

响应模型的核心职责是描述 API 对外暴露的结构,不是描述内部数据存储。这个区别很重要,因为它决定了模型里该包什么字段。

原则 1:包含该暴露给客户端的字段

python
# 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:字段加描述,让文档更完整

python
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 对象

python
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 里通常更合理,这样接口逻辑和数据结构在一起,更容易维护:

python
# 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

python
# ❌ 如果多个接口都返回用户信息,就不应该在各自的 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 返回。

例如,下面这种写法在很多项目里都很常见:

python
@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 对象再走一遍验证和序列化。同样的事情做了两次,第一次完全没有必要。

直接返回原始数据就够了:

python
@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

即使是从多个数据源组装的场景,道理也一样——返回字典就够了:

python
@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 告知文档类型即可:

python
@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

python
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,让前端可以直接从文档生成完整的错误处理逻辑:

python
@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_modelresponses 的分工是这样的:

  • response_model 负责成功响应的类型和结构

  • responses 负责错误状态码、错误语义和错误响应结构

  • 两者配合起来,前后端共享的接口契约才会完整

OpenAPI 契约越完整,前端越容易配合 hey-apiopenapi-ts 这类工具生成 SDK 和类型,而不必每次手写 fetch 和临时接口类型。

responses 写在哪里

responses 大多数情况下更适合写在单个路由上。

不同接口虽然可能共享一部分错误状态码,但它们的语义通常并不完全一样。比如这两个接口都可能返回 404

  • GET /users/{user_id}:用户不存在

  • GET /posts/{post_id}:文章不存在

状态码一样,但描述、示例、上下文都不一样。如果把这些粗暴提到 router 级别统一声明,文档会很快失真。

更稳的默认做法是:

  • 和具体接口强相关的响应:写在单个路由上

  • 通用且语义明确的响应:可以抽成公共字典复用

python
# 局部写法:最清晰,推荐默认用这种
@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 和错误处理是连着的。如果你的项目已经有统一的错误响应格式(比如包含 codemessage 的结构),responses 里直接引用同一个 ErrorResponse model,文档里的契约和代码里的错误处理策略才是一致的。关于错误格式的完整设计——错误分类、分层边界和全局 handler,详见第 6 篇:把错误处理做系统

接口元信息:summarydescription 和 tags 分组

参数有了描述,响应有了契约,接下来是让整个文档页面可导航、可浏览。

summarydescription

summary 是文档列表页里最先被看到的东西,应该简短有力——一句话说清接口做什么。好的 summary 让你不用点进去就知道接口的用途:

python
# ✅ 好:一眼就知道做什么
@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 格式

python
@router.post(
    "/posts/{post_id}/publish",
    summary="发布文章",
    description="""
将草稿状态的文章发布。

**前置条件:**
- 文章必须为草稿状态
- 当前用户必须是文章作者

**副作用:**
- 已发布的文章对所有用户可见
- 会触发订阅用户的通知

**限制:**
- 已归档的文章无法发布
""",
)
async def publish_post(post_id: int):
    ...

用 docstring 自动提取 summarydescription

比起每次都显式写 summarydescription,更好的做法是直接用路由函数的 docstring。FastAPI 会自动提取:

python
@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 篇讲的"按领域组织代码"是同一个思路:

python
# 路由级别打 tag
router = APIRouter(prefix="/posts", tags=["文章"])

# 或者单个接口打 tag
@router.post("/posts", tags=["文章"])
async def create_post(): ...

如果想控制分组在文档里的顺序和描述,可以在 FastAPI() 初始化时配置 openapi_tags

python
app = FastAPI(
    openapi_tags=[
        {"name": "用户", "description": "用户注册、登录和资料管理"},
        {"name": "文章", "description": "文章的增删改查和发布"},
        {"name": "评论", "description": "文章评论管理"},
    ]
)

文档页面会按这个顺序展示分组,每个分组有自己的描述。这对接口数量多的项目来说,是把文档从"能看"变成"好找"的关键一步。

文档展示工具

FastAPI 默认自带的 Swagger UI 已经很实用,尤其适合开发期调试。如果更重视阅读体验和对外展示效果,Scalar 通常更值得考虑。前者更像调试工具,后者更像成品化文档页面。

生产环境的文档边界

文档是否对外暴露,不应该写死在代码里。更合理的方式是按环境控制:

python
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 自动提取 summarydescriptiondescription 支持 markdown 格式。

  • tags 按业务领域分组,配合 openapi_tags 控制顺序和描述。

  • 按环境控制文档暴露:开发环境打开,生产环境默认关闭或内网可见。


下一篇:把错误处理做系统:分层、统一格式和全局收口