codecamp

6.1 正确理解响应校验与序列化

正确理解响应校验与序列化

路由函数返回的 Python 值、FastAPI 使用的响应模型和客户端收到的 JSON 是三个相连但不相同的边界。返回一个字典,不代表字典里的每个键都会被公开;返回一个 Pydantic 对象,也不代表它可以绕过响应契约;直接返回 Response,则意味着你开始自己负责响应内容。

本节沿用文章管理案例,用一个带内部密码摘要的用户记录对照三种写法:用返回类型注解声明公开模型、用 response_model 过滤字典或对象,以及直接返回 JSONResponse。示例还故意制造一次缺少字段的响应,观察服务端响应校验失败。敏感字段不会出现在前两种正常接口中,直接响应的敏感字段则是为了明确展示绕过过滤的风险。

返回类型注解和 response_model

如果路由函数有返回类型注解,FastAPI 可以把它作为响应模型,用来生成 OpenAPI、校验返回值、序列化并过滤字段。response_model 是路径操作装饰器的参数;当两者同时存在时,FastAPI 以 response_model 为准。用字典、数据库对象等类型作为内部返回值时,通常给 response_model 明确写出对外模型,并把函数返回注解写成真实的内部类型或 Any,让静态工具和运行时职责都清楚。

下面的 UserRecord 继承公开模型只是为了让“返回类型注解”示例保持类型兼容。它包含 password_hash,但 /by-annotation 的公开返回类型是 UserPublic/by-response-model 返回字典,也由 response_model=UserPublic 负责过滤。

<!-- file: ch06_response/response_contract_demo.py -->

from __future__ import annotations


from typing import Any


from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel




class UserPublic(BaseModel):
    id: int
    username: str




class UserRecord(UserPublic):
    password_hash: str




USER = UserRecord(
    id=7,
    username="alice",
    password_hash="argon2$internal-only",
)


app = FastAPI(title="响应校验与序列化示例")




@app.get("/by-annotation")
def by_annotation() -> UserPublic:
    """返回内部对象,但公开契约由返回类型 UserPublic 声明。"""
    return USER




@app.get("/by-response-model", response_model=UserPublic)
def by_response_model() -> Any:
    """返回字典,response_model 负责文档、校验和字段过滤。"""
    return USER.model_dump()




@app.get("/direct", response_model=None)
def direct_response() -> JSONResponse:
    """直接响应不会经过 response_model;这里只为演示风险。"""
    return JSONResponse(content=USER.model_dump(mode="json"))




@app.get("/broken", response_model=UserPublic)
def broken_response() -> dict[str, Any]:
    """故意缺少 username,模拟服务端违反响应契约。"""
    return {"id": USER.id}




def self_check() -> None:
    with TestClient(app, raise_server_exceptions=False) as client:
        by_annotation = client.get("/by-annotation")
        by_response_model = client.get("/by-response-model")
        direct = client.get("/direct")
        broken = client.get("/broken")


    expected_public = {"id": 7, "username": "alice"}
    assert by_annotation.status_code == 200
    assert by_annotation.json() == expected_public
    assert "password_hash" not in by_annotation.json()


    assert by_response_model.status_code == 200
    assert by_response_model.json() == expected_public
    assert "password_hash" not in by_response_model.json()


    # 这是刻意展示的危险路径:直接 JSONResponse 自己携带了敏感字段。
    assert direct.status_code == 200
    assert direct.json()["password_hash"] == "argon2$internal-only"


    # 缺少 response_model 要求的字段是服务端错误,而不是正常的空响应。
    assert broken.status_code == 500
    assert "username" not in broken.text


    schema = app.openapi()
    for path in ("/by-annotation", "/by-response-model"):
        response_schema = schema["paths"][path]["get"]["responses"]["200"]
        assert response_schema["content"]["application/json"]["schema"][
            "$ref"
        ].endswith("/UserPublic")
    direct_schema = schema["paths"]["/direct"]["get"]["responses"]["200"][
        "content"
    ]["application/json"]["schema"]
    assert "$ref" not in direct_schema


    print("response_contract_demo self-check passed")




if __name__ == "__main__":
    self_check()

在保存文件的目录运行:

python ch06_response/response_contract_demo.py

预期输出:

response_contract_demo self-check passed

最终 JSON 由谁负责

对于 /by-annotation,函数返回的是 UserRecord 对象,但返回类型是 UserPublic,因此文档和响应边界只包含 idusername。对于 /by-response-model,函数返回带有 password_hash 的字典,FastAPI 仍然按照 UserPublic 验证、序列化和过滤。两条接口最终收到的 JSON 相同。

/direct 返回的是已经构造好的 JSONResponse,并显式设置了 response_model=None。这条路径跳过了本节前两种接口依赖的响应模型过滤,因此它把 password_hash 原样带给客户端。生产接口直接返回 Response 时,必须在构造响应前自己选择公开字段;不要把“直接返回更快”当成绕过契约的理由。

/broken 返回缺少 username 的字典。TestClient 使用 raise_server_exceptions=False,所以自检能看到 500 响应;实际服务还应在日志和监控中记录响应校验错误。这个错误说明服务端实现或数据映射违反了已声明契约,不能通过把响应模型删掉来掩盖问题。

FastAPI 当前官方文档将 response_model 的作用概括为生成文档、校验、序列化和过滤输出;也支持用返回类型注解表达模型。不要根据旧文章中某个 validator 日志出现几次,就推断完整模型一定被固定重建两次,或由此宣称有固定性能损失。验证阶段、Pydantic/FastAPI 版本、返回值类型和测试入口都会影响观察结果;若性能确实成为问题,应固定版本做基准并分析真实热点,同时保留对外响应契约。

过滤不是敏感数据治理的唯一层

响应模型是重要的最后一道边界,但查询层仍应尽量只选择需要公开的列。密码摘要、访问令牌、内部备注等字段不应因为“最后会被过滤”就到处流转;它们还可能出现在日志、异常、调试接口或直接响应中。

本节只用内存对象验证字段边界,没有验证数据库 ORM 对象、别名字段或自定义序列化器。接入数据库时,应额外检查 ORM 查询结果到 UserPublic 的映射,并为敏感字段添加失败测试。

资料来源

  • 主要参考:fastapi-best-practices 中文 README 的“FastAPI 响应序列化”主题。本节保留响应模型与输出过滤的议题,但不沿用原文关于固定模型创建次数的判断。
  • 官方文档:FastAPI 响应模型直接返回 Response。官方文档说明 response_model 参与文档、校验、序列化和过滤,并说明直接返回 Response 时需要自行负责内容。
5.4 审查并执行Alembic迁移
6.2 为成功和失败响应写清文档
温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }