Skip to content

第二章 路由与请求处理

本章目标:掌握 FastAPI 的路由系统,学会使用路径参数、查询参数和请求体,并实现一个完整的 Todo CRUD 接口。

预计时长:60 分钟


2.1 路由基础

路径操作装饰器

FastAPI 用装饰器将 Python 函数绑定到 HTTP 方法 + URL 路径,这个组合叫做路径操作(Path Operation)

python
from fastapi import FastAPI

app = FastAPI()

@app.get("/items")        # 查询
def list_items(): ...

@app.post("/items")       # 创建
def create_item(): ...

@app.put("/items/{id}")   # 全量更新
def update_item(): ...

@app.patch("/items/{id}") # 局部更新
def patch_item(): ...

@app.delete("/items/{id}")# 删除
def delete_item(): ...

HTTP 方法与 RESTful 语义

RESTful API 的核心思想:用 HTTP 方法表示动作,用 URL 路径表示资源

HTTP 方法语义典型路径是否有请求体幂等性
GET读取/users/users/1
POST创建/users
PUT全量更新/users/1
PATCH局部更新/users/1
DELETE删除/users/1

幂等性:同一请求执行多次,效果与执行一次相同。GET 查 10 次还是那条数据,DELETE 删 10 次还是删了那条。POST 连发 10 次可能会创建 10 条记录。

用 ASCII 图表示一个典型的 RESTful 资源操作:

资源: /users

  ├── GET    /users        → 获取用户列表
  ├── POST   /users        → 创建新用户

  └── /users/{user_id}
        ├── GET            → 获取单个用户
        ├── PUT            → 更新用户(全量替换)
        ├── PATCH          → 更新用户(部分字段)
        └── DELETE         → 删除用户

完整可运行示例

创建 chapter02_routes.py

python
from fastapi import FastAPI

app = FastAPI()

fake_users = {
    1: {"name": "张三", "age": 25},
    2: {"name": "李四", "age": 30},
}


@app.get("/users")
def list_users():
    """获取所有用户"""
    return fake_users


@app.get("/users/{user_id}")
def get_user(user_id: int):
    """获取单个用户"""
    return fake_users.get(user_id, {"error": "用户不存在"})


@app.post("/users")
def create_user():
    """创建用户(后面会完善请求体)"""
    return {"message": "创建成功"}


@app.delete("/users/{user_id}")
def delete_user(user_id: int):
    """删除用户"""
    return {"message": f"用户 {user_id} 已删除"}
bash
fastapi dev chapter02_routes.py

打开 http://127.0.0.1:8000/docs,可以看到所有接口按 HTTP 方法清晰列出。


2.2 路径参数

路径参数是 URL 中用 {参数名} 标记的动态部分,FastAPI 自动提取并传给函数。

基本用法

python
@app.get("/users/{user_id}")
def get_user(user_id: int):
    return {"user_id": user_id}
请求: GET /users/42
     ↓ FastAPI 自动提取并转换
函数参数: user_id = 42  (int 类型)

类型自动转换与校验

声明参数类型后,FastAPI 自动做类型转换和校验:

类型声明合法请求转换结果非法请求
user_id: int/users/4242 (int)/users/abc → 422 错误
name: str/users/张三"张三" (str)任何值都合法
score: float/score/9.59.5 (float)/score/abc → 422 错误

使用 UUID 类型的例子:

python
from uuid import UUID


@app.get("/orders/{order_id}")
def get_order(order_id: UUID):
    return {"order_id": order_id}
✅ GET /orders/550e8400-e29b-41d4-a716-446655440000  → 正常返回
❌ GET /orders/not-a-uuid  → 422 校验错误

使用 Enum 限定取值范围:

python
from enum import Enum


class ModelName(str, Enum):
    alexnet = "alexnet"
    resnet = "resnet"
    lenet = "lenet"


@app.get("/models/{model_name}")
def get_model(model_name: ModelName):
    return {"model": model_name, "message": f"你选择了 {model_name.value}"}
✅ GET /models/alexnet  → 正常返回
✅ GET /models/resnet   → 正常返回
❌ GET /models/vgg      → 422 错误,不在允许的枚举值中

路径参数的顺序问题(固定路径 vs 动态路径)

重要规则:固定路径必须写在动态路径的前面,否则会被动态路径"吃掉"。

python
# ✅ 正确顺序:固定路径在前
@app.get("/users/me")
def get_current_user():
    return {"user": "当前登录用户"}


@app.get("/users/{user_id}")
def get_user(user_id: int):
    return {"user_id": user_id}
python
# ❌ 错误顺序:/users/me 永远不会被匹配到
@app.get("/users/{user_id}")
def get_user(user_id: int):
    return {"user_id": user_id}


@app.get("/users/me")          # 永远到不了这里!
def get_current_user():         # "me" 被当作 user_id → 无法转为 int → 422
    return {"user": "当前登录用户"}

路由匹配流程:

请求: GET /users/me

正确顺序(固定路径在前):            错误顺序(动态路径在前):
  /users/me    → ✅ 精确匹配!        /users/{user_id} → 匹配!
  /users/{id}  → 不会走到这里          → 把 "me" 转为 int → 失败 → 422 💥

记住:FastAPI 按照代码中装饰器的书写顺序从上到下匹配,匹配到第一个就停。


2.3 查询参数

函数参数中不属于路径参数的,FastAPI 自动识别为查询参数(Query Parameter),通过 ?key=value 传递。

基本用法

python
@app.get("/items")
def list_items(skip: int = 0, limit: int = 10):
    return {"skip": skip, "limit": limit}
GET /items                     → skip=0, limit=10(使用默认值)
GET /items?skip=5              → skip=5, limit=10
GET /items?skip=5&limit=20     → skip=5, limit=20

可选参数与默认值

写法含义不传时的行为
keyword: str必填参数不传 → 422 错误
page: int = 1有默认值,选填不传 → 使用默认值 1
tag: str | None = None可选参数不传 → 值为 None

完整示例:

python
@app.get("/search")
def search(
    keyword: str,                     # 必填
    page: int = 1,                    # 默认值 1
    page_size: int = 20,              # 默认值 20
    category: str | None = None,      # 可选
):
    result = {"keyword": keyword, "page": page, "page_size": page_size}
    if category:
        result["category"] = category
    return result
❌ GET /search                          → 422(keyword 是必填的)
✅ GET /search?keyword=python           → 使用默认 page 和 page_size
✅ GET /search?keyword=python&page=2    → page=2,其他用默认值
✅ GET /search?keyword=python&category=教程&page=2  → 全部指定

多种类型的查询参数

FastAPI 支持丰富的查询参数类型,布尔类型尤其智能:

python
from datetime import date


@app.get("/reports")
def get_reports(
    start_date: date,                  # 必填,格式 YYYY-MM-DD
    end_date: date,                    # 必填
    department: str | None = None,     # 可选字符串
    include_detail: bool = False,      # 布尔值
    limit: int = 100,                  # 整数
):
    return {
        "start_date": start_date,
        "end_date": end_date,
        "department": department,
        "include_detail": include_detail,
        "limit": limit,
    }
GET /reports?start_date=2025-01-01&end_date=2025-12-31&include_detail=true

布尔类型的智能转换:

识别为 True识别为 False
trueTrue1yesonfalseFalse0nooff

查询参数类型速查表:

类型示例 URL说明
int?page=2自动转为整数
float?price=9.9自动转为浮点
bool?active=true智能布尔转换
str?name=张三字符串(默认)
date?d=2025-06-15ISO 日期格式

2.4 请求体(Request Body)

GET 请求通过 URL 传参,但创建/更新资源时,数据量大、结构复杂,需要放在请求体中发送(通常是 JSON)。

FastAPI 使用 Pydantic Model 来定义请求体的结构。

使用 Pydantic Model 定义请求体

python
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Item(BaseModel):
    name: str
    price: float
    description: str | None = None


@app.post("/items")
def create_item(item: Item):
    return {"message": "创建成功", "item": item}

请求时发送 JSON:

json
{
    "name": "键盘",
    "price": 299.0,
    "description": "机械键盘"
}

FastAPI 自动完成的工作流:

客户端发送 JSON

FastAPI 读取请求体

Pydantic 校验数据结构和类型
    ├── 校验通过 → 创建 Item 实例,传给函数
    └── 校验失败 → 返回 422 错误 + 详细错误信息

路径参数 + 查询参数 + 请求体的混合使用

这是 FastAPI 最优雅的设计之一——三种参数可以混合使用,FastAPI 自动区分它们:

python
class Item(BaseModel):
    name: str
    price: float
    description: str | None = None


@app.put("/items/{item_id}")
def update_item(
    item_id: int,           # → 路径参数(出现在路径模板中)
    item: Item,             # → 请求体(Pydantic Model 类型)
    q: str | None = None,   # → 查询参数(既不在路径中,也不是 Model)
):
    result = {"item_id": item_id, "item": item}
    if q:
        result["q"] = q
    return result

请求示例:

PUT /items/42?q=搜索词

路径:    /items/{item_id}  → item_id = 42
查询:    ?q=搜索词          → q = "搜索词"
请求体:  { JSON body }     → item = Item(name=..., price=...)

FastAPI 如何自动区分三者

判断条件参数来源示例
参数名出现在路径字符串 {...}路径参数item_id: int 且路径含 {item_id}
参数类型是 Pydantic BaseModel请求体item: Item
以上都不是查询参数q: str = None
FastAPI 参数识别决策树:
  ┌── 在路径模板 {xxx} 里?  → 路径参数
  ├── 是 BaseModel 类型?    → 请求体
  └── 都不是?               → 查询参数

请求体中的嵌套结构

请求体可以包含嵌套的 Pydantic Model:

python
class Address(BaseModel):
    city: str
    street: str
    zipcode: str


class User(BaseModel):
    name: str
    age: int
    address: Address            # 嵌套模型
    tags: list[str] = []        # 列表字段


@app.post("/users")
def create_user(user: User):
    return user

对应的请求 JSON:

json
{
    "name": "张三",
    "age": 25,
    "address": {
        "city": "北京",
        "street": "中关村大街1号",
        "zipcode": "100080"
    },
    "tags": ["VIP", "开发者"]
}

2.5 实战练习:Todo CRUD 接口

用一个完整的例子把前面学的知识串起来——用内存字典存储,实现 Todo 的增删改查。

创建 todo_app.py

python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

app = FastAPI(title="Todo API", description="一个简单的待办事项接口")


# ---------- 数据模型 ----------

class TodoCreate(BaseModel):
    """创建 Todo 时的请求体"""
    title: str
    description: str | None = None
    completed: bool = False


class TodoUpdate(BaseModel):
    """更新 Todo 时的请求体(所有字段可选)"""
    title: str | None = None
    description: str | None = None
    completed: bool | None = None


class TodoResponse(BaseModel):
    """返回给客户端的 Todo 数据"""
    id: int
    title: str
    description: str | None = None
    completed: bool


# ---------- 内存存储 ----------

todos: dict[int, dict] = {}
next_id: int = 1


# ---------- 接口 ----------

@app.get("/todos", response_model=list[TodoResponse])
def list_todos(
    completed: bool | None = None,
    skip: int = 0,
    limit: int = 10,
):
    """获取 Todo 列表,支持按完成状态筛选和分页"""
    result = list(todos.values())
    if completed is not None:
        result = [t for t in result if t["completed"] == completed]
    return result[skip : skip + limit]


@app.get("/todos/{todo_id}", response_model=TodoResponse)
def get_todo(todo_id: int):
    """获取单个 Todo"""
    if todo_id not in todos:
        raise HTTPException(status_code=404, detail="Todo 不存在")
    return todos[todo_id]


@app.post("/todos", response_model=TodoResponse, status_code=201)
def create_todo(todo: TodoCreate):
    """创建新 Todo"""
    global next_id
    new_todo = {
        "id": next_id,
        "title": todo.title,
        "description": todo.description,
        "completed": todo.completed,
    }
    todos[next_id] = new_todo
    next_id += 1
    return new_todo


@app.put("/todos/{todo_id}", response_model=TodoResponse)
def update_todo(todo_id: int, todo: TodoUpdate):
    """更新 Todo(局部更新)"""
    if todo_id not in todos:
        raise HTTPException(status_code=404, detail="Todo 不存在")
    stored = todos[todo_id]
    update_data = todo.model_dump(exclude_unset=True)
    stored.update(update_data)
    return stored


@app.delete("/todos/{todo_id}")
def delete_todo(todo_id: int):
    """删除 Todo"""
    if todo_id not in todos:
        raise HTTPException(status_code=404, detail="Todo 不存在")
    del todos[todo_id]
    return {"message": f"Todo {todo_id} 已删除"}

启动并测试

bash
fastapi dev todo_app.py

打开 http://127.0.0.1:8000/docs,按以下顺序测试:

步骤操作预期结果
1POST /todos,body: {"title": "学习FastAPI"}返回 id=1 的 Todo,completed=false
2POST /todos,body: {"title": "写作业", "completed": true}返回 id=2 的 Todo
3GET /todos返回 2 个 Todo 的列表
4GET /todos?completed=false只返回未完成的 Todo
5GET /todos/1返回 id=1 的详情
6PUT /todos/1,body: {"completed": true}标记为已完成
7DELETE /todos/2删除 id=2
8GET /todos/999返回 404 错误

代码要点解析

todo_app.py 知识点汇总:

1. 三种模型分工
   TodoCreate  → POST 请求体(创建时用)
   TodoUpdate  → PUT 请求体(更新时用,字段全可选)
   TodoResponse → response_model(控制返回结构)

2. 三种参数混合
   GET /todos?completed=false&skip=0&limit=10
        │            │         │       │
        路径        查询参数    查询参数  查询参数

   PUT /todos/{todo_id}  +  请求体 JSON
        │        │              │
        路径    路径参数       请求体

3. HTTPException
   raise HTTPException(status_code=404, detail="Todo 不存在")
   → 自动返回 {"detail": "Todo 不存在"} + 404 状态码

4. model_dump(exclude_unset=True)
   → 只取客户端显式传了的字段,没传的不更新

练习思考

  1. 尝试在 TodoCreate 中将 title 的类型改为 int,然后发送 {"title": "学习"},观察 422 校验错误的详细内容。
  2. 尝试访问 GET /todos?skip=0&limit=1,体会分页效果。
  3. 思考:为什么要区分 TodoCreateTodoUpdateTodoResponse 三个模型?(下一章会详细讲读写分离思想)

本章小结

概念要点
路径操作@app.get/post/put/patch/delete() 将函数绑定到 HTTP 方法 + 路径
RESTful 语义GET=读取、POST=创建、PUT=全量更新、PATCH=局部更新、DELETE=删除
路径参数{param} 写在路径中,自动提取并类型转换,支持 int/str/UUID/Enum
路径顺序固定路径(/users/me)必须写在动态路径(/users/{id})前面
查询参数非路径参数、非 Model 的函数参数,从 ?key=value 提取
参数默认值无默认值=必填,=值=选填,| None = None=可选
请求体用 Pydantic BaseModel 定义,自动从 JSON body 解析校验
三者区分路径模板中 → 路径参数;BaseModel → 请求体;其他 → 查询参数
HTTPExceptionraise HTTPException(status_code=404) 返回错误响应

下一章预告:我们将深入 Pydantic,学习数据校验的各种高级用法(Field 约束、自定义校验器、嵌套模型),以及如何用响应模型控制返回数据——这是写出健壮 API 的关键。

坚持是一种品格