FastAPI-路由与参数
这篇讲什么
- FastAPI 的入参只有四种来源:路径、查询串、请求头、Cookie,全部靠「函数签名 + 类型注解」声明,框架自动做转换、校验和文档。
- 最关键的一条规矩:默认值只能写在参数上(
= 1),不能写进Query(...)标记里,写错是注册路由时直接AssertionError,不是运行时才发现。 - 校验失败统一返回 422,
detail是一个列表,每项有type/loc/msg/input/ctx,本篇把常见错误类型逐个跑出来对照。
声明入参的两种写法
-
同一个「必填整型查询参数」有两种写法,效果一样:
params_style.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17from typing import Annotated, Optional
from fastapi import FastAPI, Query
app = FastAPI()
# 写法一:官方推荐,标记放 Annotated 里,默认值留在参数上
def style_annotated(page: Annotated[int, Query(ge=1)] = 1):
return {"page": page}
# 写法二:老写法,标记整个当默认值用
def style_default(page: int = Query(1, ge=1)):
return {"page": page}实测两者都能跑,且写法二不会给任何 DeprecationWarning(本机
warnings.catch_warnings里跑,告警条数 0):1
2/old-style -> {'x': 1, 'y': 2}
告警条数: 0 -
那为什么还要用
Annotated?三个实际理由:-
能复用。把「一个分页参数」抽成一个类型别名,多个接口共用,老写法做不到:
shared_query.py 1
2
3
4
5
6
7
8
9
10from typing import Annotated
from fastapi import Query
PageQuery = Annotated[int, Query(ge=1, description="页码,从 1 开始")]
SizeQuery = Annotated[int, Query(ge=1, le=100)]
def list_items(page: PageQuery = 1, size: SizeQuery = 20):
return {"page": page, "size": size} -
类型检查器认得。
page: int = Query(1)里page的声明类型是int,但默认值实际是个Query对象,mypy/pyright 会报「int 不可能等于 Query」;Annotated写法没有这个问题。 -
默认值和校验说明分得开,读签名时一眼能看出哪个是默认值、哪个是约束。
-
-
⚠️ 唯一会踩的坑,实测五种标记全都一样:
1
2
3
4
5Cookie -> AssertionError: `Cookie` default value cannot be set in `Annotated` for 'theme'. Set the default value with `=` instead.
Header -> AssertionError: `Header` default value cannot be set in `Annotated` for 'xt'. Set the default value with `=` instead.
Query -> AssertionError: `Query` default value cannot be set in `Annotated` for 'x'. Set the default value with `=` instead.
Body -> AssertionError: `Body` default value cannot be set in `Annotated` for 'payload'. Set the default value with `=` instead.
Path -> AssertionError: Path parameters cannot have a default value- 也就是说
Annotated[int, Query(default=5)]是错的,要写Annotated[int, Query(ge=0)] = 5。 - 路径参数根本不能有默认值(它必须出现在 URL 里)。
- 报错时机是「注册路由那一刻」,服务直接起不来,好处是不会留到线上才炸。
- 也就是说
路径参数
-
路径里的
{...}会按声明的类型自动转换,实测:path_types.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34import uuid
from enum import Enum
from typing import Annotated
from fastapi import FastAPI, Path
from pydantic import BaseModel
class Kind(str, Enum):
cat = "cat"
dog = "dog"
app = FastAPI()
def by_int(item_id: int):
return {"item_id": item_id, "type": type(item_id).__name__}
def by_uuid(order_uid: uuid.UUID):
return {"order_uid": str(order_uid), "version": order_uid.version}
def by_enum(kind: Kind):
return {"kind": kind.value, "raw": kind.name}
def by_pattern(token: Annotated[str, Path(pattern=r"^[0-9a-f]{6}$")]):
return {"token": token}1
2
3
4
5
6
7
8
9/items/42 -> 200 {'item_id': 42, 'type': 'int'}
/items/abc -> 422 type=int_parsing msg='Input should be a valid integer, unable to parse string as an integer'
/items/1.5 -> 422 type=int_parsing # 小数也不会偷偷截断
/orders/123e4567-e89b-12d9-a456-426614174000 -> 200 {'order_uid': '123e...', 'version': 1}
/orders/not-a-uuid -> 422 type=uuid_parsing msg='Input should be a valid UUID, invalid character: found `n` at 0'
/pets/cat -> 200 {'kind': 'cat', 'raw': 'cat'}
/pets/bird -> 422 type=enum msg="Input should be 'cat' or 'dog'" ctx={'expected': "'cat' or 'dog'"}
/hex/abcdef -> 200 {'token': 'abcdef'}
/hex/XYZ -> 422 type=string_pattern_mismatch msg="String should match pattern '^[0-9a-f]{6}$'"- 传进来的确实是
int/UUID/Enum,不是字符串,type(item_id).__name__实测就是int。 - 枚举要继承
str, Enum才能既当路径值又当字符串用;非法值直接 422,ctx.expected会把允许的值列出来。 - 正则约束走
Path(pattern=...)。老名字regex=实测还能跑但会告警:FastAPIDeprecationWarning: regex has been deprecated, please use pattern instead,新代码一律写pattern=。
- 传进来的确实是
-
路径参数名要和 URL 里的占位符完全一致,写错不会报错但拿不到值——FastAPI 会把它当成一个「同名查询参数」去找,实测把
item_id写成id之后请求/items/42得到 422type=missing, loc=['query','id']:1
2
3@app.get("/items/{item_id}")
def by_int(id: int): ...
# GET /items/42 -> 422 {'type': 'missing', 'loc': ['query', 'id'], 'msg': 'Field required'}排查「明明 URL 里有值却提示 Field required」时,第一件事就是核对这两个名字。更隐蔽的是它不会拒绝路径段,而是把路径当不存在、转去查询串里找
id,实测/items/42?id=7返回200 {'id': 7}——线上表现就是「传了值却拿到另一个数」。 -
一个容易被忽略的行为:路径参数声明了类型,匹配上路由但转换失败时返回的是 422,不是 404:
1
2
3/users/me -> 200 {'who': 'me'}
/users/7 -> 200 {'who': 'by_id', 'user_id': 7}
/users/xx -> 422 # 不是 404对前端来说 422 意味着「路径格式不对」,跟「资源不存在」是两回事;要返回 404 得自己查库后
raise HTTPException(404)。
查询参数
-
没出现在路径里的参数默认就是查询参数,实测各种形态:
query_demo.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16from typing import Annotated, Optional
from fastapi import FastAPI, Query
app = FastAPI()
def q(
a: int, # 必填
b: Annotated[str, Query(min_length=3, max_length=8)] = "abc",
c: Annotated[Optional[list[int]], Query()] = None, # 可重复
skip: Annotated[int, Query(ge=0, le=100, description="跳过几条")] = 0,
old: Annotated[Optional[str], Query(deprecated=True)] = None,
):
return {"a": a, "b": b, "c": c, "skip": skip, "old": old}1
2
3
4
5
6
7/q?a=1 -> 200 {'a': 1, 'b': 'abc', 'c': None, 'skip': 0, 'old': None}
/q -> 422 [{'type': 'missing', 'loc': ['query', 'a'], 'msg': 'Field required'}]
/q?a=1&b=xy -> 422 type=string_too_short ctx={'min_length': 3}
/q?a=1&c=1&c=2&c=3 -> 200 {'c': [1, 2, 3]}
/q?a=1&skip=200 -> 422 type=less_than_equal ctx={'le': 100}
/q?a=1&c=notint -> 422 type=int_parsing loc=['query', 'c', 0]
/q?a=1&zzz=9 -> 200 # 多余的查询参数直接忽略- 必填就是「没有默认值」,不需要写
Query(...);要加约束才用标记。 - 重复同名参数收进列表,条件是声明成
list[int];loc里会带上出错的下标(['query','c',0]表示第一个值不是整数)。 - 多余的参数不会报错也不会进函数,想拒绝得自己校验(或者用
Request.query_params手动看)。
- 必填就是「没有默认值」,不需要写
-
Query常用的参数(实测签名与行为):-
alias:URL 上用的名字,函数里可以用合法的 Python 名:alias_demo.py 1
2
3
4
5
6
7
8
9
10from typing import Annotated, Optional
from fastapi import FastAPI, Query
app = FastAPI()
def alias(q: Annotated[Optional[str], Query(alias="keyword")] = None):
return {"q": q}1
2/alias?keyword=猫 -> 200 {'q': '猫'}
/alias?q=猫 -> 200 {'q': None} # 原名不认,只认 alias注意是替换而不是追加:写了
alias之后原来的名字就不再接受。 -
min_length/max_length/pattern:字符串约束。 -
ge/gt/le/lt/multiple_of:数值约束。 -
deprecated=True:文档里划掉并标 deprecated,实测生成的 schema:1
2
3{'name': 'old', 'in': 'query', 'required': False,
'schema': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'deprecated': True, 'title': 'Old'},
'deprecated': True} -
include_in_schema=False:不进文档,但接口照样能访问(实测/v2/hidden不在paths里,请求仍返回 200)。想真正下线接口要用deprecated+ 版本前缀,别指望藏起来。 -
validation_alias/serialization_alias:Pydantic v2 的别名机制,和alias的区别见 FastAPI-请求体与Pydantic校验。
-
请求头与 Cookie
-
用法跟
Query一样,只是标记换成Header/Cookie:head_cookie.py 1
2
3
4
5
6
7
8
9
10
11
12from typing import Annotated, Optional
from fastapi import FastAPI, Header
app = FastAPI()
def head(user_agent: Annotated[Optional[str], Header()] = None,
x_trace: Annotated[Optional[str], Header(convert_underscores=False)] = None,
xt: Annotated[Optional[str], Header(alias="X-Trace-2")] = None):
return {"user_agent": user_agent, "x_trace": x_trace, "xt": xt}1
2发送 User-Agent: probe/1.0 + x_trace: abc + X-Trace-2: second
-> {'user_agent': 'probe/1.0', 'x_trace': 'abc', 'xt': 'second'} -
三个必须记住的差别:
-
Header默认会把下划线转成连字符再匹配,所以user_agent能收到User-Agent(HTTP 头习惯用连字符,而 Python 变量名不能用连字符)。实测TestClient不带该头时拿到的是它自己塞的user_agent: 'testclient'。 -
convert_underscores=False就是字面名,实测这时参数x_trace只能收到名为x_trace的头,发X-Trace收到None。 -
Cookie没有连字符问题,参数名就是 cookie 名;缺失时走默认值,实测:1
2带 cookie session=s-1, theme=dark -> {'session': 's-1', 'theme': 'dark'}
不带 cookie -> {'session': None, 'theme': 'light'}
-
-
要拿全部头或做原始校验,别一个个声明,直接用
Request:raw_request.py 1
2
3
4
5
6
7
8
9
10
11
12
13from fastapi import FastAPI, Request
app = FastAPI()
def raw(request: Request):
return {
"headers": dict(request.headers),
"cookies": request.cookies,
"query": dict(request.query_params),
"client_ip": request.client.host if request.client else None,
}request.headers是大小写不敏感的Headers对象,dict(...)会把它拍平成小写键。
路由的匹配顺序
-
FastAPI 是自上而下第一个匹配就用,不是「最具体的赢」。所以静态路径必须写在动态路径前面:
order.py 1
2
3
4
5
6
7
8
9
10
11
12
13from fastapi import FastAPI
app = FastAPI()
def me():
return {"who": "me"}
def user_by_id(user_id: int):
return {"who": "by_id", "user_id": user_id}1
2/users/me -> 200 {'who': 'me'}
/users/7 -> 200 {'who': 'by_id', 'user_id': 7}-
如果把
/users/me挪到后面,me会被{user_id}抢走,然后因为不是整数返回 422——这是 FastAPI 最经典的顺序 bug。 -
用
APIRouter分包时同理:先include_router的先生效,实测两个 router 互换注册顺序,同一个/x/5的结果就从{'from': 'a'}(动态路由抢走)变成{'from': 'b'}(静态命中):1
2先 include 动态路由再静态: GET /x/5 -> {'from': 'a'}
先 include 静态再动态: GET /x/5 -> {'from': 'b'}跨文件的顺序问题最难查,所以路由清单要按「静态在前」的约定排。
-
-
路径里还能声明可选段和正则段,但 FastAPI 不像 Flask 那样写
<int:x>,一律靠{name}+ 类型注解;需要复杂匹配就配Path(pattern=...)。 -
redirect_slashes默认开着,斜杠不匹配时补一个 307(实测细节见 FastAPI-基本入门的「工程骨架」一节)。关掉它就在构造时传FastAPI(redirect_slashes=False),实测这时少一个斜杠就是纯 404:1
2路由是 @app.get("/sl/")
redirect_slashes=False: GET /sl -> 404 | GET /sl/ -> 200 -
给路由起名字方便反向解析:
named_route.py 1
2
3
4
5
6
7
8from fastapi import FastAPI
app = FastAPI()
def user_detail(user_id: int):
return {"user_id": user_id}1
app.url_path_for('user_detail', user_id=3) -> '/users/3'
模板里生成链接、测试里避免硬编码路径都用得上;不传
name时要用函数名,实测url_path_for("get_user", user_id=3)也能拿到/users/3。
分组与文档标记
-
APIRouter上能带一组路由的公共设置,实测prefix、tags、deprecated、include_in_schema都生效:router_meta.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17from fastapi import APIRouter, FastAPI
router = APIRouter(prefix="/v2", tags=["v2"], deprecated=True)
def shown():
return {"ok": True}
def hidden():
return {"ok": True}
app = FastAPI()
app.include_router(router)1
2
3/v2/shown 的 deprecated: True
/v2/hidden 在文档里: False | 但能访问: 200
paths: [..., '/v2/shown'] -
@app.get(...)能传的参数实测有 24 个,常用的记一下就行:1
2
3
4
5
6path, response_model, status_code, tags, dependencies, summary, description,
response_description, responses, deprecated, operation_id,
response_model_include, response_model_exclude, response_model_by_alias,
response_model_exclude_unset, response_model_exclude_defaults,
response_model_exclude_none, include_in_schema, response_class, name,
callbacks, openapi_extra, generate_unique_id_function其中
response_model_*那六个是响应裁剪,归到 FastAPI-响应模型与OpenAPI文档 讲。 -
单个接口的文档信息在装饰器里给,常用的四个:
summary:一句话标题(文档列表里显示)。description:支持 Markdown,写业务规则的地方。response_model:响应结构,见 FastAPI-响应模型与OpenAPI文档。openapi_extra:往这个接口的 OpenAPI 片段里塞任意字段,官方文档没写的东西用它补。
-
函数文档字符串会被自动当成
description,这是最省事的写法:docstring.py 1
2
3
4
5
6
7
8
9
10
11
12from fastapi import FastAPI
app = FastAPI()
def goods():
"""返回商品列表。
支持 `page` 和 `size` 两个分页参数,`size` 最大 100。
"""
return {"goods": []}1
2
3
4description = '返回商品列表。
支持 `page` 和 `size` 两个分页参数,`size` 最大 100。'
summary = 'Goods'- docstring 第一段进
description,Markdown 原样保留。 - 没写
summary时它自动取函数名的 Title Case(goods→Goods),所以函数名会直接出现在文档标题上,起名字时顺手把可读性带上。
- docstring 第一段进
422 响应体怎么读
-
所有校验失败都是同一个结构,实测一个缺参的例子:
1
2
3
4
5
6
7
8
9
10{
"detail": [
{
"type": "missing",
"loc": ["query", "a"],
"msg": "Field required",
"input": null
}
]
}detail是列表,多个字段出错就有多条。loc第一项是来源(path/query/header/cookie/body),后面是字段路径;列表元素出错会带下标,实测["query", "c", 0]。input是原始输入值(未转换的),前端可以直接回填。ctx只有部分类型有,用来放约束值,实测less_than_equal带{'le': 100}、enum带{'expected': "'cat' or 'dog'"}。
-
本机实测到的
type取值(够覆盖大部分场景):1
2
3
4
5
6
7missing 字段没给
int_parsing 转不成整数
uuid_parsing 转不成 UUID
enum 不在枚举里
string_too_short 长度小于 min_length
string_pattern_mismatch 正则不匹配
less_than_equal 大于 le- 实测 0.162 的输出里没有
url字段(Pydantic 文档里那种带url的错误是它自己model_validate抛异常时的形态,FastAPI 序列化后只留上面这几项),所以排查要靠type去查 Pydantic 的错误清单。
- 实测 0.162 的输出里没有
-
想改这个结构(比如统一成
{code, message}),有两条路:只改文案用Query(description=...),改结构要覆盖 422 的处理函数,写法在 FastAPI-依赖注入与异常中间件。