Skip to content

第 5 章 · 参数校验进阶与 Annotated

本章目标:掌握官方推荐的 Annotated 声明式校验写法,熟练使用数值/长度/正则约束与自定义校验器,并能读懂 422 错误响应。

5.1 Annotated:官方推荐写法

从 0.95.0 版本起,FastAPI 官方推荐用 Python 标准库的 Annotated 声明参数元数据:

python
from typing import Annotated
from fastapi import FastAPI, Query

app = FastAPI()


@app.get("/items/")
def read_items(
    q: Annotated[str | None, Query(max_length=50)] = None,
    #     类型 ─────────────┘        └── 校验元数据
):
    # GET /items/?q=hello 正常;q 超过 50 字符返回 422
    return {"q": q}

结构是 Annotated[实际类型, 元数据1, 元数据2, ...],默认值仍写在参数的 = 后面。对比旧写法:

python
# ❌ 旧写法(0.95.0 之前):Query 占据了默认值的位置
def read_items(q: str | None = Query(default=None, max_length=50)): ...

# ✅ 新写法:默认值回归默认值,校验信息放进 Annotated
def read_items(q: Annotated[str | None, Query(max_length=50)] = None): ...

官方给出的 Annotated 优势:

  • 默认值语义不被污染:直接调用这个函数(不经 FastAPI)时,参数就是普通参数,不会拿到 QueryInfo 之类的对象;
  • 可复用Annotated 类型可以抽成别名在多个接口间共享;
  • 兼容其他工具:同一函数还能被 Typer 等同样基于 Annotated 的库使用。

Annotated 里不能用 Query(default=...)

Annotated[str, Query(default="rick")] = "morty" 会报错。在 Annotated 内部,Query() 不接受 default 参数——默认值只写在参数 = 后面。

5.2 字符串约束:长度与正则

Query/Path/Body/Field 共享同一套约束参数:

python
from typing import Annotated
from fastapi import FastAPI, Query

app = FastAPI()


@app.get("/search/")
def search(
    q: Annotated[
        str,
        Query(
            min_length=3,                      # 最少 3 个字符
            max_length=50,                     # 最多 50 个字符
            pattern=r"^[\w\u4e00-\u9fa5 ]+$",  # 只允许字母数字下划线中文空格
            title="搜索关键词",
            description="长度 3-50,仅限中英文、数字与空格",
        ),
    ],
):
    return {"q": q}

pattern 使用正则约束格式,比如强制 ^fixedquery$ 就是精确匹配。title/description 不参与校验,但会展示在 OpenAPI 文档里。

5.3 数值约束:gt / ge / lt / le

路径参数同样可以校验,用 Path

python
from typing import Annotated
from fastapi import FastAPI, Path

app = FastAPI()


@app.get("/items/{item_id}")
def read_item(
    item_id: Annotated[int, Path(title="商品 ID", gt=0, lt=10000)],
    size: Annotated[float, Query(ge=0.1, le=10)] = 1.0,
):
    # item_id 必须是 1..9999 的整数(gt=0 排除 0 和负数)
    # size 必须在 0.1 到 10 之间(含边界,ge/le 是大于等于/小于等于)
    return {"item_id": item_id, "size": size}

四个缩写的含义务必分清:gt=greater than(严格大于)、ge=greater or equal、lt=less than、le=less or equal。想表达"必须为正数但可以小于 1"只能用 gt=0ge=1 会错误地排除 0.5。

5.4 Pydantic 模型字段的校验:Field

请求体字段用 Field,参数名与 Query/Path 完全一致:

python
from pydantic import BaseModel, Field
from fastapi import FastAPI

app = FastAPI()


class Product(BaseModel):
    name: str = Field(min_length=1, max_length=100, examples=["机械键盘"])
    price: float = Field(gt=0, description="单价,必须大于 0")
    stock: int = Field(default=0, ge=0)
    sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")   # 形如 ABC-1234


@app.post("/products/")
def create(p: Product):
    return p.model_dump()

5.5 自定义校验器

内置约束不够用时,Pydantic v2 提供 field_validator(单字段)和 model_validator(整体):

python
from pydantic import BaseModel, field_validator, model_validator


class Registration(BaseModel):
    username: str
    password: str
    password_confirm: str

    @field_validator("username")
    @classmethod
    def username_alphanumeric(cls, v: str) -> str:
        if not v.isalnum():
            raise ValueError("用户名只能包含字母和数字")
        return v.lower()   # 可以顺便做规范化转换

    @model_validator(mode="after")
    def passwords_match(self) -> "Registration":
        # mode="after":所有字段校验通过后执行,可访问 self 的全部字段
        if self.password != self.password_confirm:
            raise ValueError("两次输入的密码不一致")
        return self

校验失败抛出的 ValueError 会被 Pydantic 捕获并进入 422 响应的 detail 列表,用户看到的是友好错误而非堆栈。

5.6 读懂 422 错误响应

一次失败请求的响应体:

json
{
  "detail": [
    {
      "type": "greater_than",
      "loc": ["body", "price"],
      "msg": "Input should be greater than 0",
      "input": "-5",
      "ctx": { "gt": 0 }
    }
  ]
}
  • loc:错误位置数组。["path","item_id"] 是路径参数,["query","q"] 是查询参数,["body","price"] 是请求体字段,嵌套模型会给出完整路径如 ["body","items",0,"url"]
  • type:机器可读的错误类型(int_parsing、string_too_long、greater_than…);
  • ctx:错误上下文(违反的约束值)。

前端可以按 loc 精确定位到表单控件——这是 FastAPI 校验体系对全栈体验的最大贡献。

5.7 本章小结

  • 新代码统一用 Annotated[类型, Query(...)] 写法,默认值回归 =;Annotated 内不能再用 default=;
  • 字符串约束 min_length/max_length/pattern,数值约束 gt/ge/lt/le,Query/Path/Body/Field 通用;
  • 自定义校验用 field_validator(单字段)与 model_validator(mode="after")(跨字段);
  • 422 响应的 loc/type/msg/ctx 能精确定位每个错误,嵌套模型也不例外。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. 关于 Annotated 写法,下列哪句是正确的?

2. 要求参数必须大于 0 且可以取 0.5,应该用哪个约束?

3. 422 响应中 loc 为 ["body", "items", 0, "url"] 表示什么?

4. 想校验"两个字段必须相等"这类跨字段规则,应该用?

🛠️ 动手实践

  1. 给第 4 章的 Product 模型补全约束:name 1–50 字符、price > 0 且 < 100000、sku 正则 ^[A-Z]{3}-\d{4}$,用 curl 验证三种非法输入的 422 报错。
  2. 写一个 field_validator 把用户输入的手机号规范化:去掉 +86 前缀和所有连字符,最终必须是 11 位数字,否则抛 ValueError。
  3. 实现 GET /prices/{amount},要求 amount 是 Annotated[float, Path(gt=0)],测试 ?amount=0/prices/-1 分别得到什么错误。

校验体系已经完整,进入第 6 章控制响应的形状。