第二章 路由与请求处理
本章目标:掌握 FastAPI 的路由系统,学会使用路径参数、查询参数和请求体,并实现一个完整的 Todo CRUD 接口。
预计时长:60 分钟
2.1 路由基础
路径操作装饰器
FastAPI 用装饰器将 Python 函数绑定到 HTTP 方法 + URL 路径,这个组合叫做路径操作(Path Operation)。
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:
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} 已删除"}fastapi dev chapter02_routes.py打开 http://127.0.0.1:8000/docs,可以看到所有接口按 HTTP 方法清晰列出。
2.2 路径参数
路径参数是 URL 中用 {参数名} 标记的动态部分,FastAPI 自动提取并传给函数。
基本用法
@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/42 | 42 (int) | /users/abc → 422 错误 |
name: str | /users/张三 | "张三" (str) | 任何值都合法 |
score: float | /score/9.5 | 9.5 (float) | /score/abc → 422 错误 |
使用 UUID 类型的例子:
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 限定取值范围:
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 动态路径)
重要规则:固定路径必须写在动态路径的前面,否则会被动态路径"吃掉"。
# ✅ 正确顺序:固定路径在前
@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}# ❌ 错误顺序:/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 传递。
基本用法
@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 |
完整示例:
@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 支持丰富的查询参数类型,布尔类型尤其智能:
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 |
|---|---|
true、True、1、yes、on | false、False、0、no、off |
查询参数类型速查表:
| 类型 | 示例 URL | 说明 |
|---|---|---|
int | ?page=2 | 自动转为整数 |
float | ?price=9.9 | 自动转为浮点 |
bool | ?active=true | 智能布尔转换 |
str | ?name=张三 | 字符串(默认) |
date | ?d=2025-06-15 | ISO 日期格式 |
2.4 请求体(Request Body)
GET 请求通过 URL 传参,但创建/更新资源时,数据量大、结构复杂,需要放在请求体中发送(通常是 JSON)。
FastAPI 使用 Pydantic Model 来定义请求体的结构。
使用 Pydantic Model 定义请求体
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:
{
"name": "键盘",
"price": 299.0,
"description": "机械键盘"
}FastAPI 自动完成的工作流:
客户端发送 JSON
↓
FastAPI 读取请求体
↓
Pydantic 校验数据结构和类型
├── 校验通过 → 创建 Item 实例,传给函数
└── 校验失败 → 返回 422 错误 + 详细错误信息路径参数 + 查询参数 + 请求体的混合使用
这是 FastAPI 最优雅的设计之一——三种参数可以混合使用,FastAPI 自动区分它们:
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:
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:
{
"name": "张三",
"age": 25,
"address": {
"city": "北京",
"street": "中关村大街1号",
"zipcode": "100080"
},
"tags": ["VIP", "开发者"]
}2.5 实战练习:Todo CRUD 接口
用一个完整的例子把前面学的知识串起来——用内存字典存储,实现 Todo 的增删改查。
创建 todo_app.py:
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} 已删除"}启动并测试
fastapi dev todo_app.py打开 http://127.0.0.1:8000/docs,按以下顺序测试:
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | POST /todos,body: {"title": "学习FastAPI"} | 返回 id=1 的 Todo,completed=false |
| 2 | POST /todos,body: {"title": "写作业", "completed": true} | 返回 id=2 的 Todo |
| 3 | GET /todos | 返回 2 个 Todo 的列表 |
| 4 | GET /todos?completed=false | 只返回未完成的 Todo |
| 5 | GET /todos/1 | 返回 id=1 的详情 |
| 6 | PUT /todos/1,body: {"completed": true} | 标记为已完成 |
| 7 | DELETE /todos/2 | 删除 id=2 |
| 8 | GET /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)
→ 只取客户端显式传了的字段,没传的不更新练习思考
- 尝试在
TodoCreate中将title的类型改为int,然后发送{"title": "学习"},观察 422 校验错误的详细内容。 - 尝试访问
GET /todos?skip=0&limit=1,体会分页效果。 - 思考:为什么要区分
TodoCreate、TodoUpdate、TodoResponse三个模型?(下一章会详细讲读写分离思想)
本章小结
| 概念 | 要点 |
|---|---|
| 路径操作 | @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 → 请求体;其他 → 查询参数 |
| HTTPException | raise HTTPException(status_code=404) 返回错误响应 |
下一章预告:我们将深入 Pydantic,学习数据校验的各种高级用法(Field 约束、自定义校验器、嵌套模型),以及如何用响应模型控制返回数据——这是写出健壮 API 的关键。