codecamp

7.4 完成一次文章接口交付演练

完成一次文章接口交付演练

前面的章节分别处理了项目结构、执行方式、数据契约、依赖复用、数据库和接口文档。真正交付一个小改动时,这些检查必须围绕同一个需求连起来,否则每一项单独通过,组合后仍可能出现权限缺失、文档落后或迁移没有执行的问题。

本节选择一个小而完整的变更:文章新增可选摘要字段;创建文章需要已验证的用户身份;作者只能修改自己的文章;标题重复时返回 409;OpenAPI 文档写清 201401403409。示例用内存存储演练接口契约,用独立 Alembic revision 表示数据库步骤;真实 PostgreSQL 迁移和约束验证仍按第五、七章的专用环境执行。

先写出变更边界

这次演练的输入、输出和失败结果如下:

请求 成功结果 关键失败结果
POST /posts 201,返回 idtitlecontentsummaryauthor_id 未认证 401,标题重复 409,字段格式错误 422
PATCH /posts/{post_id} 文章作者修改后返回 200 未认证 401,不存在 404,非作者 403

author_id 来自认证依赖的结果,不从请求体接收。下面的 X-User-ID 只是一份测试替身,实际项目要替换为已经验证签名、声明和过期时间的身份依赖。把测试替身写在边界处,能够检查权限链,同时不会误导读者以为任意请求头就是认证系统。

实现模型、路由和权限

下面的完整应用把本次演练需要的模型、路由、错误文档和权限判断放在一份小文件中,便于复制运行。真实项目可以按第一章的业务目录拆分为 posts/schemas.pyposts/router.pyposts/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.inienv.py 和它依赖的基础 revision,因此先做语法检查:

python -m compileall -q ch07_delivery/alembic/versions

把该 revision 放入已有的完整 Alembic 项目,并确认 down_revision 指向该项目的实际上一版本后,才能在独立练习数据库执行 alembic upgrade headalembic currentsummary 允许为空,所以现有文章不需要先回填;如果以后要改成非空,应分成增加字段、回填、检查空值和设置约束几个可回退步骤。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.inienv.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 checkruff format --check、revision 人工审查 不能证明运行时行为
接口 delivery_demo.py、pytest 的状态码和字段断言 使用内存存储和认证替身
数据库 独立 PostgreSQL 的迁移、约束、回滚和查询 当前演练文件不连接数据库
文档 app.openapi() 包含成功与失败响应 还要在实际文档入口确认渲染效果
线上 发布流程、日志、监控和回滚演练 本教程不提供部署或压测结果

本节实际能运行的部分是内存接口自检、pytest 测试和迁移文件的静态阅读路径;PostgreSQL 执行、真实身份认证、线上部署和负载表现必须在相应环境中单独验收。保留这些未验证项,比用一次进程内测试虚构“已经上线”更可靠。

资料来源

7.3 用Ruff减少格式与静态错误
温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }