Skip to content

第 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 模板里的顺序决定匹配到的值。

python
@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。

想让路径参数只能是预定义的几个值之一,用枚举做注解:

python
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 路径参数的顺序陷阱 ​

路由按注册顺序逐个匹配,先命中者胜。 于是固定路径如果写在动态路径之后,就永远轮不到它。

❌ 不推荐:动态路径写在前面。

python
@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,让人误以为接口坏了。

✅ 推荐:固定路径永远声明在动态路径之前。

python
@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 中 ? 之后的部分)。有默认值即可选,没有默认值即必填:

python
@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] 则通过重复同名参数收集:

python
@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 / onTrue
false / False / 0 / no / offFalse
其他(如 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 挂到主应用上。

python
# 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}
python
# 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
python
# 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)
python
# 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 ​

每个路径操作都有名字,默认取函数名,也可以显式指定。拿到名字后可以反向生成路径:

python
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 返回 404APIRouter(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 与 listtrue/1/yes/on 均可为真;?q=a&q=b 收集为 list[str]
APIRouter迷你应用,用 prefix / tags / dependencies 组织,再由 include_router 挂载
版本化include_router(prefix="/api/v1") 是最简单可靠的方案
命名与反查路由名默认取函数名,url_path_for 按名字查表,需保证唯一

练习题 ​

  1. 定义一个路径操作 GET /files/{file_path},使 GET /files/home/user/report.pdf 能拿到 file_path="home/user/report.pdf"。提示:路径参数默认不匹配 /,需要查阅 Path 的 path 转换器写法。
  2. 按错误顺序注册 /models/{model_name} 与 /models/latest,用 curl 验证 /models/latest 的返回;再调整顺序重新验证,写下你观察到的差异。
  3. 用 APIRouter 把本章示例拆成 users 与 items 两个模块,挂载到 /api/v2,并在 /docs 里确认接口按 tag 分成了两组。
  4. 为 search 接口用一条 curl 命令同时传入两个 tags 与 active=no,记录响应;再把 active 改成 maybe,记录状态码并解释原因。

下一章预告 ​

查询参数只能传简单类型。当客户端要提交一个完整的对象——多个字段、嵌套结构、字段级约束——就该轮到请求体与 Pydantic 模型登场了。

👉 第 4 章:请求体与 Pydantic 模型

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