别再手动校验 JSON:用 Pydantic 守住 FastAPI 请求边界

摘要:从创建任务的 JSON 请求出发,用 Pydantic v2 处理必填字段、默认值、范围限制和空白标题,让错误数据在进入业务逻辑前得到清晰的 422 响应。

Pydantic 在请求边界校验字段

图:请求数据先经过模型、必填项和范围校验,合法数据才会进入业务逻辑。

创建任务时,客户端通常会提交一段 JSON。手动调用 request.json()、逐个检查字段、转换数字当然能做,但代码很快会被边界判断占满。

FastAPI 与 Pydantic 的组合提供了更稳定的入口:先声明“合法任务长什么样”,框架负责解析、转换、校验和生成文档。下面从一条 POST 请求开始,把这道边界完整搭起来。

先提交一条任务,观察输入和输出

fastapi dev examples/ch03_request_body/main.py

创建任务:

curl -i -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"  学习 Pydantic  ","priority":5,"tags":["fastapi"]}'

预期状态码为 201,返回的标题已经去掉首尾空格,未传的 descriptionnull

先确认示例使用的是 Pydantic v2

cd 04-fastapi-beginner
source .venv/bin/activate
python -c "import pydantic; print(pydantic.__version__)"

本系列使用 Pydantic v2,序列化使用 model_dump(),不沿用旧教程中的 .dict() 主写法。

先声明一条合法任务的形状

定义请求模型

from pydantic import BaseModel, Field


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=100)
    description: str | None = Field(default=None, max_length=500)
    priority: int = Field(default=3, ge=1, le=5)
    tags: list[str] = Field(default_factory=list, max_length=5)

title 没有默认值,所以必填。description 可省略,priority 省略时为 3。列表使用 default_factory=list,避免多个对象共享同一个可变默认值。

拒绝纯空格标题

@field_validator("title")
@classmethod
def title_must_not_be_blank(cls, value: str) -> str:
    title = value.strip()
    if not title:
        raise ValueError("title cannot be blank")
    return title

长度校验无法区分一个字符和一个空格,自定义校验器先 strip(),再判断清理后的结果。

接收模型对象

@app.post("/tasks", status_code=status.HTTP_201_CREATED)
def create_task(payload: TaskCreate) -> dict[str, object]:
    return {"id": 1, **payload.model_dump()}

函数执行时,payload 已经是 TaskCreate 对象,不需要手动读取 JSON、判断字段或转换整数。

请求模型声明字段类型、长度约束和默认值

图:请求模型先声明合法数据的形状,再让所有请求统一经过这道边界。

故意提交三种错误 JSON

合法请求:

curl -i -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"写教程"}'

依次制造错误:

curl -i -X POST http://127.0.0.1:8000/tasks -H "Content-Type: application/json" -d '{}'
curl -i -X POST http://127.0.0.1:8000/tasks -H "Content-Type: application/json" -d '{"title":"任务","priority":6}'
curl -i -X POST http://127.0.0.1:8000/tasks -H "Content-Type: application/json" -d '{"title":"   "}'

预期三条均为 422。第一条错误位置为 body -> title,第二条说明 priority 不得大于 5,第三条来自自定义校验器。

python -m pytest tests/test_ch03.py -q

从 JSON 到 TaskCreate 经历了什么

一次 POST 请求经历:JSON 字节 → JSON 对象 → Pydantic 校验和转换 → TaskCreate 实例 → 业务函数。任一步校验失败,FastAPI 都不会调用 create_task

模型同时服务于四个场景:运行时验证、编辑器类型提示、OpenAPI Schema、Swagger 表单。修改字段约束后刷新 /docs,界面中的模型定义也会变化。

不要用一个模型承载所有阶段。创建时不应该让客户端传 id,读取时却必须返回 id。下一篇会拆分 TaskCreateTaskRead

请求数据经过解析和校验后生成模型对象

图:JSON 请求体先被解析,再执行模型校验,最后才成为路由可以使用的对象。

请求体最容易错在这五处

  • 忘记 Content-Type: application/json:服务无法按预期解析请求体。
  • JSON 使用单引号包字段:JSON 内部必须是双引号;shell 外层可用单引号。
  • 写成 description: str | None 却不给默认值:字段仍然必填,只是允许值为 null。
  • 使用 tags=[] 作为可变默认值:优先使用 default_factory=list
  • 只写 min_length=1:纯空格仍能通过,需要清理或业务校验。

把模型再推近真实业务一步

  1. 增加 estimated_minutes,限制为 1~1440,默认 30。
  2. 限制每个 tag 的长度为 1~20;提示:定义单独的受约束字符串类型。
  3. 提交字符串形式的 priority,例如 "5",观察是否被转换。
  4. 为超过 5 个 tags 的请求增加测试。
  5. 排错:故意删除请求头,比较 curl 响应和服务器日志。

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

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

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

写在最后

Pydantic 把散乱的 JSON 变成有类型、有约束的应用边界。下一篇将控制另一个方向:服务器允许向客户端返回哪些字段,以及如何用 201、404 等状态码表达结果。