FastAPI + SQLite 从 0 写一个真正能用的待办 API

摘要:从空目录开始,做一个具备新增、查询、修改、删除和自动化测试的待办事项 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 用户可以在资源管理器中创建 apptests 目录,不需要照搬最后两条 shell 命令。

2. 一个模型不要承担所有角色

数据库中的 Todo 有 id,但新建 Todo 时用户不应自己传 id;局部更新时,titledone 又都应当可选。所以我们使用四个很薄的模型,而不是把一张表直接暴露给所有接口。

将下面的完整代码写入 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 devGET /todos 仍然能读到未删除的记录,这才是从演示路由跨到可用 API 的那一步。

参考资料

相关推荐
陈驰_05041 小时前
需求列表接口 422 Unprocessable Entity 排查与修复全记录
状态模式·fastapi·vue 3
2601_962283881 小时前
Django数据库配置(一)
mysql·postgresql·django·sqlite·数据库配置
落木萧萧8251 小时前
MyBatis 启动的时候都在干什么:从 MappedStatement 说起
java·数据库·后端
Json____2 小时前
基于 FastAPI + Vue3 的在线拍卖系统技术解析
spring boot·后端·fastapi·wwwoop.com
创新技术阁2 小时前
FastapiAdmin 实战:二次开发前的准备(环境配置与项目启动)
前端·后端·fastapi
g10565591392 小时前
华为 OceanStor 基础使用入门指南
服务器·数据库·性能优化
潇凝子潇2 小时前
MySQL buffer pool 计算公式
数据库·mysql
广州灵眸科技有限公司2 小时前
瑞芯微(EASY EAI)RV1126B display
开发语言·数据库·人工智能·科技·嵌入式硬件
凌晨1682 小时前
MySQL:DML、DDL与TCL精讲
数据库·mysql·oracle