用 FastAPI 写完一个真正可用的 CRUD:从创建到删除

摘要:把参数、Pydantic 模型、响应契约和异常处理组合成任务 CRUD,重点处理 PATCH 的部分更新语义、204 空响应以及跨接口场景测试。

任务资源从创建到删除的完整生命周期

图:同一条任务依次经历创建、读取、部分更新和删除,构成完整资源生命周期。

会写一个 GET 接口,不等于已经会设计资源 API。真正容易出错的是接口之间的关系:创建返回的 ID 能否继续查询,PATCH 会不会把未提交字段清空,删除后再次查询是否变成 404。

这篇文章用内存字典完成一套小而完整的任务 CRUD。暂时不碰数据库,是为了先把 HTTP 行为和资源生命周期做正确;存储实现会在后面的文章中替换。

先看这套 CRUD 对外承诺什么

方法路径作用成功状态码
POST/tasks创建任务201
GET/tasks查询列表200
GET/tasks/{id}查询详情200
PATCH/tasks/{id}修改部分字段200
DELETE/tasks/{id}删除任务204
fastapi dev examples/ch05_crud/main.py

先用测试固定已有行为

cd 04-fastapi-beginner
source .venv/bin/activate

代码位于 examples/ch05_crud/main.py。启动前执行测试,建立修改前基线:

python -m pytest tests/test_ch05.py -q

把五个接口串成资源生命周期

建立三类模型

TaskCreate 只接受创建字段,TaskUpdate 的字段都有默认值 NoneTaskRead 包含服务生成字段。完整定义见示例代码。

创建和读取

task = TaskRead(id=next_id, completed=False, **payload.model_dump())
tasks[next_id] = task
next_id += 1
return task

本文使用字典模拟数据库,键是任务 ID,值是模型对象。get_task_or_404() 集中处理重复的查找与 404。

实现部分更新

updated = task.model_copy(
    update=payload.model_dump(exclude_unset=True)
)
tasks[task_id] = updated
return updated

exclude_unset=True 只导出请求中出现的字段。客户端只传 title 时,原有 description 不会被默认值覆盖。

删除并返回空响应

@app.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task_id: int) -> Response:
    get_task_or_404(task_id)
    del tasks[task_id]
    return Response(status_code=status.HTTP_204_NO_CONTENT)

204 的语义是操作成功且没有响应体,因此测试会断言 response.content == b""

创建、读取和更新模型各自承担不同职责

图:创建输入、读取输出和部分更新使用不同模型,CRUD 契约会更清楚。

用 curl 走完创建到删除

创建:

curl -i -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"完成 CRUD","description":"贯通五个接口"}'

假设返回 ID 为 1,继续执行:

curl http://127.0.0.1:8000/tasks/1
curl -X PATCH http://127.0.0.1:8000/tasks/1 \
  -H "Content-Type: application/json" -d '{"completed":true}'
curl "http://127.0.0.1:8000/tasks?completed=true"
curl -i -X DELETE http://127.0.0.1:8000/tasks/1
curl -i http://127.0.0.1:8000/tasks/1

预期最后一次查询为 404。然后一键重放相同场景:

python -m pytest tests/test_ch05.py -q

PATCH 最难的是区分“未传”和 null

CRUD 不是五个孤立函数,而是围绕同一资源契约协作。创建产生 ID,详情使用 ID,修改保留未提交字段,删除后详情必须变成 404。场景测试比只测试单个函数更容易发现接口之间的不一致。

PATCH 表示部分修改,PUT 通常表示用完整表示替换资源。教程选择 PATCH,是为了实践“未传”和“显式传 null”的区别。

内存字典只适合学习:进程退出后数据消失;多个 worker 各有一份字典;同时修改也没有事务保护。SQLModel 与 SQLite 实战会保持接口契约不变,只替换存储实现。

PATCH 区分字段未传与显式空值

图:PATCH 只修改调用者真正提交的字段,未传字段不能和显式空值混为一谈。

内存 CRUD 的几个隐蔽错误

  • 每次创建都得到 ID 1:忘记递增或错误地使用局部变量。
  • PATCH 清空 description:没有使用 exclude_unset=True
  • DELETE 返回 JSON 和 204:204 不应该携带响应体。
  • 测试互相污染:全局字典需要在每个测试前重置。
  • 用列表下标充当 ID:删除元素后下标变化,稳定 ID 应独立生成。

接数据库之前,还可以补这些能力

  1. 增加 priority 字段,并让列表支持按优先级过滤。
  2. 增加 PUT /tasks/{id},要求提交完整任务内容。
  3. 阻止空 PATCH:请求 {} 时返回 400。
  4. 增加“删除不存在任务”的自动化测试。
  5. 启动两个进程,验证内存数据为什么不能共享,并记录观察结果。

最后,用测试固定这次改动

手动请求通过后,运行与本文对应的自动化测试:

python -m pytest tests/test_ch05.py -q
python tests/validate_course.py

写在最后

你已经拥有第一个行为完整的 API,但所有代码都堆在一个文件里。下一篇先用测试固定现有行为,再使用 APIRouter 和 Python 包把模型、路由和入口拆开。