FastAPI请求验证:10 个 Pydantic进阶技巧
接口一上线,422 报错刷了一屏。
日志里看着都挺干净,业务代码根本没进去:
POST /orders/import
status=422
msg=Input should be a valid integer
field=items.3.sku_id
这种问题我一般不先翻 service。请求都没过 FastAPI 的验证层,业务代码再漂亮也没用。FastAPI 的请求体就是靠 Pydantic model 做校验,字段约束可以放在 Field、Query 这些地方;Pydantic v2 里校验器主要用 field_validator、model_validator 这一套。
下面这些写法,都是我觉得线上接口迟早会用到的。
先把字段别写成裸类型。
from typing import Annotated
from pydantic import BaseModel, FieldSkuId = Annotated[int, Field(gt=0, description="商品ID必须大于0")]
Qty = Annotated[int, Field(ge=1, le=999, description="单次下单数量")]
classOrderLine(BaseModel):
sku_id: SkuId
qty: Qty
sku_id: int 当然能跑,但它只能挡住字符串,挡不住 0、-1 这种脏数据。很多库存异常,最后查半天,发现入口就没拦。
第二个,字符串不要只写 str。
NameText = Annotated[str, Field(min_length=2, max_length=30)]classUserCreate(BaseModel):
username: NameText
phone: Annotated[str, Field(pattern=r"^1[3-9]\d{9}$")]
这里我不会在业务层再写一堆 if not phone。请求校验层能挡住,就别放进去污染 service。
第三个,前端传空字符串很常见,尤其是表单。
from pydantic import field_validatorclassAddressIn(BaseModel):
city: str
detail: str | None = None
@field_validator("detail", mode="before")
@classmethod
defempty_to_none(cls, v):
if isinstance(v, str) andnot v.strip():
returnNone
return v.strip() if isinstance(v, str) else v
这个地方我第一眼就不太信前端。它说没填,可能传的是 "",也可能是 " "。
第四个,多个字段之间有关系,别拆开校验。
from pydantic import BaseModel, model_validatorclassCouponUseIn(BaseModel):
user_id: int
coupon_id: int
order_amount: int
discount_amount: int
@model_validator(mode="after")
defcheck_discount(self):
if self.discount_amount >= self.order_amount:
raise ValueError("优惠金额不能大于等于订单金额")
return self
这种校验放在 controller 里也能写,但后面接口一多,肯定散。散了就漏。
第五个,枚举字段不要放任字符串乱飞。
from enum import StrEnumclassPayWay(StrEnum):
wx = "wx"
alipay = "alipay"
balance = "balance"
classPayIn(BaseModel):
order_no: str
pay_way: PayWay
我见过有人写 pay_way: str,然后业务里判断 "wechat"、"wxpay"、"WX"。这类代码一出现,后面肯定会补锅。
第六个,列表校验要校验到元素,不是只校验 list。
classBatchImportIn(BaseModel):
items: Annotated[list[OrderLine], Field(min_length=1, max_length=200)]
别小看 max_length=200。批量接口不控数量,压测没事,运营一导入大文件,数据库连接池先开始骂人。
第七个,字段别名要提前处理,特别是接第三方回调。
from pydantic import ConfigDictclassPayNotifyIn(BaseModel):
model_config = ConfigDict(populate_by_name=True)
trade_no: str = Field(alias="tradeNo")
paid_at: int = Field(alias="paidAt")
amount: int
第三方喜欢驼峰,后端喜欢下划线。别在接口里手动 body.get("tradeNo"),这种代码多了以后,字段改一次能漏三个地方。
第八个,多余字段默认最好别悄悄放过。
classStrictOrderIn(BaseModel):
model_config = ConfigDict(extra="forbid") user_id: int
items: list[OrderLine]
remark: str | None = None
extra="forbid" 我一般会用在核心写接口,比如下单、支付、退款。多传字段直接拒掉。原因很简单,脏请求越早暴露越便宜。
第九个,默认值别写成业务陷阱。
from datetime import datetime, timezone
from pydantic import FieldclassAuditIn(BaseModel):
operator: str
action: str
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
别写 created_at: datetime = datetime.now()。这玩意儿不是每次请求都重新算。看着像小问题,线上日志时间一乱,排查链路能把人绕晕。
第十个,把验证错误改成人能看的日志。
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponseapp = FastAPI()
@app.exception_handler(RequestValidationError)
asyncdefbad_request_handler(request: Request, exc: RequestValidationError):
first = exc.errors()[0] if exc.errors() else {}
return JSONResponse(
status_code=422,
content={
"code": "PARAM_INVALID",
"path": ".".join(map(str, first.get("loc", []))),
"msg": first.get("msg", "参数错误"),
},
)
默认 422 不是不能用,但排线上问题时,我更想看到哪个字段炸了。尤其是批量接口,items.17.qty 这种路径,比一句“参数错误”有用多了。
最后贴一个稍微完整点的入口模型:
classCreateOrderIn(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True) user_id: Annotated[int, Field(gt=0)]
items: Annotated[list[OrderLine], Field(min_length=1, max_length=100)]
pay_way: PayWay
coupon_id: int | None = None
remark: Annotated[str | None, Field(max_length=120)] = None
@field_validator("remark", mode="before")
@classmethod
defclean_remark(cls, v):
if v isNone:
returnNone
v = v.strip()
return v orNone
@model_validator(mode="after")
defcheck_coupon(self):
if self.coupon_id isnotNoneand self.pay_way == PayWay.balance:
raise ValueError("余额支付暂不支持优惠券")
return self
请求验证这块,别等业务层兜底。
参数错了就挡在门口,字段脏了就让它暴露,第三方乱传就直接拒。FastAPI + Pydantic 好用的地方不只是少写几行代码,而是把很多事故提前压在 422 这一层。