7.4 完成一次文章接口交付演练
完成一次文章接口交付演练
前面的章节分别处理了项目结构、执行方式、数据契约、依赖复用、数据库和接口文档。真正交付一个小改动时,这些检查必须围绕同一个需求连起来,否则每一项单独通过,组合后仍可能出现权限缺失、文档落后或迁移没有执行的问题。
本节选择一个小而完整的变更:文章新增可选摘要字段;创建文章需要已验证的用户身份;作者只能修改自己的文章;标题重复时返回 409;OpenAPI 文档写清 201、401、403 和 409。示例用内存存储演练接口契约,用独立 Alembic revision 表示数据库步骤;真实 PostgreSQL 迁移和约束验证仍按第五、七章的专用环境执行。
先写出变更边界
这次演练的输入、输出和失败结果如下:
| 请求 | 成功结果 | 关键失败结果 |
|---|---|---|
POST /posts |
201,返回 id、title、content、summary、author_id |
未认证 401,标题重复 409,字段格式错误 422 |
PATCH /posts/{post_id} |
文章作者修改后返回 200 |
未认证 401,不存在 404,非作者 403 |
author_id 来自认证依赖的结果,不从请求体接收。下面的 X-User-ID 只是一份测试替身,实际项目要替换为已经验证签名、声明和过期时间的身份依赖。把测试替身写在边界处,能够检查权限链,同时不会误导读者以为任意请求头就是认证系统。
实现模型、路由和权限
下面的完整应用把本次演练需要的模型、路由、错误文档和权限判断放在一份小文件中,便于复制运行。真实项目可以按第一章的业务目录拆分为 posts/schemas.py、posts/router.py 和 posts/service.py,拆分时保持依赖方向不变。
<!-- file: ch07_delivery/delivery_demo.py -->
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, status
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
class PostCreate(BaseModel):
title: str = Field(min_length=3, max_length=80)
content: str = Field(min_length=1)
summary: str | None = Field(default=None, max_length=160)
class PostUpdate(BaseModel):
summary: str | None = Field(default=None, max_length=160)
class PostResponse(PostCreate):
id: int
author_id: int
class ErrorResponse(BaseModel):
detail: str
posts: dict[int, dict[str, object]] = {}
def reset_store() -> None:
posts.clear()
def get_current_user(
x_user_id: Annotated[int | None, Header()] = None,
) -> int:
if x_user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="需要登录",
)
return x_user_id
UserDep = Annotated[int, Depends(get_current_user)]
app = FastAPI(title="文章接口交付演练")
@app.post(
"/posts",
response_model=PostResponse,
status_code=status.HTTP_201_CREATED,
responses={
401: {"model": ErrorResponse, "description": "未认证"},
409: {"model": ErrorResponse, "description": "标题重复"},
},
)
def create_post(payload: PostCreate, user_id: UserDep) -> dict[str, object]:
if any(post["title"] == payload.title for post in posts.values()):
raise HTTPException(status_code=409, detail="文章标题已存在")
post_id = max(posts, default=0) + 1
post = {"id": post_id, "author_id": user_id, **payload.model_dump()}
posts[post_id] = post
return post
@app.patch(
"/posts/{post_id}",
response_model=PostResponse,
responses={
401: {"model": ErrorResponse, "description": "未认证"},
403: {"model": ErrorResponse, "description": "无权修改"},
404: {"model": ErrorResponse, "description": "文章不存在"},
},
)
def update_post(
post_id: int,
payload: PostUpdate,
user_id: UserDep,
) -> dict[str, object]:
post = posts.get(post_id)
if post is None:
raise HTTPException(status_code=404, detail="文章不存在")
if post["author_id"] != user_id:
raise HTTPException(status_code=403, detail="没有修改这篇文章的权限")
post.update(payload.model_dump(exclude_unset=True))
return post
def self_check() -> None:
reset_store()
with TestClient(app, raise_server_exceptions=False) as client:
no_auth = client.post(
"/posts",
json={"title": "没有身份", "content": "正文"},
)
assert no_auth.status_code == 401
created = client.post(
"/posts",
headers={"X-User-ID": "7"},
json={
"title": "交付演练文章",
"content": "第一版正文",
"summary": "一段摘要",
},
)
assert created.status_code == 201
assert created.json()["author_id"] == 7
assert created.json()["summary"] == "一段摘要"
duplicate = client.post(
"/posts",
headers={"X-User-ID": "7"},
json={"title": "交付演练文章", "content": "重复标题"},
)
assert duplicate.status_code == 409
forbidden = client.patch(
"/posts/1",
headers={"X-User-ID": "8"},
json={"summary": "越权摘要"},
)
assert forbidden.status_code == 403
assert posts[1]["summary"] == "一段摘要"
updated = client.patch(
"/posts/1",
headers={"X-User-ID": "7"},
json={"summary": "修订摘要"},
)
assert updated.status_code == 200
assert updated.json()["summary"] == "修订摘要"
schema = app.openapi()
post_responses = schema["paths"]["/posts"]["post"]["responses"]
assert {"201", "401", "409"} <= set(post_responses)
patch_responses = schema["paths"]["/posts/{post_id}"]["patch"]["responses"]
assert {"200", "401", "403", "404"} <= set(patch_responses)
print("delivery demo self-check passed")
if __name__ == "__main__":
self_check()
运行:
python ch07_delivery/delivery_demo.py
预期输出为 delivery demo self-check passed。自检覆盖未认证、创建、重复标题、非作者修改、作者修改和 OpenAPI 响应文档;它不连接数据库,也不表示身份替身已经达到生产认证强度。
用迁移记录数据库变化
模型和路由增加了 summary,数据库结构也要有对应迁移。迁移文件必须是静态、可审查的历史记录,不要在 upgrade() 中导入当前请求模型,也不要让未来的模型改写过去的迁移。
<!-- file: ch07_delivery/alembic/versions/20260905_add_post_summary.py -->
"""add optional post summary
Revision ID: c31e7a90d2f4
Revises: 8b17f0c4e2a1
Create Date: 2026-09-05 16:00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "c31e7a90d2f4"
down_revision = "8b17f0c4e2a1"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"posts",
sa.Column("summary", sa.String(length=160), nullable=True),
)
def downgrade() -> None:
op.drop_column("posts", "summary")
本节只提供一份独立 revision,不包含 alembic.ini、env.py 和它依赖的基础 revision,因此先做语法检查:
python -m compileall -q ch07_delivery/alembic/versions
把该 revision 放入已有的完整 Alembic 项目,并确认 down_revision 指向该项目的实际上一版本后,才能在独立练习数据库执行 alembic upgrade head 和 alembic current。summary 允许为空,所以现有文章不需要先回填;如果以后要改成非空,应分成增加字段、回填、检查空值和设置约束几个可回退步骤。downgrade 会删除摘要内容,只有确认数据保留策略后才能使用。
把检查放进测试文件
自检脚本适合快速演练,项目交付时还应把关键断言放入 pytest 收集的测试文件。下面的文件复用同一个应用,验证权限和重复标题;它不重新定义业务规则。
<!-- file: ch07_delivery/test_delivery.py -->
from delivery_demo import app, reset_store
from fastapi.testclient import TestClient
def test_owner_and_non_owner_paths() -> None:
reset_store()
with TestClient(app) as client:
created = client.post(
"/posts",
headers={"X-User-ID": "21"},
json={"title": "pytest 交付文章", "content": "正文"},
)
assert created.status_code == 201
forbidden = client.patch(
"/posts/1",
headers={"X-User-ID": "22"},
json={"summary": "越权摘要"},
)
assert forbidden.status_code == 403
def test_duplicate_title_is_conflict() -> None:
reset_store()
with TestClient(app) as client:
payload = {"title": "唯一标题", "content": "正文"}
first = client.post(
"/posts",
headers={"X-User-ID": "21"},
json=payload,
)
second = client.post(
"/posts",
headers={"X-User-ID": "21"},
json=payload,
)
assert first.status_code == 201
assert second.status_code == 409
if __name__ == "__main__":
import pytest
raise SystemExit(pytest.main([__file__, "-q"]))
运行完整演练:
python ch07_delivery/delivery_demo.py
python -m pytest -q ch07_delivery/test_delivery.py
测试使用内存存储只为了快速验证路由与权限;数据库版本的接口测试应把应用的数据库依赖覆盖为独立 PostgreSQL 会话,并使用每例回滚策略。不能因为这两个进程内测试通过,就跳过 PostgreSQL 迁移和约束检查。
干净环境中的复现顺序
最终文件树可以保持最小规模:
ch07_delivery/
├── delivery_demo.py
├── test_delivery.py
└── alembic/
└── versions/
└── 20260905_add_post_summary.py
从干净目录复现时,按下面顺序执行:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install \
"fastapi==0.141.1" \
"httpx==0.28.1" \
"anyio==4.15.0" \
"pytest==9.1.1" \
"SQLAlchemy==2.0.52" \
"alembic==1.19.2" \
"psycopg==3.3.5" \
"ruff==0.16.6"
python ch07_delivery/delivery_demo.py
python -m pytest -q ch07_delivery/test_delivery.py
ruff check ch07_delivery
ruff format --check ch07_delivery
python -m compileall -q ch07_delivery/alembic/versions
数据库相关命令还要求把 revision 放入包含 alembic.ini、env.py 和完整 revision 图的实际项目。满足这些条件,并确认 TEST_DATABASE_URL 指向独立 PostgreSQL 数据库后再执行:
export TEST_DATABASE_URL='postgresql+psycopg://tester:password@127.0.0.1:5432/fastapi_test'
alembic upgrade head
alembic current
这份顺序把环境准备、静态检查、接口检查和数据库检查分开,出现失败时可以知道是哪一层出了问题。不要在没有测试数据库时把迁移命令改成生产连接串,也不要把 alembic upgrade head 的计划写成已经执行。
发布前检查清单
交付这次小改动时,逐项留下真实结果:
| 检查层 | 命令或证据 | 本示例的边界 |
|---|---|---|
| 静态 | ruff check、ruff format --check、revision 人工审查 |
不能证明运行时行为 |
| 接口 | delivery_demo.py、pytest 的状态码和字段断言 |
使用内存存储和认证替身 |
| 数据库 | 独立 PostgreSQL 的迁移、约束、回滚和查询 | 当前演练文件不连接数据库 |
| 文档 | app.openapi() 包含成功与失败响应 |
还要在实际文档入口确认渲染效果 |
| 线上 | 发布流程、日志、监控和回滚演练 | 本教程不提供部署或压测结果 |
本节实际能运行的部分是内存接口自检、pytest 测试和迁移文件的静态阅读路径;PostgreSQL 执行、真实身份认证、线上部署和负载表现必须在相应环境中单独验收。保留这些未验证项,比用一次进程内测试虚构“已经上线”更可靠。
资料来源
- 主要参考:fastapi-best-practices 中文 README 的项目结构、数据库、依赖、响应和测试主题。本节把分散的实践组织成一次文章接口交付演练,文件与验收清单属于教程扩展。
- 官方文档:FastAPI 测试、FastAPI 依赖覆盖、FastAPI 额外响应、Alembic 自动生成迁移。