3.1 用Pydantic表达输入规则
用Pydantic表达输入规则
请求体是接口和调用方之间的契约。只在路由函数里写 if,会让长度、状态和值域规则散落在业务代码中;把这些规则写进 Pydantic 模型,FastAPI 就能在进入业务函数前完成解析和校验,并生成同一份 OpenAPI 描述。本节使用 Pydantic v2,案例仍然是文章管理 API。
先把客户端能决定的字段写出来
文章创建请求需要标题、正文、一个必须出现但可以为 null 的发布时间,以及可选标签。文章状态只能是 draft 或 published,预计阅读时间有明确的数值范围。作者 ID、创建时间和审核结果由服务端决定,不属于这个输入模型;把服务端字段混入请求模型,会让客户端看起来可以修改不应该修改的数据。
Field 可以表达字符串长度、数值上下界和列表长度,枚举可以把有限值域写成类型。下面的完整程序还验证了默认值、缺少字段、显式 null 和非法值。
<!-- file: ch03_contracts/input_rules.py -->
from __future__ import annotations
from datetime import datetime
from enum import Enum
from pydantic import BaseModel, Field, StrictInt, ValidationError
class ArticleStatus(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
class PostCreate(BaseModel):
title: str = Field(min_length=1, max_length=120)
content: str = Field(min_length=1, max_length=10_000)
status: ArticleStatus = ArticleStatus.DRAFT
reading_time_minutes: int = Field(default=1, ge=1, le=120)
# 没有默认值:键必须出现,但值可以是 null。
published_at: datetime | None
tags: list[str] = Field(default_factory=list, max_length=5)
class StrictPostCreate(BaseModel):
title: str
# 这一字段拒绝把字符串 "3" 转换为整数。
reading_time_minutes: StrictInt = Field(ge=1, le=120)
def self_check() -> None:
valid = PostCreate(
title="先定义文章契约",
content="把可检查的规则放在请求模型中。",
published_at=None,
tags=["fastapi", "pydantic"],
)
assert valid.status is ArticleStatus.DRAFT
assert valid.reading_time_minutes == 1
# 默认的 int 字段允许 Pydantic v2 做常见的类型转换。
converted = PostCreate(
title="类型转换",
content="阅读时间来自 JSON 字符串。",
reading_time_minutes="3",
published_at=None,
)
assert converted.reading_time_minutes == 3
# published_at 没有默认值,因此省略键会失败;写成 null 则合法。
try:
PostCreate(title="缺少字段", content="没有 published_at")
except ValidationError as exc:
assert any(error["loc"] == ("published_at",) for error in exc.errors())
else:
raise AssertionError("缺少 published_at 应该失败")
try:
PostCreate(
title="",
content="正文",
reading_time_minutes=0,
published_at=None,
status="archived",
)
except ValidationError as exc:
locations = {error["loc"] for error in exc.errors()}
assert {("title",), ("reading_time_minutes",), ("status",)} <= locations
else:
raise AssertionError("非法字段应该失败")
try:
StrictPostCreate(title="严格输入", reading_time_minutes="3")
except ValidationError as exc:
assert exc.errors()[0]["loc"] == ("reading_time_minutes",)
else:
raise AssertionError("StrictInt 不应接受字符串")
print("input_rules self-check passed")
if __name__ == "__main__":
self_check()
在保存文件的目录中运行:
python ch03_contracts/input_rules.py
预期输出为 input_rules self-check passed。这次检查覆盖了模型创建和校验分支,没有启动 HTTP 服务;FastAPI 接口收到请求体时会使用同一个模型。
默认值、可空字段和缺省字段不是一回事
下面三种声明的含义不同:
required_text: str
nullable_required_text: str | None
optional_text: str | None = None
required_text 必须有字符串值;nullable_required_text 仍然必须在输入中出现,但值可以是 null;optional_text 可以不出现,缺少时模型会填入 None。Pydantic v2 不会因为类型包含 None 就自动提供默认值,所以创建模型时应按业务语义选择声明方式。
同样要区分“转换”与“严格拒绝”。普通的 int 字段可以把能够明确解释的 JSON 字符串转换成整数,这适合兼容表单、旧客户端或配置输入;StrictInt、StrictStr 等严格类型会拒绝这种转换,适合协议必须保持精确类型的字段。严格校验不是越多越好:先确认客户端契约和兼容要求,再在边界字段上启用。
枚举和边界规则要与业务含义一致
ArticleStatus 限制了可接受的状态值,但它没有判断“已经发布的文章必须有发布时间”这类跨字段业务规则。后者可能依赖当前时间、作者权限或数据库状态,应放在服务层或明确的业务校验中,而不是在字段校验器里查询数据库。字段模型负责输入形状,业务服务负责需要外部状态的判断。
长度与数量边界也需要有理由。max_length=10_000 是本案例的教学限制,不代表所有文章都必须使用这个值;生产项目应结合数据库列类型、网关限制、存储费用和编辑器策略设定,并在接口文档中说明。空列表默认使用 default_factory=list,避免不同模型实例共享同一个可变对象。
需要邮箱校验时再安装额外依赖
Pydantic 的 EmailStr 依赖 email-validator,基础的 Pydantic 安装不一定包含它。确实需要邮箱语义校验时,安装可选依赖并在模型中使用:
python -m pip install "pydantic[email]"
from pydantic import BaseModel, EmailStr
class AuthorCreate(BaseModel):
email: EmailStr
不要为了检查一个普通字符串就引入这个依赖;如果项目已经决定接收邮箱地址,使用 EmailStr 比手写不完整的正则更容易维护。邮箱格式校验也不等于邮箱所有权验证,后者需要业务流程和外部确认。
FastAPI 中的失败边界
当路由参数声明为 payload: PostCreate 时,FastAPI 会在调用路由函数前构造模型。缺少必填字段、长度超限、枚举值不在集合中等输入错误会在请求边界返回 422,业务函数不会被调用。模型成功构造后,文章标题是否重复、作者是否有发布权限等问题仍应由服务层决定;不要把数据库查询塞进字段 validator 来“提前校验”。
输入模型还应只暴露客户端真正可以提交的字段。即使客户端额外发送 author_id 或 created_at,服务端也应在模型配置或业务层明确决定是拒绝还是忽略,不能因为模型没有声明这些字段就默认把它们写进数据库。
资料来源
- 主要参考:fastapi-best-practices 中文 README 的“Pydantic / 大量使用 Pydantic”主题。本节沿用其对字段约束、枚举和邮箱类型的方向,按 Pydantic v2 重新编写了文章请求示例。
- 官方文档:Pydantic 字段、Pydantic 模型、FastAPI 请求体。

免费 AI IDE


更多建议: