第 3 章:路由与路径/查询参数
路由是每天都要写的代码。本章把「路径操作」讲透,把路径参数与查询参数的匹配规则钉死,并用
APIRouter把单文件应用拆成可维护的模块。
学习目标
- 理解「路径操作(Path Operation)」= 路径 + HTTP 方法的组合含义
- 掌握路径参数的类型转换与校验,能用
Enum限定取值范围 - 掌握查询参数的可选/必填声明,理解
bool与list[str]的解析规则 - 能独立完成一次
APIRouter模块化改造,并用prefix做 API 版本化
3.1 路径操作装饰器
FastAPI 里一个接口被称为路径操作(Path Operation),因为它由两件事共同定义:路径(Path)——URL 中从域名之后开始的部分,如 /items/42;方法(Method)——HTTP 动词,如 GET。所以 GET /items 与 POST /items 是两个独立的路径操作,可以分别注册、分别出现在文档里。
| 装饰器 | 语义 | 典型用途 |
|---|---|---|
@app.get | 读取资源 | 查询列表、查询详情 |
@app.post | 创建资源 | 新增记录,通常返回 201 |
@app.put | 全量替换 | 用完整对象覆盖已有资源 |
@app.patch | 局部更新 | 只传需要修改的字段 |
@app.delete | 删除资源 | 通常返回 204 |
@app.options | 查询支持的通信选项 | 常由 CORS 中间件处理 |
@app.head | 只返回响应头 | 探测资源是否存在与长度 |
3.2 路径参数
路径参数写在 URL 模板的 {} 里,名字必须与函数参数名一致。函数参数的书写顺序无关紧要,URL 模板里的顺序决定匹配到的值。
@app.get("/users/{user_id}/items/{item_id}")
async def read_user_item(user_id: int, item_id: str):
return {"user_id": user_id, "item_id": item_id}类型注解带来三件事:user_id: int 让字符串 "42" 自动转成整数("abc" 转换失败返回 422);item_id: str 不做转换、原样传入;两者都会让 /docs 把参数标记成对应的 JSON 类型。常用类型有 int、float、bool、uuid.UUID、datetime.date、Enum。
想让路径参数只能是预定义的几个值之一,用枚举做注解:
from enum import Enum
from fastapi import FastAPI
app = FastAPI()
class ModelName(str, Enum):
alexnet = "alexnet"
resnet = "resnet"
lenet = "lenet"
@app.get("/models/{model_name}")
async def get_model(model_name: ModelName):
return {"model_name": model_name, "message": f"使用 {model_name.value} 模型"}继承 str(Python 3.11+ 也可用 enum.StrEnum)让枚举成员本身就是字符串,返回时序列化为 "alexnet" 而不是 repr;文档自动列出可选值,/docs 会把该参数渲染成只含三个选项的下拉框。传入未定义的值返回 422,错误类型为 enum。
3.3 路径参数的顺序陷阱
路由按注册顺序逐个匹配,先命中者胜。 于是固定路径如果写在动态路径之后,就永远轮不到它。
❌ 不推荐:动态路径写在前面。
@app.get("/users/{user_id}")
async def read_user(user_id: str):
# GET /users/me 会命中这里,user_id 变成字符串 "me"
return {"user_id": user_id}
@app.get("/users/me")
async def read_current_user():
return {"user_id": "the current user"}请求 GET /users/me 返回的是 {"user_id": "me"},第二条路由成了死代码。若 user_id 注解为 int 则更糟——直接 422,让人误以为接口坏了。
✅ 推荐:固定路径永远声明在动态路径之前。
@app.get("/users/me")
async def read_current_user():
return {"user_id": "the current user"}
@app.get("/users/{user_id}")
async def read_user(user_id: int):
return {"user_id": user_id}🔑 把路由按「从具体到宽泛」排序:固定路径 → 有限枚举 → 动态参数。同一模块内保持这个顺序,可以规避 90% 的顺序问题。
3.4 查询参数
不在路径模板里、且是简单类型的函数参数,会被自动识别为查询参数(URL 中 ? 之后的部分)。有默认值即可选,没有默认值即必填:
@app.get("/items/")
async def read_items(q: str, skip: int = 0, limit: int = 10):
return {"q": q, "skip": skip, "limit": limit}/items/?q=x 返回 {"q":"x","skip":0,"limit":10},/items/?q=x&skip=20&limit=5 覆盖默认值,/items/?q=x&skip=abc 返回 422,缺 q 也返回 422。必填参数必须排在带默认值的参数之前,否则是 Python 的 SyntaxError。
bool 查询参数接受一组常见写法,前端不必为「复选框是否勾选」额外做转换;list[str] 则通过重复同名参数收集:
@app.get("/search/")
async def search(active: bool = False, tags: list[str] | None = None, limit: int = 10):
return {"active": active, "tags": tags, "limit": limit}| 传入值 | 解析结果 |
|---|---|
true / True / 1 / yes / on | True |
false / False / 0 / no / off | False |
其他(如 maybe)、空值 | 422 |
GET /search/?active=1&tags=api&tags=web → {"active":true,"tags":["api","web"],"limit":10}。list[int]、set[str] 同理。不要写成 tags: list[str] = []:那样虽然能跑,但表达不出「可不传」的语义,也容易与「必填一个值」混淆。
路径参数与查询参数混用时的判定规则:
| 参数声明 | 是否出现在路径模板 {} 中 | 判定结果 |
|---|---|---|
item_id: int | 是 | 路径参数,参与 URL 匹配 |
q: str | None = None | 否,简单类型 | 查询参数 |
item: Item(Pydantic 模型) | 否,复杂类型 | 请求体(第 4 章) |
request: Request | 否,特殊类型 | 直接注入请求对象 |
查询参数名撞上 Python 保留字(from、class)时,用合法参数名加 Query(alias=...),只改对外契约:async def list_orders(from_: str = Query(alias="from")) 对外依然是 ?from=2024-01-01。
3.5 用 APIRouter 模块化
单文件撑不过三个业务模块。APIRouter 是一个「迷你 FastAPI 应用」:同样的装饰器与参数,只是不启动服务器,最后通过 include_router 挂到主应用上。
# app/api/v1/users.py
from fastapi import APIRouter
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me")
async def read_current_user():
return {"id": 0, "name": "current"}
@router.get("/{user_id}")
async def read_user(user_id: int):
return {"id": user_id, "name": "alice"}
@router.get("")
async def list_users(skip: int = 0, limit: int = 10):
return {"skip": skip, "limit": limit}# app/api/v1/items.py
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
router = APIRouter(prefix="/items", tags=["items"])
class Item(BaseModel):
name: str = Field(min_length=1, max_length=50)
price: float = Field(gt=0)
async def verify_token() -> None:
"""占位依赖:第 6 章会替换为真实鉴权依赖。"""
return None
@router.post("", status_code=201, dependencies=[Depends(verify_token)])
async def create_item(item: Item):
return item# app/api/v1/router.py
from fastapi import APIRouter
from app.api.v1 import items, users
api_router = APIRouter()
api_router.include_router(users.router)
api_router.include_router(items.router)# app/main.py
from fastapi import FastAPI
from app.api.v1.router import api_router
app = FastAPI(title="FastAPI 教程 Demo", version="0.1.0")
app.include_router(api_router, prefix="/api/v1")启动后用 uv run fastapi dev app/main.py,curl http://127.0.0.1:8000/api/v1/users/me 返回 {"id":0,"name":"current"}。APIRouter 的三个常用构造参数:prefix 给该路由下所有路径加统一前缀;tags 在 Swagger UI 中把接口归入同名分组(只影响文档呈现,不影响匹配);dependencies=[Depends(...)] 是路由级依赖,对该路由下所有路径操作生效。
3.6 tags 与文档分组
接口上到几十个,分组是刚需。在创建应用时用 FastAPI(openapi_tags=[{"name": "users", "description": "用户相关接口"}, ...]) 补充分组说明;未在此声明的 tag 依然会出现,只是没有描述文字。
3.7 路由命名与 url_path_for
每个路径操作都有名字,默认取函数名,也可以显式指定。拿到名字后可以反向生成路径:
from fastapi import Request
@app.get("/users/{user_id}", name="read_user") # 显式命名,便于反向生成
async def read_user_detail(user_id: int):
return {"user_id": user_id}
@app.get("/profile-link")
async def profile_link(request: Request):
return {"url": request.url_path_for("read_user", user_id=42)} # "/users/42"这是按名字直接查表,不是 Django 那种带命名空间的 reverse,同一应用内路由名必须唯一;名字默认取函数名,所以重命名函数会静默改变 url_path_for 的行为,对外暴露链接的服务建议显式写 name=。
3.8 全局前缀与 API 版本化
include_router 的 prefix 会追加在 APIRouter(prefix=...) 之上:/api/v1 + /users + /me = /api/v1/users/me。常见做法有两种:路径版本化 /api/v1/...(最简单直接,网关与日志友好,本教程采用)与请求头版本化 Accept: application/vnd.api+json;version=1(路径干净,但调试与缓存更麻烦)。升级到 v2 时新建 app/api/v2/ 与对应的 router.py,在 main.py 里把 v1、v2 各挂一次(app.include_router(v2_router, prefix="/api/v2")),v1 保持不动即可平滑共存。
3.9 路由分发全景
常见坑与排查
| 现象 | 原因 | 解决 |
|---|---|---|
GET /users/me 返回 {"user_id":"me"} 或 422 | /users/{user_id} 注册在 /users/me 之前,先命中 | 按「具体 → 宽泛」排序,固定路径声明在动态路径之前 |
请求路径出现 //,如 /users//me 返回 404 | APIRouter(prefix="/users/") 结尾带斜杠,与 "/me" 拼成双斜杠 | prefix 一律不带结尾斜杠;子路径写成 "/me" |
prefix="/users" 配 @router.get("/") 时,请求 /users 得到 307 跳转 | 拼接结果是 /users/,与 /users 不是同一路径 | 需要精确路径时用 @router.get("") |
| 同一路径重复注册,前一个接口「失效」 | 路由按注册顺序匹配,先注册者胜,FastAPI 不报错也不警告 | 靠代码组织避免;排查时先在 /docs 确认路径与方法是否重复 |
SyntaxError: non-default argument follows default argument | 无默认值的参数写在了带默认值参数之后 | 把必填参数前置,如 async def f(q: str, skip: int = 0) |
| 查询参数名与 Python 保留字冲突 | from、class、import 等是保留字,不能直接做参数名 | 参数名加下划线,并用 Query(alias="from") 恢复对外名称 |
本章小结
| 要点 | 说明 |
|---|---|
| 路径操作 | 「路径 + HTTP 方法」的组合,是 FastAPI 中接口的正式名称 |
| 路径参数 | 写在 URL 模板 {} 中,类型注解驱动转换与校验,失败返回 422 |
| Enum 限定 | class X(str, Enum) 让取值受限,并在 /docs 渲染为下拉框 |
| 顺序规则 | 路由按注册顺序匹配,固定路径必须写在动态路径之前 |
| 查询参数 | 不在路径模板中的简单类型参数;有默认值即可选,无默认值即必填 |
| bool 与 list | true/1/yes/on 均可为真;?q=a&q=b 收集为 list[str] |
| APIRouter | 迷你应用,用 prefix / tags / dependencies 组织,再由 include_router 挂载 |
| 版本化 | include_router(prefix="/api/v1") 是最简单可靠的方案 |
| 命名与反查 | 路由名默认取函数名,url_path_for 按名字查表,需保证唯一 |
练习题
- 定义一个路径操作
GET /files/{file_path},使GET /files/home/user/report.pdf能拿到file_path="home/user/report.pdf"。提示:路径参数默认不匹配/,需要查阅Path的path转换器写法。 - 按错误顺序注册
/models/{model_name}与/models/latest,用curl验证/models/latest的返回;再调整顺序重新验证,写下你观察到的差异。 - 用
APIRouter把本章示例拆成users与items两个模块,挂载到/api/v2,并在/docs里确认接口按 tag 分成了两组。 - 为
search接口用一条curl命令同时传入两个tags与active=no,记录响应;再把active改成maybe,记录状态码并解释原因。
下一章预告
查询参数只能传简单类型。当客户端要提交一个完整的对象——多个字段、嵌套结构、字段级约束——就该轮到请求体与 Pydantic 模型登场了。