10. FastAPI 表单数据
表单常用于登录、修改资料和发布文章。学习这一章时,最重要的不是记住 Form() 的写法,而是弄清楚:前端发送的是什么格式,后端就必须按对应格式接收。
本章通过一套完整的前后端案例,演示如何使用 application/x-www-form-urlencoded 提交个人资料,并在页面中处理成功响应和校验错误。
一、表单与 JSON 的区别
本章重点比较以下两种请求体格式:
| 格式 | Content-Type | 常见用途 | FastAPI 接收方式 | 前端常用写法 |
|---|---|---|---|---|
| URL 编码表单 | application/x-www-form-urlencoded | 登录、资料修改等文本表单 | Form() | URLSearchParams 或普通 HTML 表单 |
| JSON | application/json | 结构化对象、数组 | Pydantic 模型、Body() | JSON.stringify() |
URL 编码表单发送的数据类似:
nickname=小明&age=20&city=上海JSON 请求体则是:
{
"nickname": "小明",
"age": 20,
"city": "上海"
}虽然它们携带的字段相同,但 HTTP 请求体格式不同。后端使用 Form() 时,不会因为前端把字段放进 JSON 就自动读取成功。
提交方式区别
| 前端提交内容 | 请求格式 | FastAPI 接收方式 |
|---|---|---|
axios.post(url, form)(传普通对象) | JSON | Pydantic 模型 |
axios.post(url, new URLSearchParams(...)) | 普通文本表单 | Form() |
axios.post(url, formData)(传 FormData) | multipart/form-data | Form()、File() |
二、安装依赖并准备目录
FastAPI 使用 Form() 解析表单前,需要安装 python-multipart:
uv add python-multipart本章示例目录如下:
form-demo/
├─ main.py
└─ static/
└─ index.htmlmain.py:FastAPI 后端,提供个人资料表单接口。static/index.html:前端页面,包含表单和提交脚本。- 后端同时托管前端页面,因此本案例不涉及跨域。
三、后端完整代码
将下面代码保存为 main.py:
from pathlib import Path
from typing import Annotated
from fastapi import FastAPI, Form
from fastapi.staticfiles import StaticFiles
app = FastAPI(title="FastAPI 表单数据示例")
BASE_DIR = Path(__file__).resolve().parent
@app.post("/api/profile")
def update_profile(
nickname: Annotated[str, Form(min_length=1, max_length=30)],
age: Annotated[int, Form(ge=0, le=150)],
city: Annotated[str, Form(max_length=50)] = "未填写",
bio: Annotated[str | None, Form(max_length=200)] = None,
):
"""接收 URL 编码的个人资料表单。"""
return {
"message": "资料更新成功",
"data": {
"nickname": nickname,
"age": age,
"city": city,
"bio": bio,
},
}
# 静态页面挂载在最后,避免先于 API 路由接管所有路径。
app.mount(
"/",
StaticFiles(directory=str(BASE_DIR / "static"), html=True),
name="static",
)1. 理解表单参数
update_profile() 中的每个参数都使用了 Form():
nickname: Annotated[str, Form(min_length=1, max_length=30)]这表示:
- 从表单中读取名为
nickname的字段。 - 按
str类型处理。 - 长度必须为 1~30 个字符。
- 没有默认值,所以它是必填字段。
年龄声明为 int:
age: Annotated[int, Form(ge=0, le=150)]HTML 表单中的年龄最初是文本,例如 "20"。FastAPI 会尝试把它转换为整数 20,再检查是否位于 0~150 之间。传入 "abc" 或 -1 都会得到 422 校验错误。
下面两个字段有默认值,因此可以不传:
city: Annotated[str, Form(max_length=50)] = "未填写"
bio: Annotated[str | None, Form(max_length=200)] = None注意:字段不传与字段提交空字符串并不完全相同。文本框存在但内容为空时,后端可能收到 "",而不是 None。
2. 为什么最后挂载静态页面
app.mount("/", StaticFiles(...), name="static")它让后端能够提供 static/index.html,并把 API 和页面放在同一个源下。挂载路径是 /,所以应放在 API 路由之后,避免影响接口匹配。
四、前端完整页面
将下面代码保存为 static/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 表单示例</title>
<style>
body {
max-width: 720px;
margin: 40px auto;
padding: 0 16px;
font-family: sans-serif;
}
form {
display: grid;
gap: 12px;
margin-bottom: 32px;
}
label {
display: grid;
gap: 4px;
}
pre {
padding: 12px;
background: #f4f4f4;
white-space: pre-wrap;
}
</style>
</head>
<body>
<h1>FastAPI 表单示例</h1>
<h2>修改个人资料</h2>
<form id="profile-form">
<label>
昵称
<input name="nickname" maxlength="30" required />
</label>
<label>
年龄
<input name="age" type="number" min="0" max="150" value="20" required />
</label>
<label>
城市
<input name="city" value="上海" />
</label>
<label>
个人简介
<textarea name="bio" maxlength="200"></textarea>
</label>
<button type="submit">保存资料</button>
</form>
<h3>服务器响应</h3>
<pre id="profile-result">尚未提交</pre>
<script>
const profileForm = document.querySelector("#profile-form");
const profileResult = document.querySelector("#profile-result");
profileForm.addEventListener("submit", async (event) => {
event.preventDefault();
try {
// 收集表单字段,并转换成 URL 编码格式。
const body = new URLSearchParams(new FormData(profileForm));
const response = await fetch("/api/profile", {
method: "POST",
body,
});
const result = await response.json();
profileResult.textContent =
`HTTP ${response.status}\n` + JSON.stringify(result, null, 2);
} catch (error) {
profileResult.textContent = `请求失败:${error.message}`;
}
});
</script>
</body>
</html>1. name 必须与后端参数对应
前端字段是否能让后端识别,主要看 name:
<input name="nickname" />对应后端:
nickname: Annotated[str, Form()]id 主要用于 JavaScript 或 <label> 定位元素,不会代替表单字段名。nickname、userName、username 是三个不同字段,不能混用。
2. 使用 URLSearchParams 发送普通表单
const body = new URLSearchParams(new FormData(profileForm));这里先用 FormData 收集页面字段,再转换成 URL 编码格式。浏览器发送请求时会自动设置对应的 Content-Type,后端由 Form() 读取。
event.preventDefault() 用来阻止浏览器默认的表单跳转,让页面通过 fetch() 提交并直接显示响应。
3. 正确处理成功和校验失败
fetch() 遇到 400、422 或 500 时,通常不会因为 HTTP 状态码而自动进入 catch。因此页面代码会读取响应体,并把状态码一起显示出来。
catch 主要处理网络断开、服务未启动、响应不是预期 JSON 等异常情况。实际项目中还可以根据 response.ok 分别渲染成功提示和错误列表。
五、启动并联调
确保 main.py 和 static/index.html 都已保存,然后在 main.py 所在目录执行:
uv run uvicorn main:app --reload --host 127.0.0.1 --port 8000打开:
http://127.0.0.1:8000/填写并提交表单,预期响应类似:
{
"message": "资料更新成功",
"data": {
"nickname": "小明",
"age": 20,
"city": "上海",
"bio": "正在学习 FastAPI"
}
}也可以打开交互文档测试:
http://127.0.0.1:8000/docs使用命令行测试
PowerShell 中可以使用哈希表作为 URL 编码请求体:
Invoke-RestMethod `
-Method Post `
-Uri http://127.0.0.1:8000/api/profile `
-Body @{nickname='小明'; age='20'; city='上海'; bio='正在学习 FastAPI'}六、不使用 JavaScript 也能提交表单
普通 HTML 表单默认使用 URL 编码格式,因此不写 fetch() 也能提交:
<form action="/api/profile" method="post">
<input name="nickname" placeholder="昵称" required />
<input name="age" type="number" value="20" required />
<input name="city" value="上海" />
<button type="submit">提交</button>
</form>提交后浏览器会跳转到接口地址,并直接显示返回的 JSON。fetch() 的价值是阻止页面跳转、在当前页面处理结果以及展示更友好的错误信息。
原生 HTML 表单支持 GET 和 POST。本例使用 POST,因此后端也使用 @app.post("/api/profile");如果方法不一致,会得到 405 Method Not Allowed。
七、看懂 422 表单校验错误
假设提交个人资料时缺少 nickname,响应可能包含以下内容(节选):
{
"detail": [
{
"type": "missing",
"loc": ["body", "nickname"],
"msg": "Field required"
}
]
}重点关注两个位置:
loc:指出错误位于请求体中的nickname字段。type、msg:说明错误原因。
常见测试如下:
| 操作 | 结果 | 原因 |
|---|---|---|
不传 nickname | 422 | 缺少必填字段 |
age=abc | 422 | 无法转换成整数 |
age=-1 | 422 | 不满足 ge=0 |
nickname 超过 30 个字符 | 422 | 不满足 max_length=30 |
bio 超过 200 个字符 | 422 | 不满足 max_length=200 |
HTML 中的 required、min、max 只能改善浏览器交互,不能代替后端校验。用户可以绕过页面直接调用 API,所以后端必须独立检查数据。
八、为什么不能把 JSON 发给 Form() 接口
下面的前端写法与本章后端不匹配:
fetch("/api/profile", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
nickname: "小明",
age: 20,
city: "上海",
}),
});它发送的是 JSON,而 /api/profile 声明从表单读取字段,因此通常会出现缺少 nickname、age 等字段的 422 错误。
应当根据后端声明选择格式:
| 后端声明 | 前端请求体 |
|---|---|
nickname: Annotated[str, Form()] | URLSearchParams 或普通 HTML 表单 |
| Pydantic 模型作为 JSON 请求体 | JSON.stringify(),并设置 application/json |
一个接口使用哪种格式,应由后端参数声明和接口约定决定。前端不能只把数据内容写对,还必须使用正确的 Content-Type 和请求体编码。
九、前后端分离时的跨域配置
本章通过 FastAPI 托管页面,页面请求 /api/profile,页面和 API 同源,不需要 CORS。
如果前端由另一个开发服务器提供,例如:
- 前端:
http://127.0.0.1:5173 - 后端:
http://127.0.0.1:8000
那么前端需要使用完整接口地址:
fetch("http://127.0.0.1:8000/api/profile", {
method: "POST",
body: new URLSearchParams(new FormData(profileForm)),
});后端还需要允许该前端源,例如:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["http://127.0.0.1:5173"],
allow_methods=["POST", "OPTIONS"],
allow_headers=["Content-Type"],
)allow_origins 应填写页面实际来源,不要写成接口路径。localhost 和 127.0.0.1 在浏览器里也是不同的源。
普通表单导航与 JavaScript 跨域读取响应的规则不完全相同,不要把所有表单提交问题都归因于 CORS。先查看终端是否收到请求,再查看浏览器控制台和网络面板。
十、常见问题排查
| 现象 | 优先检查 |
|---|---|
启动时提示 Form data requires "python-multipart" | 是否执行 uv add python-multipart,安装后是否重启服务 |
| 所有表单字段都提示缺失 | 是否把 JSON 发给了 Form() 接口,或请求格式与后端不一致 |
| 某一个字段提示缺失 | 前端 name 是否与后端参数名完全一致 |
| 返回 404 | URL 是否正确,静态页面挂载是否放在 API 路由之后 |
| 返回 405 | 是否把 POST 接口用 GET 或其他方法请求 |
| 静态目录报错 | 是否创建了 static 目录,且目录与 main.py 的相对位置正确 |
| 浏览器提示 CORS | 前端源是否加入 allow_origins,请求的协议、域名和端口是否一致 |
排查时建议同时观察三处:Uvicorn 终端、浏览器开发者工具的 Network 面板、接口返回的 detail。
十一、登录表单与数据安全
- OAuth2 密码表单通常使用
username、password等约定字段,登录认证章节会完整演示。 - 密码不应写入日志或响应。能够接收密码不等于完成认证,还需要密码散列、用户校验和令牌机制。
- 使用
POST不会自动加密数据。涉及密码或其他敏感信息时,实际部署还应使用 HTTPS。 - 前端校验只能改善用户体验,后端校验才是接口必须执行的数据检查。
十二、本章小结与练习
记住四组对应关系:
Form()对应表单字段,不对应 JSON。- 普通文本表单可以使用
URLSearchParams或原生 HTML 表单提交。 - 前端字段的
name必须与后端Form()参数名一致。 - 前端校验改善体验,后端校验保证接口数据有效。
练习:
- 给个人资料增加可选的
website字段,同时修改前端和后端,并限制最大长度。 - 给个人资料增加
subscribe复选框,后端使用bool接收,分别测试勾选和不勾选时的结果。 - 将页面的错误输出改为逐条显示
loc和msg,而不只是打印完整 JSON。 - 分别测试缺少昵称、年龄为负数、年龄为文本以及误发 JSON,记录状态码与错误位置。