Python技术迷

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, Field

SkuId = 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_validator

classAddressIn(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_validator

classCouponUseIn(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 StrEnum

classPayWay(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 ConfigDict

classPayNotifyIn(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 Field

classAuditIn(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 JSONResponse

app = 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 这一层。