FastAPI 响应也要有契约:用 response_model 挡住字段泄漏

摘要:请求体有校验,响应同样需要边界。本文拆分创建模型与读取模型,用 response_model 过滤内部字段,并用 201、404 准确表达接口结果。

响应模型过滤内部敏感字段

图:内部对象经过响应模型过滤,只有契约允许的字段能够离开服务。

很多接口只盯着输入校验,却把数据库对象原样返回。这样做最危险的地方不是“不够优雅”,而是某次加上 password_hash、内部备注或成本字段后,它们可能悄悄出现在公共响应里。

解决办法是把响应也当作契约:业务内部可以拥有更多字段,客户端只能拿到明确声明的那一部分。下面用一个故意带 internal_note 的任务,验证 FastAPI 如何把它挡在响应之外。

先制造一次字段泄漏,再把它挡住

fastapi dev examples/ch04_response_errors/main.py
curl -i http://127.0.0.1:8000/tasks/1
curl -i http://127.0.0.1:8000/tasks/999

第一条预期为 200,且看不到 internal_note;第二条预期为 404:

{"detail":"任务不存在"}

先找到那个不该公开的内部字段

cd 04-fastapi-beginner
source .venv/bin/activate

打开 本文示例代码对应测试。先注意存储字典中有一个不应该公开的 internal_note

拆开输入模型和输出模型

拆分输入与输出模型

class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=100)
    description: str | None = Field(default=None, max_length=500)


class TaskRead(TaskCreate):
    id: int
    completed: bool

客户端创建任务时只提供标题和说明;服务读取任务时额外返回服务生成的 ID 和完成状态。

约束创建响应

@app.post(
    "/tasks",
    response_model=TaskRead,
    status_code=status.HTTP_201_CREATED,
)
def create_task(payload: TaskCreate):
    return {
        "id": 2,
        "completed": False,
        "internal_note": "不会返回",
        **payload.model_dump(),
    }

函数内部返回了额外字段,但客户端只能收到 TaskRead 声明的字段。201 表示服务器成功创建了新资源。

抛出业务错误

task = TASKS.get(task_id)
if task is None:
    raise HTTPException(
        status_code=status.HTTP_404_NOT_FOUND,
        detail="任务不存在",
    )
return task

参数 999 本身是合法整数,因此不是 422;只是对应资源不存在,所以返回 404。

创建输入模型与读取输出模型分开

图:输入模型负责接收创建数据,输出模型负责筛掉不应公开的内部字段。

同时验证 201、404 和字段过滤

curl -s http://127.0.0.1:8000/tasks/1
curl -i http://127.0.0.1:8000/tasks/999
curl -i -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"定义响应契约"}'

检查三件事:内部字段没有泄漏、不存在返回 404、创建成功返回 201。

自动验证:

python -m pytest tests/test_ch04.py -q

创建成功、资源不存在和字段过滤三种响应结果

图:状态码说明结果类型,响应模型继续约束最终交给调用者的字段。

response_model 不只是文档

请求模型和响应模型分别位于系统边界两侧:

客户端 JSON -> TaskCreate -> 业务逻辑 -> 内部对象 -> TaskRead -> JSON 响应

response_model 不只是文档,它会对返回值执行验证和序列化。若业务代码缺少 id,这是服务端违反自己的契约,应该在测试中尽早暴露。

常见状态码:200 查询或修改成功,201 创建成功,204 删除成功且没有响应体,404 资源不存在,422 参数校验失败。不要把所有结果都包装成 HTTP 200 再在 JSON 里塞自定义错误码。

状态码和响应边界的常见误区

  • 直接返回数据库对象的所有字段:密码哈希、内部备注可能泄漏。
  • 找不到任务时返回 None:响应模型校验可能变成 500,应明确抛出 404。
  • 手写数字 201 到处散落:使用 status.HTTP_201_CREATED 更易读。
  • 把不存在当 422:422 是输入形状或约束不合法,404 是合法标识对应资源不存在。
  • detail 中暴露堆栈或数据库 SQL:错误体应该对调用者稳定且安全。

把响应契约继续做严

  1. 为任务增加内部字段 owner_email,证明响应中不会出现。
  2. 增加 DELETE /tasks/{task_id},成功返回 204,不存在返回 404。
  3. 故意删除返回值中的 id,观察测试或请求会出现什么。
  4. 为 404 响应增加精确 JSON 断言。
  5. 思考:更新接口成功应该返回 200 还是 204?分别适用于什么场景?

最后,用测试固定这次改动

手动请求通过后,运行与本文对应的自动化测试:

python -m pytest tests/test_ch04.py -q
python tests/validate_course.py

写在最后

稳定 API 同时约束输入与输出,并通过 HTTP 状态码准确表达结果。下一篇会把已经掌握的参数、模型和错误组合起来,完成第一个完整 CRUD 闭环。