FastAPI请求验证:10 个 Pydantic进阶技巧
422 Unprocessable Entity 不吓人,吓人的是你以为自己把请求验严了,结果只是“看起来像”。前端多传一个字段,后端悄悄吞了;"18" 被当成 18 过了;老接口的 userId、uid、user_id 混着飞,最后日志里只剩一串 loc。Pydantic v2 里,模型行为主要靠 ConfigDict 控,字段和模型级校验分别用 field_validator、model_validator,这些东西在 FastAPI 里比“写个 BaseModel 就完事”实用得多。
先上我平时更愿意落地的一版:
from typing import Annotated, Literalfrom fastapi import FastAPI, Query
from pydantic import (
AliasChoices,
BaseModel,
ConfigDict,
Field,
SecretStr,
TypeAdapter,
field_validator,
model_validator,
)
app = FastAPI()
classAPIModel(BaseModel):
model_config = ConfigDict(
extra="forbid", # 前端多传字段,直接打回去
strict=True, # 少让 Pydantic 帮你“脑补类型”
)
classAddressIn(APIModel):
city: Annotated[str, Field(min_length=2, max_length=20)]
detail: Annotated[str, Field(min_length=5, max_length=80)]
classCreateUserIn(APIModel):
user_id: str = Field(validation_alias=AliasChoices("user_id", "userId", "uid"))
age: int = Field(ge=18, le=80)
password: SecretStr
confirm_password: SecretStr
tags: list[str] = Field(default_factory=list)
address: AddressIn | None = None
@field_validator("user_id", mode="before")
@classmethod
defclean_user_id(cls, value):
return str(value).strip().lower()
@field_validator("tags")
@classmethod
defclean_tags(cls, values: list[str]):
seen = set()
result = []
for item in values:
item = item.strip().lower()
if item and item notin seen:
seen.add(item)
result.append(item)
if len(result) > 5:
raise ValueError("tags 不能超过 5 个")
return result
@model_validator(mode="after")
defcheck_passwords(self):
if self.password.get_secret_value() != self.confirm_password.get_secret_value():
raise ValueError("两次密码不一致")
return self
@app.post("/users")
asyncdefcreate_user(payload: CreateUserIn):
return {"user_id": payload.user_id, "tags": payload.tags}
classUserQuery(APIModel):
page: int = Field(1, ge=1)
size: int = Field(20, ge=1, le=100)
status: Literal["active", "locked", "deleted"] = "active"
@app.get("/users")
asyncdeflist_users(query: Annotated[UserQuery, Query()]):
return query
这段里其实已经把 10 个实战点塞进去了。
第一,extra="forbid" 要尽早开。Pydantic 默认对额外字段是 ignore,这玩意开发时看着省事,线上排错很烦,前端字段拼错了你都不一定知道。接口一旦对外,这里我第一眼就不太信默认值。
第二,能严格就严格。Pydantic 默认会做类型转换,很多场景是友好的,但支付金额、年龄、状态码这类字段,我不喜欢它替我猜。严格模式一开,脏数据早点死在入口。
第三,老字段兼容别硬写 if。validation_alias + AliasChoices 专门干这个,接口演进时很省事。你要兼容三种命名,模型里收口就行,不要把路由函数写成垃圾回收站。
第四,清洗动作尽量前置。像 user_id 去空格、转小写,tags 去重,这种事放 field_validator 里做,别等进业务层再补锅。请求模型不只是“拦错”,还应该顺手把脏输入收拾一下。
第五,跨字段规则别拆散。密码和确认密码、开始时间和结束时间、折扣价和原价,这些都该放 model_validator 里统一看,不然你在两个字段校验器里互相偷看,后面改需求很别扭。
第六,约束直接写在类型边上。Annotated + Field 这套我现在用得多,长度、范围、描述都挂在字段上,代码和文档是一份东西,后面看 OpenAPI 也直观。FastAPI 本身就会把这些元数据带进文档。
第七,嵌套模型别嫌麻烦。address 单独拆出来后,报错位置会更准,body -> address -> city 一眼能看懂。模型一坨写平,422 看着像天书。FastAPI 对嵌套模型支持本来就很完整。
第八,查询参数也别散着接。FastAPI 已经支持把一组查询参数收进 Pydantic 模型里了,这个能力从 0.115.0 起就有,分页、筛选、排序这种参数,收成一个模型之后,复用和校验都顺手得多。
第九,批量校验别为了凑模型再套一层。像导入接口、批量同步接口,我更愿意直接上 TypeAdapter:
batch_adapter = TypeAdapter(list[CreateUserIn])defparse_batch(raw_items: list[dict]) -> list[CreateUserIn]:
return batch_adapter.validate_python(raw_items)
TypeAdapter 可以直接校验普通类型、列表类型,不一定非得再包个 BatchRequest。另外它最好复用,别每次请求都临时 new 一次。
第十,敏感字段别在日志里裸奔。密码、密钥这类字段,用 SecretStr 起码先挡一层。线上排障时日志一开就是全量请求,这时候再想起来脱敏 usually 已经晚了。
最后补一句,我现在写 FastAPI 请求模型,默认会分出输入模型和输出模型,不让一个类从入参一路跑到响应。Pydantic v2 之后,FastAPI 对输入输出 schema 的表达也更精确了,这种拆分不是洁癖,是少踩坑。
这 10 个点没什么玄学,都是把“请求进门之前就该死的数据”拦在门口。业务代码已经够乱了,别让脏参数再进去添一脚。