摘要:从空目录开始,做一个具备新增、查询、修改、删除和自动化测试的待办事项 API。数据落到 SQLite,关闭服务后不会丢失。
一个 Hello World 路由能证明 FastAPI 装好了,但还不能证明你会写 API。实际接口至少要回答三个问题:输入不合法时怎么办,数据保存在哪里,修改代码后怎么知道原有功能没坏。
这篇文章用一个 Todo 资源把这三件事连起来。
1. 初始化项目
先安装 uv,然后执行:
bash
uv init --app --bare todo-api
cd todo-api
uv python pin 3.12
uv add "fastapi[standard]" sqlmodel pydantic
uv add --dev pytest httpx2 ruff
mkdir -p app tests
touch app/__init__.py app/main.py tests/test_main.py
Windows 用户可以在资源管理器中创建 app 和 tests 目录,不需要照搬最后两条 shell 命令。
2. 一个模型不要承担所有角色
数据库中的 Todo 有 id,但新建 Todo 时用户不应自己传 id;局部更新时,title 和 done 又都应当可选。所以我们使用四个很薄的模型,而不是把一张表直接暴露给所有接口。
将下面的完整代码写入 app/main.py:
python
from collections.abc import Generator
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, Query, Response, status
from pydantic import model_validator
from sqlmodel import Field, Session, SQLModel, create_engine, select
class TodoBase(SQLModel):
title: str = Field(min_length=1, max_length=100, index=True)
done: bool = False
class Todo(TodoBase, table=True):
id: int | None = Field(default=None, primary_key=True)
class TodoCreate(TodoBase):
pass
class TodoUpdate(SQLModel):
title: str | None = Field(default=None, min_length=1, max_length=100)
done: bool | None = None
@model_validator(mode="after")
def reject_explicit_nulls(self):
for field_name in self.model_fields_set:
if getattr(self, field_name) is None:
raise ValueError(f"{field_name} 不能为 null")
return self
class TodoPublic(TodoBase):
id: int
sqlite_url = "sqlite:///todo.db"
engine = create_engine(sqlite_url, connect_args={"check_same_thread": False})
def create_db_and_tables() -> None:
SQLModel.metadata.create_all(engine)
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
SessionDep = Annotated[Session, Depends(get_session)]
@asynccontextmanager
async def lifespan(_: FastAPI):
create_db_and_tables()
yield
app = FastAPI(title="Todo API", version="1.0.0", lifespan=lifespan)
@app.post("/todos", response_model=TodoPublic, status_code=status.HTTP_201_CREATED)
def create_todo(todo: TodoCreate, session: SessionDep) -> Todo:
db_todo = Todo.model_validate(todo)
session.add(db_todo)
session.commit()
session.refresh(db_todo)
return db_todo
@app.get("/todos", response_model=list[TodoPublic])
def list_todos(
session: SessionDep,
done: bool | None = None,
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=100)] = 20,
) -> list[Todo]:
statement = select(Todo)
if done is not None:
statement = statement.where(Todo.done == done)
return list(session.exec(statement.offset(offset).limit(limit)).all())
@app.get("/todos/{todo_id}", response_model=TodoPublic)
def get_todo(todo_id: int, session: SessionDep) -> Todo:
todo = session.get(Todo, todo_id)
if todo is None:
raise HTTPException(status_code=404, detail="Todo not found")
return todo
@app.patch("/todos/{todo_id}", response_model=TodoPublic)
def update_todo(todo_id: int, changes: TodoUpdate, session: SessionDep) -> Todo:
todo = session.get(Todo, todo_id)
if todo is None:
raise HTTPException(status_code=404, detail="Todo not found")
todo.sqlmodel_update(changes.model_dump(exclude_unset=True))
session.add(todo)
session.commit()
session.refresh(todo)
return todo
@app.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_todo(todo_id: int, session: SessionDep) -> Response:
todo = session.get(Todo, todo_id)
if todo is None:
raise HTTPException(status_code=404, detail="Todo not found")
session.delete(todo)
session.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
get_session() 每次请求产生一个独立 Session,请求结束后自动关闭。这个依赖注入点后面还会用于测试:测试不需要改接口代码,只要替换 Session 的来源。
3. 启动并在 Swagger 中调用
bash
uv run fastapi dev
终端显示服务已启动后,打开:
text
http://127.0.0.1:8000/docs
先调用 POST /todos,请求体填:
json
{
"title": "写完 FastAPI 文章",
"done": false
}
响应状态码应为 201:
json
{
"title": "写完 FastAPI 文章",
"done": false,
"id": 1
}
再调用 PATCH /todos/1:
json
{
"done": true
}
这里特意用 PATCH 而不是 PUT,因为我们只修改 done,并不重新提交整个 Todo。exclude_unset=True 保证了未出现在请求体里的字段不会被覆盖。
也可以用 curl 完成同样的验证:
bash
curl -X POST http://127.0.0.1:8000/todos \
-H "Content-Type: application/json" \
-d '{"title":"写完 FastAPI 文章"}'
curl -X PATCH http://127.0.0.1:8000/todos/1 \
-H "Content-Type: application/json" \
-d '{"done":true}'
curl "http://127.0.0.1:8000/todos?done=true"
4. 测试时不要碰真实数据库
如果测试直接写 todo.db,用例之间会相互污染,还可能删掉手工调试留下的数据。下面的测试使用 SQLite 内存库,每个用例都从空表开始。
写入 tests/test_main.py:
python
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, SQLModel, create_engine
from sqlmodel.pool import StaticPool
from app.main import app, get_session
@pytest.fixture(name="session")
def session_fixture():
test_engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(test_engine)
with Session(test_engine) as session:
yield session
@pytest.fixture(name="client")
def client_fixture(session: Session):
def get_session_override():
return session
app.dependency_overrides[get_session] = get_session_override
yield TestClient(app)
app.dependency_overrides.clear()
def test_todo_crud(client: TestClient) -> None:
created = client.post("/todos", json={"title": "写完 FastAPI 文章"})
assert created.status_code == 201
todo_id = created.json()["id"]
updated = client.patch(f"/todos/{todo_id}", json={"done": True})
assert updated.status_code == 200
assert updated.json()["done"] is True
completed = client.get("/todos", params={"done": "true"})
assert completed.status_code == 200
assert len(completed.json()) == 1
deleted = client.delete(f"/todos/{todo_id}")
assert deleted.status_code == 204
assert client.get(f"/todos/{todo_id}").status_code == 404
def test_rejects_blank_title(client: TestClient) -> None:
response = client.post("/todos", json={"title": ""})
assert response.status_code == 422
def test_rejects_null_in_patch(client: TestClient) -> None:
created = client.post("/todos", json={"title": "不能被置空"})
todo_id = created.json()["id"]
response = client.patch(f"/todos/{todo_id}", json={"title": None})
assert response.status_code == 422
由于本文用 --bare 创建了未打包项目,再在 pyproject.toml 中加上测试路径:
toml
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
执行:
bash
uv run pytest -q
预期结果:
text
3 passed
这三个测试守住了完整生命周期和两条输入边界:空标题不能新建,PATCH 也不能把必填字段明确置为 null。后面重构路由或模型时,先跑它们,比重新点一遍 Swagger 稳定得多。
常见问题
为什么 SQLite 要加 check_same_thread=False?
FastAPI 处理一次请求时可能涉及不同线程,默认的 SQLite 线程检查会阻止这种用法。关闭检查不等于可以在请求之间共享同一个 Session;本文仍然是每个请求一个 Session。
为什么删除返回 204?
204 No Content 表示操作成功,但响应体为空。因此代码明确返回了空 Response,而不是再塞一个 {"ok": true}。
现在可以删掉运行中的服务,然后重新执行 uv run fastapi dev。GET /todos 仍然能读到未删除的记录,这才是从演示路由跨到可用 API 的那一步。