09. FastAPI 请求参数类型验证:Field 方式
Field 用来声明请求体字段的规则
Pydantic 模型负责描述“请求体有哪些字段、字段是什么类型”,Field 则继续补充长度、范围、精度、默认值和文档说明。
例如,一个商品创建接口可以提出这些要求:
- 商品名不能为空,最长 50 个字符。
- 价格必须大于 0,最多保留两位小数。
- 库存不能为负数,而且 JSON 中必须是整数,不能是字符串
"10"。 - 最多提交 5 个标签。
- 请求体不能包含模型中未声明的字段。
本系列使用 Pydantic 2。完整的数据处理过程是:
前端提交 JSON 请求体
→ FastAPI 创建 Pydantic 模型
→ Field 验证字段
→ 接口获得验证后的模型对象
→ model_dump() 导出 Python 字典Field 负责验证输入,model_dump() 负责导出验证后的数据,两者用途不同。
完整案例:前端提交商品,后端用 Field 验证
准备两个文件,并放在同一个目录中:
demo/
├─ main.py
└─ index.html这个案例不连接数据库。后端返回一个固定的模拟 id,重点观察 Field 如何拦截不合法请求体。
后端:main.py
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 dataproduct: ProductIn 表示从请求体读取商品数据。只有验证成功才会执行 create_product();验证失败时,FastAPI 通常返回 422,不会继续使用错误数据创建商品。
Field 规则逐项说明
| 字段 | Field 写法 | 验证效果 |
|---|---|---|
name | min_length=1 | 至少 1 个字符 |
name | max_length=50 | 最多 50 个字符 |
price | gt=0 | 必须大于 0,gt 是 greater than |
price | max_digits=10 | 整数和小数部分合计最多 10 位数字 |
price | decimal_places=2 | 小数部分最多 2 位 |
stock | default=0 | 未传库存时默认为 0,但不代表可以传 null |
stock | ge=0 | 必须大于或等于 0,ge 是 greater than or equal |
stock | strict=True | JSON 字符串 "10" 不能当作整数 10 |
tags | default_factory=list | 未传标签时,为每个模型对象创建新的空列表 |
tags | max_length=5 | 列表最多包含 5 个元素 |
description 和 examples 主要用于生成接口文档,不承担业务验证。
extra="forbid" 是模型配置,不是某个字段的 Field 规则。它让请求体中的额外字段也被拒绝,例如误写成 naem 时不会静默忽略。
前端:index.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>这里有两个有意设计的前端细节:
price以 JSON 字符串发送,例如"199.90",后端的Decimal会精确解析它,避免把金额先转换成 JavaScript 浮点数。stock使用Number()转换后再发送,使 JSON 中是数字10。因为后端设置了strict=True,直接发送字符串"10"会验证失败。
前端表单没有添加 required、min 等浏览器限制,是为了能亲手把错误数据发给后端,观察 Field 的 422 响应。真实项目可以同时做前端校验来改善输入体验,但前端校验可以被绕过,不能替代后端验证。
运行并测试 Field 验证
在 main.py 和 index.html 所在目录启动:
uv run uvicorn main:app --reload浏览器打开 http://127.0.0.1:8000/。使用默认数据提交后,应看到类似响应:
HTTP 201{
"name": "机械键盘",
"price": "199.90",
"stock": 10,
"tags": ["电脑", "外设"],
"id": 1
}响应中的 price 是字符串,因为接口使用了 model_dump(mode="json");stock 仍然是数字。
然后逐项修改表单并重新提交:
| 测试数据 | 命中的规则 | 预期结果 |
|---|---|---|
name 改为空字符串 | min_length=1 | 422 |
price 改为 "0" | gt=0 | 422 |
price 改为 "12.345" | decimal_places=2 | 422 |
stock 改为 -1 | ge=0 | 422 |
stock 改为 10.5 | int 类型与严格规则 | 422 |
| 输入 6 个标签 | max_length=5 | 422 |
打开 http://127.0.0.1:8000/docs,还可以测试两种前端表单不容易构造的情况:
- 把
stock写成 JSON 字符串"10",触发strict=True。 - 给请求体增加
"unknown": 123,触发extra="forbid"。
422 响应的 detail 是错误列表。重点查看其中的 loc,例如:
{
"loc": ["body", "price"],
"msg": "Input should be greater than 0"
}loc 表示错误位于请求体的 price 字段。msg 的文字可能随 Pydantic 版本变化,前端更适合根据字段位置和错误类型展示提示,不要依赖整段英文文本。
Field 验证后如何配合 model_dump()
model_dump() 不会替代 Field 验证
FastAPI 接收到请求体后,先根据 ProductIn 和 Field 规则创建 product。在接口中调用:
data = product.model_dump()只是把已经验证过的模型转换成字典。model_dump() 本身不是字段验证器,也不会代替 gt=0、max_length=5 等规则。
默认模式与 JSON 模式
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() 会包含它们的默认值。如果只想保留调用方显式提交的字段:
product.model_dump(exclude_unset=True)
# {'name': '机械键盘', 'price': Decimal('199.90')}这在 PATCH 局部更新中很有用。先定义更新模型:
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)然后在接口中只导出本次传入的字段:
@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},得到:
{"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),观察值类型与字段集合的差别。