Skip to content

10. FastAPI 表单数据 ​

表单常用于登录、修改资料和发布文章。学习这一章时,最重要的不是记住 Form() 的写法,而是弄清楚:前端发送的是什么格式,后端就必须按对应格式接收。

本章通过一套完整的前后端案例,演示如何使用 application/x-www-form-urlencoded 提交个人资料,并在页面中处理成功响应和校验错误。

一、表单与 JSON 的区别 ​

本章重点比较以下两种请求体格式:

格式Content-Type常见用途FastAPI 接收方式前端常用写法
URL 编码表单application/x-www-form-urlencoded登录、资料修改等文本表单Form()URLSearchParams 或普通 HTML 表单
JSONapplication/json结构化对象、数组Pydantic 模型、Body()JSON.stringify()

URL 编码表单发送的数据类似:

text
nickname=小明&age=20&city=上海

JSON 请求体则是:

json
{
  "nickname": "小明",
  "age": 20,
  "city": "上海"
}

虽然它们携带的字段相同,但 HTTP 请求体格式不同。后端使用 Form() 时,不会因为前端把字段放进 JSON 就自动读取成功。

提交方式区别 ​

前端提交内容请求格式FastAPI 接收方式
axios.post(url, form)(传普通对象)JSONPydantic 模型
axios.post(url, new URLSearchParams(...))普通文本表单Form()
axios.post(url, formData)(传 FormData)multipart/form-dataForm()、File()

二、安装依赖并准备目录 ​

FastAPI 使用 Form() 解析表单前,需要安装 python-multipart:

powershell
uv add python-multipart

本章示例目录如下:

text
form-demo/
├─ main.py
└─ static/
   └─ index.html
  • main.py:FastAPI 后端,提供个人资料表单接口。
  • static/index.html:前端页面,包含表单和提交脚本。
  • 后端同时托管前端页面,因此本案例不涉及跨域。

三、后端完整代码 ​

将下面代码保存为 main.py:

python
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():

python
nickname: Annotated[str, Form(min_length=1, max_length=30)]

这表示:

  • 从表单中读取名为 nickname 的字段。
  • 按 str 类型处理。
  • 长度必须为 1~30 个字符。
  • 没有默认值,所以它是必填字段。

年龄声明为 int:

python
age: Annotated[int, Form(ge=0, le=150)]

HTML 表单中的年龄最初是文本,例如 "20"。FastAPI 会尝试把它转换为整数 20,再检查是否位于 0~150 之间。传入 "abc" 或 -1 都会得到 422 校验错误。

下面两个字段有默认值,因此可以不传:

python
city: Annotated[str, Form(max_length=50)] = "未填写"
bio: Annotated[str | None, Form(max_length=200)] = None

注意:字段不传与字段提交空字符串并不完全相同。文本框存在但内容为空时,后端可能收到 "",而不是 None。

2. 为什么最后挂载静态页面 ​

python
app.mount("/", StaticFiles(...), name="static")

它让后端能够提供 static/index.html,并把 API 和页面放在同一个源下。挂载路径是 /,所以应放在 API 路由之后,避免影响接口匹配。

四、前端完整页面 ​

将下面代码保存为 static/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 表单示例</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:

html
<input name="nickname" />

对应后端:

python
nickname: Annotated[str, Form()]

id 主要用于 JavaScript 或 <label> 定位元素,不会代替表单字段名。nickname、userName、username 是三个不同字段,不能混用。

2. 使用 URLSearchParams 发送普通表单 ​

javascript
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 所在目录执行:

powershell
uv run uvicorn main:app --reload --host 127.0.0.1 --port 8000

打开:

text
http://127.0.0.1:8000/

填写并提交表单,预期响应类似:

json
{
  "message": "资料更新成功",
  "data": {
    "nickname": "小明",
    "age": 20,
    "city": "上海",
    "bio": "正在学习 FastAPI"
  }
}

也可以打开交互文档测试:

text
http://127.0.0.1:8000/docs

使用命令行测试 ​

PowerShell 中可以使用哈希表作为 URL 编码请求体:

powershell
Invoke-RestMethod `
  -Method Post `
  -Uri http://127.0.0.1:8000/api/profile `
  -Body @{nickname='小明'; age='20'; city='上海'; bio='正在学习 FastAPI'}

六、不使用 JavaScript 也能提交表单 ​

普通 HTML 表单默认使用 URL 编码格式,因此不写 fetch() 也能提交:

html
<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,响应可能包含以下内容(节选):

json
{
  "detail": [
    {
      "type": "missing",
      "loc": ["body", "nickname"],
      "msg": "Field required"
    }
  ]
}

重点关注两个位置:

  • loc:指出错误位于请求体中的 nickname 字段。
  • type、msg:说明错误原因。

常见测试如下:

操作结果原因
不传 nickname422缺少必填字段
age=abc422无法转换成整数
age=-1422不满足 ge=0
nickname 超过 30 个字符422不满足 max_length=30
bio 超过 200 个字符422不满足 max_length=200

HTML 中的 required、min、max 只能改善浏览器交互,不能代替后端校验。用户可以绕过页面直接调用 API,所以后端必须独立检查数据。

八、为什么不能把 JSON 发给 Form() 接口 ​

下面的前端写法与本章后端不匹配:

javascript
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

那么前端需要使用完整接口地址:

javascript
fetch("http://127.0.0.1:8000/api/profile", {
  method: "POST",
  body: new URLSearchParams(new FormData(profileForm)),
});

后端还需要允许该前端源,例如:

python
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 是否与后端参数名完全一致
返回 404URL 是否正确,静态页面挂载是否放在 API 路由之后
返回 405是否把 POST 接口用 GET 或其他方法请求
静态目录报错是否创建了 static 目录,且目录与 main.py 的相对位置正确
浏览器提示 CORS前端源是否加入 allow_origins,请求的协议、域名和端口是否一致

排查时建议同时观察三处:Uvicorn 终端、浏览器开发者工具的 Network 面板、接口返回的 detail。

十一、登录表单与数据安全 ​

  • OAuth2 密码表单通常使用 username、password 等约定字段,登录认证章节会完整演示。
  • 密码不应写入日志或响应。能够接收密码不等于完成认证,还需要密码散列、用户校验和令牌机制。
  • 使用 POST 不会自动加密数据。涉及密码或其他敏感信息时,实际部署还应使用 HTTPS。
  • 前端校验只能改善用户体验,后端校验才是接口必须执行的数据检查。

十二、本章小结与练习 ​

记住四组对应关系:

  1. Form() 对应表单字段,不对应 JSON。
  2. 普通文本表单可以使用 URLSearchParams 或原生 HTML 表单提交。
  3. 前端字段的 name 必须与后端 Form() 参数名一致。
  4. 前端校验改善体验,后端校验保证接口数据有效。

练习:

  1. 给个人资料增加可选的 website 字段,同时修改前端和后端,并限制最大长度。
  2. 给个人资料增加 subscribe 复选框,后端使用 bool 接收,分别测试勾选和不勾选时的结果。
  3. 将页面的错误输出改为逐条显示 loc 和 msg,而不只是打印完整 JSON。
  4. 分别测试缺少昵称、年龄为负数、年龄为文本以及误发 JSON,记录状态码与错误位置。