Skip to content

第 7 章:参数进阶 ​

第 3 章讲了路径与查询参数,第 4 章讲了请求体。本章补齐 FastAPI 全部 7 种参数来源,并解决一个工程问题:参数多起来后,怎么声明才不会被默认值和 Annotated 绕晕。


学习目标 ​

  • 掌握 FastAPI 的 7 种参数来源:Path / Query / Body / Header / Cookie / Form / File
  • 理解 FastAPI 判定参数来源的规则,能预测一个参数落到哪里
  • 能用 Annotated 写出可复用、默认值语义清晰的参数声明
  • 掌握各参数类的高频校验与元数据选项,并知道哪些场景不能用
  • 能独立完成一个同时使用 Path、Query、Header、Cookie 的端点

7.1 七种参数来源总览 ​

参数类来源典型用途额外依赖
PathURL 路径模板 {id}资源标识无
QueryURL ?key=value过滤、分页、排序无
Body请求体 JSON结构化数据(Pydantic 模型)无
Header请求头认证、追踪、内容协商无
CookieCookie会话、偏好设置无
Form表单编码 / multipart/form-dataHTML 表单提交python-multipart
Filemultipart/form-data文件上传python-multipart

7.1.1 参数来源判定流程 ​

只写 q: str | None = None 时,FastAPI 按下面的流程推断来源:

要点:显式标注优先级最高,能覆盖默认推断;但一旦标注了参数类,参数名就再也不会因为出现在路径模板里而被识别为 Path,必须显式写 Path()。


7.2 两种声明写法 ​

写法示例问题
❌ 旧式q: str | None = Query(None, max_length=50)默认值藏在 Query() 里,签名看不出 q 可选;无法抽成类型别名
✅ 推荐q: Annotated[str | None, Query(max_length=50)] = None约束与默认值分离,清晰可复用

Annotated[str | None, Query(max_length=50)] 只描述约束与元数据,= None 描述默认值,两件事分开,语义立刻清晰。

🔑 官方推荐 Annotated 的理由:一是默认值不再藏在参数类调用里;二是 Annotated[...] 可以抽成类型别名在多处复用,而带默认值的旧写法无法复用。

python
from typing import Annotated

from fastapi import Query

# 可复用类型别名:定义一次,处处复用
PageSize = Annotated[int, Query(ge=1, le=100, description="每页条数")]
PageNumber = Annotated[int, Query(ge=1, description="页码,从 1 开始")]


@app.get("/articles/")
async def list_articles(page: PageNumber = 1, size: PageSize = 20) -> dict[str, int]:
    return {"page": page, "size": size}

7.3 Path():路径参数约束 ​

路径参数天然必填,Path(...) 中的 ...(Ellipsis)表示必填。

python
from typing import Annotated

from fastapi import Path


@app.get("/items/{item_id}")
async def read_item(
    item_id: Annotated[int, Path(title="物品 ID", description="物品的数字标识", ge=1, lt=100000)],
) -> dict[str, int]:
    return {"item_id": item_id}

ge=1 的实际效果:/items/42 返回 200;/items/0 返回 422(Input should be greater than or equal to 1);/items/abc 返回 422(int_parsing);/items/100000 返回 422(触发 lt)。

常用数值约束:gt / ge / lt / le,由 Pydantic 执行,错误结构与其他校验错误完全一致。声明为 Path() 的参数名必须出现在路径模板中;反之,路径模板里的参数也别用 Query() 标注,否则请求直接 422。


7.4 Query():完整选项 ​

python
q: Annotated[
    str | None,
    Query(min_length=3, max_length=50, pattern=r"^[a-zA-Z0-9\- ]+$", alias="item-query",
          deprecated=True, description="检索关键词,仅允许字母、数字、空格与连字符",
          examples=["fastapi tutorial"]),
] = None
选项作用
min_length / max_length字符串长度约束
pattern正则约束(字符串须完全匹配该模式)
alias对外参数名与 Python 形参名不同,用于 item-query 这类带连字符的名字
deprecated=True在 Swagger UI 中标记为已废弃
title / description / examples文档元数据与示例值
include_in_schema=False该参数不出现在文档中
python
# 请求 /legacy/?item-query=fastapi
item_query: Annotated[str | None, Query(alias="item-query")] = None

💡 对外参数名统一用 kebab-case 或统一用 snake_case,选定后靠 alias 固定,并写进接口文档。


7.5 Body():单值与 embed ​

单值请求体的约束直接写在 Body() 上:

python
# 请求体是裸字符串 "fastapi"
name: Annotated[str, Body(min_length=3, max_length=30)] = ...

# 加上 embed=True 后请求体变成对象 {"name": "fastapi"}
name_embedded: Annotated[str, Body(embed=True, min_length=3)] = ...

embed=True 的真正用途:当你有多个 Body() 参数时,FastAPI 需要给每个参数一个键名来区分(多参数时默认就会 embed);显式写 embed=True 是为了在只有一个参数时也保持对象结构,避免客户端因参数增减而改请求体形状。

python
class Item(BaseModel):
    name: str
    price: float


@app.put("/items/{item_id}")
async def update_item(item_id: int, item: Item, importance: int = Body(gt=0)) -> dict[str, object]:
    # 请求体:{"item": {"name": "...", "price": 1.0}, "importance": 5}
    return {"item_id": item_id, "item": item, "importance": importance}

python
@app.get("/context/")
async def read_context(
    user_agent: Annotated[str | None, Header()] = None,       # 匹配 User-Agent
    x_token: Annotated[str | None, Header(convert_underscores=False)] = None,  # 匹配 X_Token
    session_id: Annotated[str | None, Cookie()] = None,       # Cookie 不做下划线转换
) -> dict[str, str | None]:
    return {"ua": user_agent, "token": x_token, "sid": session_id}

Header 的三个关键行为:

  1. convert_underscores 默认为 True:形参名里的下划线会转成连字符去匹配,所以 user_agent 对应请求头 User-Agent。
  2. 大小写不敏感:HTTP 请求头名本身不区分大小写(RFC 9110),Starlette 内部用不区分大小写的字典存储,User-Agent / user-agent / USER-AGENT 都能被 user_agent 接住;但 Python 形参名是大小写敏感的。
  3. Cookie 不做下划线转换,因为 Cookie 名区分大小写且常有连字符。

⚠️ 形参名不能以下划线开头(如 _user_agent),Pydantic 会把 _name 当私有属性而报错。

第 5 章用 response.set_cookie(...) 写入 Cookie,这里的 Cookie() 就是读取端,两端合起来形成闭环。


7.7 Form():表单参数 ​

Form 与 File 都依赖 python-multipart 解析表单编码与 multipart/form-data:

bash
pip install python-multipart
# 或使用 uv
uv add python-multipart

未安装时导入或首次请求会报 RuntimeError: Form data requires "python-multipart" to be installed.。

7.7.1 Form 不能与 JSON body 混用 ​

❌ 不推荐:

python
@app.post("/broken/")
async def broken(item: Item, username: str = Form()) -> dict[str, object]:
    # ❌ 启动即报错:
    # AssertionError: Cannot specify both Form and Body parameters in the same endpoint.
    return {"item": item, "username": username}

原因是两者的 Content-Type 互斥:JSON body 要求 application/json,Form 要求表单编码或 multipart/form-data。一个请求只能有一种 Content-Type,FastAPI 无法同时满足。

✅ 推荐——要表单就全用表单字段,要 JSON 就全用 Pydantic 模型:

python
# 方案 A:全表单,最后组装成 Pydantic 模型做业务校验
@app.post("/form-only/")
async def form_only(
    username: Annotated[str, Form(min_length=3, max_length=20)],
    email: Annotated[EmailStr, Form()],
    age: Annotated[int, Form(ge=0, le=150)],
) -> UserCreate:
    return UserCreate(username=username, email=email, age=age)


# 方案 B:全 JSON(推荐,见第 4 章)
@app.post("/json-only/")
async def json_only(user: UserCreate) -> UserCreate:
    return user

🔑 判断标准:接口消费者是浏览器原生 <form> 还是前端 fetch?前者用 Form,后者用 JSON body。不要为「兼容」而在一个端点上同时支持两种。


7.8 File() 与 UploadFile ​

维度bytes + File()UploadFile
底层整个文件读进内存SpooledTemporaryFile(超阈值落盘),无内存爆炸风险
读取直接就是 bytesawait file.read() / file.file
元数据无filename / content_type / size
适用小文件(头像、证书)任意大小,用户上传
python
@app.post("/documents/")
async def upload_document(
    file: Annotated[UploadFile, File(description="待解析的文档")],
) -> dict[str, str | int]:
    content = await file.read()
    return {"filename": file.filename or "", "type": file.content_type or "", "size": len(content)}

💡 bytes 版本只需把类型换成 Annotated[bytes, File(max_length=1024 * 512)],适合 512KB 以内的小文件。文件类型白名单、大小上限与分片上传详见第 14 章。


7.9 用 Depends 复用整组参数 ​

单参数别名之外,更常用的是把一组分页参数封装成可复用依赖:

python
class PageParams(BaseModel):
    page: int
    size: int


def pagination(
    page: Annotated[int, Query(ge=1, description="页码,从 1 开始")] = 1,
    size: Annotated[int, Query(ge=1, le=100, description="每页条数")] = 20,
) -> PageParams:
    return PageParams(page=page, size=size)


PageDep = Annotated[PageParams, Depends(pagination)]


@app.get("/articles/")
async def list_articles(pager: PageDep) -> dict[str, int]:
    return {"page": pager.page, "size": pager.size}

分页逻辑只写一次,所有列表端点复用,Swagger UI 里也会自动出现这两个参数。


7.10 综合示例:/search 同时用四种来源 ​

python
@app.get("/api/{tenant}/search")
async def search_in_tenant(
    tenant: Annotated[str, Path(min_length=2, max_length=30, pattern=r"^[a-z0-9\-]+$")],
    q: Annotated[str, Query(min_length=1, max_length=100, description="检索关键词")],
    page: Annotated[int, Query(ge=1)] = 1,
    size: Annotated[int, Query(ge=1, le=50)] = 10,
    trace_id: Annotated[str | None, Header(alias="X-Trace-Id")] = None,
    session_id: Annotated[str | None, Cookie()] = None,
) -> dict[str, object]:
    return {"tenant": tenant, "q": q, "trace_id": trace_id, "logged_in": session_id is not None}

对应关系:tenant 在路径模板且被 Path() 标注 → 路径参数;q / page / size 是标量且不在路径中 → 查询参数;trace_id 标注 Header(alias=...) → 请求头;session_id 标注 Cookie() → Cookie。所有校验失败都返回统一的 422 结构,客户端只需处理一种错误格式。请求示例:

bash
curl -s "http://127.0.0.1:8000/api/acme/search?q=fastapi&page=2&size=20" \
  -H "X-Trace-Id: 3f9c1a" -b "session_id=abc123"

7.10.1 错误示范 vs 正确示范 ​

❌ 不推荐:

python
@app.get("/bad/{item_id}")
async def bad(
    item_id: int = Query(ge=1),                          # 路径参数却标了 Query
    user_agent: str = Header(convert_underscores=True),   # 默认值藏在参数类里
    item_query: str = Query(None),                       # 对外名风格不统一
) -> None: ...

✅ 推荐:

python
@app.get("/good/{item_id}")
async def good(
    item_id: Annotated[int, Path(ge=1)],
    user_agent: Annotated[str | None, Header()] = None,
    item_query: Annotated[str | None, Query(alias="item-query")] = None,
) -> None: ...

常见坑与排查 ​

现象原因解决
启动抛 AssertionError: Cannot specify both Form and Body parameters同一路径操作混用了 Form() 与 Pydantic 模型 / Body()二选一:全表单(组装成模型做业务校验)或全 JSON
RuntimeError: Form data requires "python-multipart" to be installed未安装 python-multipartpip install python-multipart 或 uv add python-multipart
Header 参数永远取不到值请求头真实名称含下划线,却被默认转成了连字符用 Header(convert_underscores=False),或用 alias= 显式指定
Header 形参以下划线开头,启动即报错Pydantic 把 _name 当私有属性去掉前导下划线,靠 alias 表达对外名称
用了 alias 后返回值字段名对不上alias 只影响入参解析,不影响返回值序列化出参别名用 Pydantic 的 Field(serialization_alias=...)
同一参数同时写 Query(None) 和 = None旧写法机械改造成 Annotated 时默认值写了两遍Annotated[...] 内不放默认值,默认值只写在 = 后面
pattern 报 invalid escape sequence 或匹配不上未用原始字符串;或误以为 pattern 是「包含匹配」写 r"^\d{4}$";pattern 是完全匹配,包含匹配需写 .*关键词.*
路径参数返回 422 且提示字段名不对参数被 Query() 标注,但名字在路径模板里路径参数一律用 Path()
File() 上传大文件导致内存暴涨bytes 会把整个文件读进内存改用 UploadFile,必要时自行限制大小

本章小结 ​

要点说明
七种来源Path / Query / Body / Header / Cookie / Form / File
判定规则显式标注 > 路径模板 > Pydantic 模型 > 标量默认 Query
推荐写法Annotated[T, Query(...)] = 默认值,默认值与元数据分离
Annotated 价值可抽成类型别名复用,如 PageSize = Annotated[int, Query(ge=1, le=100)]
Path 约束gt / ge / lt / le,错误结构与 Pydantic 一致
alias解决连字符参数名;只影响入参,不影响出参
Header下划线默认转连字符,大小写不敏感,形参不能以 _ 开头
Form / File需 python-multipart;Form 与 JSON body 不可混用
大文件优先 UploadFile(SpooledTemporaryFile),别用 bytes
复用用 Depends 封装成组参数,比单个别名更好用

练习题 ​

  1. 实现 GET /products/{sku},要求 sku 匹配 ^[A-Z]{3}-\d{4}$,并支持 ?include=price,stock 形式的可选查询参数(提示:list[str] + Query())。
  2. 把 7.9 节的 pagination 改造成支持 order_by 与 order(asc/desc)两个额外参数,取值用 Literal["asc", "desc"] 约束。
  3. 写一个端点同时接受 UploadFile 与两个 Form 字段(标题、描述),并说明为什么这里能共存而 7.7.1 节的 Form + JSON 不行。
  4. 手工制造一次 AssertionError: Cannot specify both Form and Body parameters,记录完整报错信息,然后分别用「全表单」与「全 JSON」两种方案修复。

下一章预告 ​

参数声明解决的是「数据怎么进来」,但数据不合法、资源不存在、权限不足时怎么返回,是另一套体系。下一章把散落各处的错误响应收编成统一的异常处理机制。

👉 第 8 章:错误处理与异常体系

📖本文阅读--次|📊全站访问--次|👥访客--人