首页/Python 框架指南/路由与请求响应
Python 框架指南 · 第 02 章

路由与请求响应

本章是 FastAPI 的「点单系统」说明书:URL 怎么进来、数据怎么接住、结果怎么发回去。学完你能独立写出一套带文档、带校验的 CRUD 接口。 示例场景统一为墨集 inkhub 内容平台——一个管理「栏目、文章…

约 28 分钟11,170 字24 个代码示例

本章是 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} 已删除"}

常见坑:

  1. 同一路径可以注册多个方法(GET + POST 共存没问题),FastAPI 靠「方法 + 路径」二元组区分。
  2. 忘写装饰器、只定义了函数——函数永远不会被调用,还不报错。
  3. 别用 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)

常见坑:

  1. 路由顺序:/articles/me 必须注册在 /articles/{article_id} 之前,否则 me 会被当成 article_id 去做 int 校验,直接 422。FastAPI 按注册顺序匹配,先到先得。
  2. 路径参数值里带 /(如文件路径 a/b/c)会 404,需要通配写法 {file_path:path}。
  3. 函数参数名必须和坑名完全一致:{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}

常见坑:

  1. 可选参数的标准写法是 str | None = None(或 Optional[str])。只写 q: str = None 会过不了类型检查器。
  2. Query() 里不给 default,参数就变成必选——想可选记得 Query(default=None) 或用 Annotated + = None 的组合。
  3. 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 是一个对象。

常见坑:

  1. 请求头必须是 Content-Type: application/json。用表单(form-data)发 JSON 字段会 422——表单要用 Form(),那是另一套接法。
  2. 字段校验失败返回 422,错误明细在 detail 数组里,loc: ["body", "title"] 会精确到字段。
  3. 请求体参数用于 POST / PUT / PATCH;GET 虽然技术上能带 body,但约定不带,别这么干。
  4. 别在 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 被滤网拦住了。这就是它最重要的价值:入库模型和出参模型分开定义。

三件事一次做完:

  1. 字段过滤:return 的数据里,只有 AuthorOut 声明过的字段能出去。
  2. 输出校验 + 转换:返回的数据会按模型做一次校验和类型转换,返回类型不对会当场报错,脏数据出不了门。
  3. 文档生成:/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"})     # 排除指定字段(列表页不吐正文)

常见坑:

  1. handler 同时写了返回类型标注(-> AuthorOut)和 response_model=AuthorOut 时,以 response_model 为准。建议二选一,团队统一风格。
  2. 返回数据缺少 response_model 的必填字段时,会抛 ResponseValidationError(500)——滤网拦的不只是「多的」,还有「残的」。
  3. 默认返回 null 的字段照样出现在响应里,想要「空的就别出现」用 response_model_exclude_none=True。
  4. 入参和出参共用一个模型是大坑:客户端能看到不该看的字段定义。入参用 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(统一异常处理那章细讲)。

常见坑:

  1. 204 接口别 return 有内容的对象——body 会被忽略或直接报错,客户端预期也是空体。
  2. 「全接口 200,错误写在 body 里」是反模式:监控、网关、客户端 SDK 都读状态码,别绕过它。
  3. 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")

常见坑:

  1. prefix 必须以 / 开头;结尾不要带 /(拼接时会出双斜杠)。
  2. 忘了 include_router → 路由不存在,请求 404,而且没有任何报错提示,排查时先检查挂载。
  3. 两个 router 的路径撞车(前缀拼接后相同)不会报错,先注册的先匹配——命名时保持前缀唯一。
  4. 路由顺序问题(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 章讲)。

常见坑:

  1. summary 是纯文本一行字,description 才支持 Markdown。
  2. deprecated=True 只是「标注废弃」,接口照常能调,真正的禁用要靠依赖(第 08 章)。
  3. responses 的 key 是字符串 "404",不是数字。
  4. 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 拆分与挂载的最小演示

作业

  1. 路径参数和查询参数,在「定义方式」上的根本区别是什么?
  2. response_model 为什么被叫做「出口滤网」?它能防住哪一类典型的信息泄漏?
  3. /articles/me 和 /articles/{article_id} 谁应该先注册?不这么做会发生什么?
  4. router 自带 prefix="/articles",include_router(router, prefix="/v2"),最终路径是什么?
  5. 新建文章成功、删除文章成功,分别适合返回什么状态码?哪个不应该带响应体?

下一章:03 · Pydantic 数据校验与配置