第一个完整项目——内存版待办事项
这一章要把哪些知识串起来
一个任务包含编号、标题、描述和完成状态。我们实现创建、列表、详情、完整替换、局部更新、删除六种操作。
这里使用 Python 字典存储数据,目的是先把接口流程学清楚。服务重启后数据消失,多个进程也不共享这份字典;第 15 章讲 MySQL 基础,第 26~38 章完成持久化版本。
接口约定
| 方法 | 路径 | 输入 | 成功结果 |
|---|---|---|---|
| POST | /todos | 创建模型 | 201,返回任务 |
| GET | /todos | 筛选和分页查询参数 | 200,返回列表 |
| GET | /todos/{todo_id} | 路径编号 | 200,返回任务 |
| PUT | /todos/{todo_id} | 完整任务内容 | 200,返回替换后任务 |
| PATCH | /todos/{todo_id} | 部分字段 | 200,返回更新后任务 |
| DELETE | /todos/{todo_id} | 路径编号 | 204,无正文 |
完整示例:todo_memory.py
python
from itertools import count
from typing import Annotated
from fastapi import FastAPI, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
app = FastAPI(title="内存版待办事项")
class TodoCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=500)
done: bool = False
@field_validator("title")
@classmethod
def clean_title(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("标题不能只有空格")
return value
class TodoPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str | None = Field(default=None, min_length=1, max_length=100)
description: str | None = Field(default=None, max_length=500)
done: bool | None = None
@field_validator("title")
@classmethod
def clean_title(cls, value: str | None) -> str:
if value is None or not value.strip():
raise ValueError("标题不能为 null 或只有空格")
return value.strip()
@field_validator("done")
@classmethod
def check_done(cls, value: bool | None) -> bool:
if value is None:
raise ValueError("完成状态不能为 null")
return value
class TodoPublic(TodoCreate):
id: int
todos: dict[int, TodoPublic] = {}
id_counter = count(1)
def find_todo(todo_id: int) -> TodoPublic:
todo = todos.get(todo_id)
if todo is None:
raise HTTPException(status_code=404, detail="任务不存在")
return todo
@app.post("/todos", response_model=TodoPublic, status_code=201)
async def create_todo(data: TodoCreate):
todo_id = next(id_counter)
todo = TodoPublic(id=todo_id, **data.model_dump())
todos[todo_id] = todo
return todo
@app.get("/todos", response_model=list[TodoPublic])
async def list_todos(
done: bool | None = None,
skip: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 10,
):
items = sorted(todos.values(), key=lambda todo: todo.id)
if done is not None:
items = [todo for todo in items if todo.done == done]
return items[skip : skip + limit]
@app.get("/todos/{todo_id}", response_model=TodoPublic)
async def read_todo(todo_id: int):
return find_todo(todo_id)
@app.put("/todos/{todo_id}", response_model=TodoPublic)
async def replace_todo(todo_id: int, data: TodoCreate):
find_todo(todo_id)
todo = TodoPublic(id=todo_id, **data.model_dump())
todos[todo_id] = todo
return todo
@app.patch("/todos/{todo_id}", response_model=TodoPublic)
async def update_todo(todo_id: int, data: TodoPatch):
old_todo = find_todo(todo_id)
changes = data.model_dump(exclude_unset=True)
merged = {**old_todo.model_dump(), **changes}
updated = TodoPublic.model_validate(merged)
todos[todo_id] = updated
return updated
@app.delete("/todos/{todo_id}", status_code=204)
async def delete_todo(todo_id: int):
find_todo(todo_id)
del todos[todo_id]
return Response(status_code=204)启动:
powershell
python -m uvicorn todo_memory:app --reload为什么 PATCH 模型这样写
PATCH 的核心是分清三种意图:
| 请求 | 意图 | 本示例行为 |
|---|---|---|
{} | 没有指定变更 | 保持原数据 |
{"done": true} | 标记完成 | 仅修改完成状态 |
{"description": null} | 清空描述 | 描述变成 None |
{"title": null} | 将标题设为空 | 拒绝,返回 422 |
{"done": null} | 将完成状态设为空 | 拒绝,返回 422 |
PATCH 模型给字段设置默认值以允许省略。字段校验器默认不会因为一个字段被省略就运行;客户端显式提供值时才进行相应检查。这个例子因此允许省略 title,同时拒绝显式 null。
exclude_unset=True 保留“传过哪些字段”的信息;用 exclude_none=True 会错误地丢掉“清空描述”这种明确意图。官方局部更新说明
为什么合并后再校验
TodoPatch 检查的是修改请求;TodoPublic.model_validate(merged) 检查的是修改完成后的整个任务。
避免认为 model_copy(update=...) 一定会重新校验更新内容。需要保证合并结果仍然合法时,可以像这里一样重新构建或校验模型。Pydantic 模型复制说明
手动验证顺序
- POST 创建
{"title": "学习请求参数", "description": "完成第 5 章"}。 - 记录响应中的
id,不要假设永远为 1。 - GET 读取该任务。
- PATCH 提交
{"done": true}。 - GET
/todos?done=true,确认列表包含它。 - PATCH 提交
{"description": null},确认描述被清空。 - 提交
{"title": " "},应得到 422。 - DELETE 删除任务,响应应是 204 且没有正文。
- 再次 GET,该任务应返回 404。
本章练习
为列表添加 keyword 搜索,搜索标题;再添加 priority,只允许 low、normal、high。
过关标准:不看原代码,也能写出创建、详情、PATCH 和删除流程,并解释模型和状态码。