Skip to content

09. FastAPI 请求参数类型验证:Field 方式 ​

Field 用来声明请求体字段的规则 ​

Pydantic 模型负责描述“请求体有哪些字段、字段是什么类型”,Field 则继续补充长度、范围、精度、默认值和文档说明。

例如,一个商品创建接口可以提出这些要求:

  • 商品名不能为空,最长 50 个字符。
  • 价格必须大于 0,最多保留两位小数。
  • 库存不能为负数,而且 JSON 中必须是整数,不能是字符串 "10"。
  • 最多提交 5 个标签。
  • 请求体不能包含模型中未声明的字段。

本系列使用 Pydantic 2。完整的数据处理过程是:

text
前端提交 JSON 请求体
  → FastAPI 创建 Pydantic 模型
  → Field 验证字段
  → 接口获得验证后的模型对象
  → model_dump() 导出 Python 字典

Field 负责验证输入,model_dump() 负责导出验证后的数据,两者用途不同。

完整案例:前端提交商品,后端用 Field 验证 ​

准备两个文件,并放在同一个目录中:

text
demo/
├─ main.py
└─ index.html

这个案例不连接数据库。后端返回一个固定的模拟 id,重点观察 Field 如何拦截不合法请求体。

后端:main.py ​

python
from decimal import Decimal

from fastapi import FastAPI
from fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict, Field

app = FastAPI()


class ProductIn(BaseModel):
    # 请求体出现未声明字段时直接拒绝
    model_config = ConfigDict(extra="forbid")

    name: str = Field(
        min_length=1,
        max_length=50,
        description="商品名称",
        examples=["机械键盘"],
    )
    price: Decimal = Field(
        gt=0,
        max_digits=10,
        decimal_places=2,
        description="商品价格",
    )
    stock: int = Field(default=0, ge=0, strict=True)
    tags: list[str] = Field(default_factory=list, max_length=5)


@app.get("/")
def read_index():
    return FileResponse("index.html")


@app.post("/products", status_code=201)
def create_product(product: ProductIn):
    # 执行到这里时,product 已通过 Field 验证

    # mode="json" 会把 Decimal 等值转换为 JSON 兼容值
    data = product.model_dump(mode="json")
    data["id"] = 1
    return data

product: ProductIn 表示从请求体读取商品数据。只有验证成功才会执行 create_product();验证失败时,FastAPI 通常返回 422,不会继续使用错误数据创建商品。

Field 规则逐项说明 ​

字段Field 写法验证效果
namemin_length=1至少 1 个字符
namemax_length=50最多 50 个字符
pricegt=0必须大于 0,gt 是 greater than
pricemax_digits=10整数和小数部分合计最多 10 位数字
pricedecimal_places=2小数部分最多 2 位
stockdefault=0未传库存时默认为 0,但不代表可以传 null
stockge=0必须大于或等于 0,ge 是 greater than or equal
stockstrict=TrueJSON 字符串 "10" 不能当作整数 10
tagsdefault_factory=list未传标签时,为每个模型对象创建新的空列表
tagsmax_length=5列表最多包含 5 个元素

description 和 examples 主要用于生成接口文档,不承担业务验证。

extra="forbid" 是模型配置,不是某个字段的 Field 规则。它让请求体中的额外字段也被拒绝,例如误写成 naem 时不会静默忽略。

前端:index.html ​

html
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>FastAPI Field 验证演示</title>
  </head>
  <body>
    <h1>创建商品</h1>

    <form id="product-form">
      <p>
        <label for="name">商品名称</label><br />
        <input id="name" name="name" value="机械键盘" />
      </p>

      <p>
        <label for="price">价格</label><br />
        <input id="price" name="price" value="199.90" />
      </p>

      <p>
        <label for="stock">库存</label><br />
        <input id="stock" name="stock" value="10" />
      </p>

      <p>
        <label for="tags">标签(用逗号分隔)</label><br />
        <input id="tags" name="tags" value="电脑, 外设" />
      </p>

      <button type="submit">提交商品</button>
    </form>

    <h2>后端响应</h2>
    <pre id="result">尚未提交</pre>

    <script>
      const form = document.querySelector("#product-form");
      const result = document.querySelector("#result");

      form.addEventListener("submit", async (event) => {
        event.preventDefault();

        const formData = new FormData(form);
        const stockText = formData.get("stock").trim();

        const payload = {
          name: formData.get("name"),
          // 金额保留为字符串,交给后端 Decimal 精确解析
          price: formData.get("price"),
          // stock 声明了 strict=True,所以这里要发送 JSON 数字
          stock: stockText === "" ? null : Number(stockText),
          tags: formData
            .get("tags")
            .split(/[,,]/)
            .map((tag) => tag.trim())
            .filter(Boolean),
        };

        try {
          const response = await fetch("/products", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify(payload),
          });

          const data = await response.json();

          // 成功时显示 201 和商品数据,失败时显示 422 和字段错误
          result.textContent =
            `HTTP ${response.status}\n` + JSON.stringify(data, null, 2);
        } catch (error) {
          result.textContent = `请求失败:${error.message}`;
        }
      });
    </script>
  </body>
</html>

这里有两个有意设计的前端细节:

  1. price 以 JSON 字符串发送,例如 "199.90",后端的 Decimal 会精确解析它,避免把金额先转换成 JavaScript 浮点数。
  2. stock 使用 Number() 转换后再发送,使 JSON 中是数字 10。因为后端设置了 strict=True,直接发送字符串 "10" 会验证失败。

前端表单没有添加 required、min 等浏览器限制,是为了能亲手把错误数据发给后端,观察 Field 的 422 响应。真实项目可以同时做前端校验来改善输入体验,但前端校验可以被绕过,不能替代后端验证。

运行并测试 Field 验证 ​

在 main.py 和 index.html 所在目录启动:

powershell
uv run uvicorn main:app --reload

浏览器打开 http://127.0.0.1:8000/。使用默认数据提交后,应看到类似响应:

text
HTTP 201
json
{
  "name": "机械键盘",
  "price": "199.90",
  "stock": 10,
  "tags": ["电脑", "外设"],
  "id": 1
}

响应中的 price 是字符串,因为接口使用了 model_dump(mode="json");stock 仍然是数字。

然后逐项修改表单并重新提交:

测试数据命中的规则预期结果
name 改为空字符串min_length=1422
price 改为 "0"gt=0422
price 改为 "12.345"decimal_places=2422
stock 改为 -1ge=0422
stock 改为 10.5int 类型与严格规则422
输入 6 个标签max_length=5422

打开 http://127.0.0.1:8000/docs,还可以测试两种前端表单不容易构造的情况:

  • 把 stock 写成 JSON 字符串 "10",触发 strict=True。
  • 给请求体增加 "unknown": 123,触发 extra="forbid"。

422 响应的 detail 是错误列表。重点查看其中的 loc,例如:

json
{
  "loc": ["body", "price"],
  "msg": "Input should be greater than 0"
}

loc 表示错误位于请求体的 price 字段。msg 的文字可能随 Pydantic 版本变化,前端更适合根据字段位置和错误类型展示提示,不要依赖整段英文文本。

Field 验证后如何配合 model_dump() ​

model_dump() 不会替代 Field 验证 ​

FastAPI 接收到请求体后,先根据 ProductIn 和 Field 规则创建 product。在接口中调用:

python
data = product.model_dump()

只是把已经验证过的模型转换成字典。model_dump() 本身不是字段验证器,也不会代替 gt=0、max_length=5 等规则。

默认模式与 JSON 模式 ​

python
product = ProductIn(name="机械键盘", price="199.90")

python_data = product.model_dump()
print(python_data)
# {
#     'name': '机械键盘',
#     'price': Decimal('199.90'),
#     'stock': 0,
#     'tags': []
# }

json_data = product.model_dump(mode="json")
print(json_data)
# {'name': '机械键盘', 'price': '199.90', 'stock': 0, 'tags': []}
  • model_dump() 默认使用 mode="python",price 仍然是 Decimal,适合后端计算或交给数据库层。
  • model_dump(mode="json") 返回 JSON 兼容的字典,Decimal 等值会转换成适合 JSON 表示的类型。
  • 两种模式返回的都是字典,不是 JSON 字符串;需要 JSON 字符串时才使用 model_dump_json()。

默认值与 exclude_unset ​

上面的 product 没有显式传入 stock 和 tags,但默认 model_dump() 会包含它们的默认值。如果只想保留调用方显式提交的字段:

python
product.model_dump(exclude_unset=True)
# {'name': '机械键盘', 'price': Decimal('199.90')}

这在 PATCH 局部更新中很有用。先定义更新模型:

python
class ProductUpdate(BaseModel):
    name: str | None = Field(default=None, min_length=1, max_length=50)
    price: Decimal | None = Field(
        default=None,
        gt=0,
        max_digits=10,
        decimal_places=2,
    )
    stock: int | None = Field(default=None, ge=0, strict=True)

然后在接口中只导出本次传入的字段:

python
@app.patch("/products/{product_id}")
def update_product(product_id: int, product: ProductUpdate):
    update_data = product.model_dump(
        mode="json",
        exclude_unset=True,
    )

    # 实际项目中根据 update_data 更新数据库
    return {"id": product_id, "changes": update_data}

假设请求体是 {"stock":0},得到:

python
{"stock": 0}

stock 会保留,因为 0 是显式传入的合法值;未传的 name 和 price 不会进入更新字典。若请求体是 {},结果就是空字典,实际项目应决定拒绝空更新还是直接返回。

exclude_unset=True 与 exclude_none=True 不同:前者排除“没有传入”的字段,后者排除“值为 None”的字段。如果业务允许用 null 清空某个值,不能随意使用 exclude_none=True 丢掉这个更新意图;字段是否允许 null,仍由模型类型和业务规则决定。

Field 验证的边界 ​

  • min_length=1 只检查长度,不会自动拒绝全是空格的字符串。需要去除空白或额外规则时,可配合 field_validator。
  • tags 的 max_length=5 限制列表长度,不会自动限制每个标签的字符串长度。
  • Field 适合单字段规则;“优惠价不能高于原价”涉及两个字段,需要模型验证器,例如 @model_validator(mode="after")。
  • 字段默认值如果也需要经过验证,可考虑 Field(validate_default=True)。
  • 验证通过只说明数据格式和约束符合要求,不代表库存足够、商品存在或当前用户有操作权限,这些仍属于业务检查。

练习:给 ProductIn 增加 sale_price: Decimal | None,使用 Field 限制它必须大于 0 且最多两位小数;再用模型验证器保证它不高于 price。最后分别调用 model_dump()、model_dump(mode="json") 和 model_dump(exclude_unset=True),观察值类型与字段集合的差别。