用 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 的字段都有默认值 None,TaskRead 包含服务生成字段。完整定义见示例代码。
创建和读取
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 只修改调用者真正提交的字段,未传字段不能和显式空值混为一谈。
内存 CRUD 的几个隐蔽错误
- 每次创建都得到 ID 1:忘记递增或错误地使用局部变量。
- PATCH 清空 description:没有使用
exclude_unset=True。 - DELETE 返回 JSON 和 204:204 不应该携带响应体。
- 测试互相污染:全局字典需要在每个测试前重置。
- 用列表下标充当 ID:删除元素后下标变化,稳定 ID 应独立生成。
接数据库之前,还可以补这些能力
- 增加
priority字段,并让列表支持按优先级过滤。 - 增加
PUT /tasks/{id},要求提交完整任务内容。 - 阻止空 PATCH:请求
{}时返回 400。 - 增加“删除不存在任务”的自动化测试。
- 启动两个进程,验证内存数据为什么不能共享,并记录观察结果。
最后,用测试固定这次改动
手动请求通过后,运行与本文对应的自动化测试:
python -m pytest tests/test_ch05.py -q
python tests/validate_course.py
写在最后
你已经拥有第一个行为完整的 API,但所有代码都堆在一个文件里。下一篇先用测试固定现有行为,再使用 APIRouter 和 Python 包把模型、路由和入口拆开。