路由与请求响应
本章是 FastAPI 的「点单系统」说明书:URL 怎么进来、数据怎么接住、结果怎么发回去。学完你能独立写出一套带文档、带校验的 CRUD 接口。 示例场景统一为墨集 inkhub 内容平台——一个管理「栏目、文章…
本章是 FastAPI 的「点单系统」说明书:URL 怎么进来、数据怎么接住、结果怎么发回去。学完你能独立写出一套带文档、带校验的 CRUD 接口。
示例场景统一为墨集 inkhub 内容平台——一个管理「栏目、文章、作者」的内容系统。
本章你会用到的东西
@app.get / post / put / patch / delete:五个请求方法装饰器- 路径参数 + 查询参数:URL 两处取值的地方
- Pydantic 模型请求体:接 JSON 的正规姿势
response_model:出口滤网status_code:把结果「办成了没」说清楚APIRouter:路由分货架tags / summary / description:给 /docs 写说明书
2.1 五个方法装饰器:五种「点单方式」
作用:把「HTTP 方法 + 路径」和某个 Python 函数绑在一起。客户端怎么点单,就进哪个函数。
签名(五个装饰器参数完全一样,只是语义不同):
@app.get(
path, # 路径,必填,以 / 开头
*,
response_model=..., # 响应模型(2.5 节)
status_code=..., # 状态码(2.6 节)
tags=..., # 文档分组标签(2.8 节)
summary=..., # 一句话摘要(2.8 节)
description=..., # 详细说明(2.8 节)
deprecated=..., # 标记为已废弃
)
语义速记(咖啡店比喻):
| 装饰器 | 语义 | 咖啡店动作 |
|---|---|---|
@app.get |
取数据 | 看菜单、取餐 |
@app.post |
新建 | 下一单新的 |
@app.put |
整体替换 | 整杯重做 |
@app.patch |
局部更新 | 只改备注(少冰) |
@app.delete |
删除 | 退单 |
最小示例:
from fastapi import FastAPI
app = FastAPI(title="墨集 inkhub 内容平台")
@app.get("/articles")
def list_articles() -> list[dict[str, object]]:
return [{"id": 1, "title": "FastAPI 路由速览"}]
@app.post("/articles")
def create_article() -> dict[str, str]:
return {"message": "文章已创建"}
@app.put("/articles/{article_id}")
def replace_article(article_id: int) -> dict[str, str]:
return {"message": f"文章 {article_id} 已整体替换"}
@app.patch("/articles/{article_id}")
def update_article(article_id: int) -> dict[str, str]:
return {"message": f"文章 {article_id} 已局部更新"}
@app.delete("/articles/{article_id}")
def delete_article(article_id: int) -> dict[str, str]:
return {"message": f"文章 {article_id} 已删除"}
常见坑:
- 同一路径可以注册多个方法(GET + POST 共存没问题),FastAPI 靠「方法 + 路径」二元组区分。
- 忘写装饰器、只定义了函数——函数永远不会被调用,还不报错。
- 别用 GET 做删除等危险操作:浏览器预加载、爬虫、各种缓存代理都可能「顺手」访问 GET 接口,造成误删。
2.2 路径参数:URL 里的动态位
作用:在路径里挖个坑 {article_id},请求时坑里的值自动传给同名函数参数。就像快递单号——单号本身就是地址的一部分,定位唯一资源。
签名:坑写在路径里,值靠函数参数的类型标注来校验:
@app.get("/articles/{article_id}")
def get_article(article_id: int) -> dict[str, int]:
# ^^^^ 类型标注 = 校验规则
return {"article_id": article_id}
请求 GET /articles/42 → article_id 是 int 的 42;
请求 GET /articles/abc → 类型对不上,FastAPI 直接回 422,函数根本不会执行。
支持的数据类型:int、float、str、bool、UUID、datetime、Enum 以及它们的列表。
from enum import Enum
class Column(str, Enum): # Enum 路径参数:只允许枚举里的值
tech = "tech"
life = "life"
design = "design"
@app.get("/columns/{column}")
def get_column(column: Column) -> dict[str, str]:
return {"column": column.value}
# GET /columns/tech ✅ 200
# GET /columns/finance ❌ 422(不在枚举里)
加约束:用 Path(),配合 Annotated(新写法,推荐):
from typing import Annotated
from fastapi import Path
@app.get("/articles/{article_id}")
def get_article(
article_id: Annotated[int, Path(ge=1, le=99999, description="文章ID")],
) -> dict[str, int]:
return {"article_id": article_id}
# GET /articles/0 ❌ 422(小于 1)
常见坑:
- 路由顺序:
/articles/me必须注册在/articles/{article_id}之前,否则me会被当成article_id去做 int 校验,直接 422。FastAPI 按注册顺序匹配,先到先得。 - 路径参数值里带
/(如文件路径a/b/c)会 404,需要通配写法{file_path:path}。 - 函数参数名必须和坑名完全一致:
{article_id}对应article_id,写成articleID就对不上了。
2.3 查询参数:URL 问号后面的备注栏
作用:?key=value&key2=value2 形式的参数。规则很简单——函数参数没出现在路径里,就自动变成查询参数。它是「点单备注」:不改变你要哪杯咖啡(路径),只调整细节(筛选、分页)。
必选 / 可选 / 默认值三件套:
@app.get("/articles")
def list_articles(
keyword: str | None = None, # 有默认值 None → 可选
page: int = 1, # 有默认值 → 可选,不传就用 1
size: int = 10,
author: str = "all", # 默认值可以是任意值
) -> dict[str, object]:
return {"keyword": keyword, "page": page, "size": size, "author": author}
反过来说:没有默认值的参数就是必选,不传就 422:
def list_articles(keyword: str) -> ...: # ?keyword=xxx 必须传
bool 的智能转换:查询参数是 bool 时,true / false、1 / 0、on / off、yes / no 都能识别:
def list_articles(published: bool = True) -> ...:
...
# GET /articles?published=no → published = False
# GET /articles?published=1 → published = True
列表参数:同一个 key 重复出现:
# GET /articles?tag=python&tag=fastapi
def list_articles(tag: list[str]) -> ...:
... # tag = ["python", "fastapi"]
加约束 / 写文档:用 Query(),同样推荐 Annotated 写法:
from typing import Annotated
from fastapi import Query
@app.get("/articles")
def list_articles(
keyword: Annotated[
str | None,
Query(min_length=1, max_length=50, description="标题关键词"),
] = None,
page: Annotated[int, Query(ge=1, le=999, description="页码")] = 1,
) -> dict[str, object]:
return {"keyword": keyword, "page": page}
常见坑:
- 可选参数的标准写法是
str | None = None(或Optional[str])。只写q: str = None会过不了类型检查器。 Query()里不给default,参数就变成必选——想可选记得Query(default=None)或用Annotated+= None的组合。- 422 报错里看
loc字段:["query", "page"]说明是查询参数page出错,别去请求体里找。
2.4 请求体:让客户端「填表」
作用:POST / PUT / PATCH 的数据主力在请求体里,通常是 JSON。FastAPI 的接法:定义一个 Pydantic 模型当参数类型,框架自动完成「读 body → JSON 解析 → 逐字段校验 → 组装成实例」。
签名与最小示例:
from pydantic import BaseModel, Field
from fastapi import FastAPI
app = FastAPI(title="墨集 inkhub 内容平台")
class ArticleIn(BaseModel): # 入参模型:客户端填的表
title: str = Field(min_length=1, max_length=100)
content: str
author_id: int = Field(ge=1)
tags: list[str] = [] # 嵌套结构直接用类型标注
@app.post("/articles")
def create_article(article: ArticleIn) -> dict[str, object]:
# article 已经是 ArticleIn 实例,字段访问直接用
return {"title": article.title, "tags": article.tags}
请求:
curl -X POST http://127.0.0.1:8000/articles \
-H "Content-Type: application/json" \
-d '{"title": "SSE 入门", "content": "……", "author_id": 2, "tags": ["实时"]}'
模型可以嵌套,一层套一层都行:
class AuthorBrief(BaseModel):
name: str
bio: str = ""
class ArticleIn(BaseModel):
title: str
author: AuthorBrief # 嵌套模型
tags: list[str] = []
对应 JSON 里 author 是一个对象。
常见坑:
- 请求头必须是
Content-Type: application/json。用表单(form-data)发 JSON 字段会 422——表单要用Form(),那是另一套接法。 - 字段校验失败返回 422,错误明细在
detail数组里,loc: ["body", "title"]会精确到字段。 - 请求体参数用于 POST / PUT / PATCH;GET 虽然技术上能带 body,但约定不带,别这么干。
- 别在 handler 里手写
await request.json()再自己取字段——交给 Pydantic 模型,校验和文档都是白送的。
Field 的完整约束(
ge / pattern / examples等)在第 03 章展开,这里先会「模型接住 body」即可。
2.5 response_model:出口滤网
作用:函数 return 的数据先过一遍「滤网」再发给客户端——该带的带出去,不该带的拦下来。你 return 的可能是数据库整行(带密码哈希),滤网只放行模型里声明的字段。
签名:
@app.get("/authors/{author_id}", response_model=AuthorOut)
最小示例:
from typing import Any
from pydantic import BaseModel
class AuthorOut(BaseModel): # 出参模型:注意——没有 password 字段
id: int
name: str
bio: str = ""
AUTHORS: dict[int, dict[str, Any]] = {
1: {"id": 1, "name": "林墨", "bio": "写字的人", "password": "不告诉你"},
}
@app.get("/authors/{author_id}", response_model=AuthorOut)
def get_author(author_id: int) -> dict[str, Any]:
return AUTHORS[author_id] # 返回值里带着 password,但出不去
客户端收到的只有 id / name / bio——password 被滤网拦住了。这就是它最重要的价值:入库模型和出参模型分开定义。
三件事一次做完:
- 字段过滤:return 的数据里,只有
AuthorOut声明过的字段能出去。 - 输出校验 + 转换:返回的数据会按模型做一次校验和类型转换,返回类型不对会当场报错,脏数据出不了门。
- 文档生成:/docs 里这个接口的响应结构就是
AuthorOut的形状。
按需裁剪:
@app.get("/authors/{author_id}", response_model=AuthorOut,
response_model_exclude_unset=True) # 只输出显式赋值过的字段
@app.get("/authors/{author_id}", response_model=AuthorOut,
response_model_exclude_defaults=True) # 排除等于默认值的字段
@app.get("/authors/{article_id}", response_model=ArticleOut,
response_model_exclude={"content"}) # 排除指定字段(列表页不吐正文)
常见坑:
- handler 同时写了返回类型标注(
-> AuthorOut)和response_model=AuthorOut时,以response_model为准。建议二选一,团队统一风格。 - 返回数据缺少
response_model的必填字段时,会抛ResponseValidationError(500)——滤网拦的不只是「多的」,还有「残的」。 - 默认返回
null的字段照样出现在响应里,想要「空的就别出现」用response_model_exclude_none=True。 - 入参和出参共用一个模型是大坑:客户端能看到不该看的字段定义。入参用
XxxIn,出参用XxxOut。
2.6 状态码:告诉客户端「办成了没」
作用:HTTP 状态码是「办理结果」的速记。return 的 body 说「发生了什么」,状态码说「这单办得怎么样」。
用法:装饰器参数 status_code,配合 fastapi.status 里的常量:
from fastapi import FastAPI, status
app = FastAPI(title="墨集 inkhub 内容平台")
@app.post("/articles", status_code=status.HTTP_201_CREATED)
def create_article(article: ArticleIn) -> dict[str, int]:
return {"id": 100}
@app.delete("/articles/{article_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_article(article_id: int) -> None:
return None # 204 = 无内容,不 return body
常用常量速查:
| 常量 | 含义 | 什么时候用 |
|---|---|---|
HTTP_200_OK |
成功 | 查询、普通操作(默认值) |
HTTP_201_CREATED |
已创建 | POST 新建成功 |
HTTP_204_NO_CONTENT |
无内容 | DELETE 成功、更新成功不返回体 |
HTTP_400_BAD_REQUEST |
请求错误 | 参数语义不对 |
HTTP_401_UNAUTHORIZED |
未认证 | 没 token(第 11 章) |
HTTP_403_FORBIDDEN |
无权限 | 认证了但没权限 |
HTTP_404_NOT_FOUND |
不存在 | 资源找不到 |
HTTP_409_CONFLICT |
冲突 | 唯一约束被撞(重名) |
HTTP_422_UNPROCESSABLE_ENTITY |
校验失败 | FastAPI 校验不通过时自动返回 |
动态状态码:装饰器里的 status_code 是写死的;要根据运行结果变,注入 Response 对象:
from fastapi import Response
@app.post("/articles")
def create_article(article: ArticleIn, response: Response) -> dict[str, int]:
article_id = 100
if article_id > 99:
response.status_code = status.HTTP_201_CREATED
return {"id": article_id}
更常见的动态做法是抛 HTTPException(统一异常处理那章细讲)。
常见坑:
- 204 接口别 return 有内容的对象——body 会被忽略或直接报错,客户端预期也是空体。
- 「全接口 200,错误写在 body 里」是反模式:监控、网关、客户端 SDK 都读状态码,别绕过它。
- POST 创建成功返回 201 更专业,但注意前端拿到的处理逻辑一样,别为了 201 又包一层。
2.7 APIRouter:把路由分货架
作用:接口多了全挂在 app 上,main.py 会变成菜市场。APIRouter 是「分区货架」:文章一个货架、作者一个货架,各自带前缀和标签,最后由收银台 include_router 统一扫码入库。
最小示例:
# articles_api.py —— 文章货架
from fastapi import APIRouter
articles_router = APIRouter(prefix="/articles", tags=["文章管理"])
@articles_router.get("/{article_id}")
def get_article(article_id: int) -> dict[str, int]:
return {"article_id": article_id}
# authors_api.py —— 作者货架
from fastapi import APIRouter
authors_router = APIRouter(prefix="/authors", tags=["作者"])
# main.py —— 收银台:统一挂载
from fastapi import FastAPI
app = FastAPI(title="墨集 inkhub 内容平台")
app.include_router(articles_router)
app.include_router(authors_router, prefix="/v1") # 挂载时还能再叠加前缀
挂载后,文章接口的完整路径是 /articles/{article_id},作者接口是 /v1/authors/...。
APIRouter 常用参数:
| 参数 | 作用 |
|---|---|
prefix |
本货架所有路径的公共前缀 |
tags |
文档分组标签(2.8 节) |
dependencies |
货架级依赖,如整组接口都要登录(第 08 章展开) |
responses |
整组接口的公共额外响应说明 |
include_router 的参数:prefix(叠加到 router 的 prefix 后面)、tags(追加)、dependencies(追加)。同一套 router 可以挂到多个前缀下——做 /v1、/v2 双版本接口时特别好用:
app.include_router(articles_router, prefix="/v1")
app.include_router(articles_router, prefix="/v2")
常见坑:
prefix必须以/开头;结尾不要带/(拼接时会出双斜杠)。- 忘了
include_router→ 路由不存在,请求 404,而且没有任何报错提示,排查时先检查挂载。 - 两个 router 的路径撞车(前缀拼接后相同)不会报错,先注册的先匹配——命名时保持前缀唯一。
- 路由顺序问题(2.2 节的
/articles/me)在拆成 router 后依然存在:固定路径的路由要保证最终注册顺序靠前。
2.8 tags / summary / description:给接口写说明书
作用:让 /docs 从「草稿纸」变成「说明书」。三层可以配:应用级、路由级、参数级。
应用级——FastAPI() 构造参数:
app = FastAPI(
title="墨集 inkhub 内容平台",
description="内容平台的**文章、栏目、作者**管理接口。",
version="1.0.0",
)
路由级——装饰器参数:
@app.get(
"/articles/{article_id}",
tags=["文章管理"], # 文档里按 tag 自动分组
summary="查询单篇文章", # 一句话摘要(不支持 Markdown)
description="按 ID 查询文章详情,**正文全文返回**。", # 支持 Markdown
response_description="文章详情",
deprecated=False, # True 时文档标灰提示废弃
responses={ # 声明额外响应
"404": {"description": "文章不存在"},
},
)
def get_article(article_id: int) -> dict[str, int]:
return {"article_id": article_id}
用了 APIRouter 的路由,tags=["文章管理"] 也可以写在 router 上,组内接口共享,省得每个都写。
参数级——Path() / Query() / Field() 的 title / description / examples,让文档里每个参数都有说明(Field 的完整玩法第 03 章讲)。
常见坑:
summary是纯文本一行字,description才支持 Markdown。deprecated=True只是「标注废弃」,接口照常能调,真正的禁用要靠依赖(第 08 章)。responses的 key 是字符串"404",不是数字。- tags 是分组标签不是路径,别把它当 prefix 用。
2.9 小结
一张表带走本章:
| 你想做什么 | 用什么 |
|---|---|
| 绑定 URL 到函数 | @app.get/post/put/patch/delete |
| URL 里取动态值并校验 | 路径参数 + 类型标注(Path() 加约束) |
| 筛选 / 分页 / 可选项 | 查询参数(默认值控制必选可选,Query() 加约束) |
| 接收 JSON | Pydantic 模型参数(XxxIn) |
| 控制返回哪些字段 | response_model=XxxOut |
| 声明办理结果 | status_code=status.HTTP_201_CREATED |
| 路由分组建前缀 | APIRouter(prefix=..., tags=...) + include_router |
| 完善接口文档 | tags / summary / description / responses |
配套代码:
demo/demo_router.py:单文件演示路径参数 / 查询参数 / 请求体 / 响应模型 / 状态码,内嵌 uvicorn,注释里有 curl 示例demo/demo_api_router.py:APIRouter 拆分与挂载的最小演示
作业
- 路径参数和查询参数,在「定义方式」上的根本区别是什么?
response_model为什么被叫做「出口滤网」?它能防住哪一类典型的信息泄漏?/articles/me和/articles/{article_id}谁应该先注册?不这么做会发生什么?- router 自带
prefix="/articles",include_router(router, prefix="/v2"),最终路径是什么? - 新建文章成功、删除文章成功,分别适合返回什么状态码?哪个不应该带响应体?