codecamp

4.1 一次定义资源存在性检查

一次定义资源存在性检查

文章详情、文章修改、文章评论和文章收藏都需要先确认文章存在。如果每个路由都自己调用一次查询并分别处理“找不到”,久而久之就会出现状态码、错误消息和查询方式不一致的问题。本节把这条规则提取为一个 FastAPI 依赖,让路由直接拿到已经加载的文章对象。

这里的“依赖”不是把所有业务逻辑塞进一个函数,而是把一个稳定的前置条件表达出来:进入路由前,给定的 post_id 必须能找到文章。查询仍由文章领域的 service 负责,依赖只负责把 service 的结果转换为 HTTP 层需要的 404。

先把查询结果放在 service

依赖会调用 service.get_post(),而不是直接访问全局数据或数据库。这样做有两个好处:文章查询规则仍属于文章领域;以后把内存数据替换成数据库查询时,路由和依赖的接口可以保持不变。

下面的 _POSTS 只是可运行的教学替身。它没有持久化能力,也没有处理多进程并发;生产代码应把相同的 service 边界接到数据库会话上。

Annotated 声明文章依赖

valid_post_id() 的参数名是 post_id,它与路由路径中的 {post_id} 相同。FastAPI 会从当前请求的路径参数中取值,再调用依赖。依赖返回的文章对象会注入到路由参数 post 中,路由不需要再次查询。

参数名是这条约定的关键。例如路由写成 /posts/{post_id},依赖却声明 article_id: int,FastAPI 不会自动把两个名字猜成同一个路径参数;应当统一命名,或者显式设计另一个参数来源。

Annotated[dict[str, Any], Depends(valid_post_id)] 同时保留了类型信息和依赖关系。类型注解帮助阅读器和静态工具理解 post 的形状,Depends 则告诉 FastAPI 如何得到它。

<!-- file: ch04_resource/resource_dependency_demo.py -->

from __future__ import annotations


from typing import Annotated, Any


from fastapi import APIRouter, Depends, FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field




class PostResponse(BaseModel):
    id: int
    title: str
    content: str
    author_id: int




class PostUpdate(BaseModel):
    title: str = Field(min_length=1, max_length=80)




_POSTS: list[dict[str, Any]] = [
    {
        "id": 1,
        "title": "FastAPI 项目从哪里开始",
        "content": "先定义可检查的接口,再决定如何拆分目录。",
        "author_id": 10,
    },
    {
        "id": 2,
        "title": "让错误结果也有约定",
        "content": "找不到文章时返回明确的 404。",
        "author_id": 20,
    },
]




class PostService:
    async def get_post(self, post_id: int) -> dict[str, Any] | None:
        for post in _POSTS:
            if post["id"] == post_id:
                return post
        return None


    async def update_title(
        self, post: dict[str, Any], title: str
    ) -> dict[str, Any]:
        post["title"] = title
        return post




service = PostService()




async def valid_post_id(post_id: int) -> dict[str, Any]:
    """加载文章;找不到时在进入路由前返回 404。"""
    post = await service.get_post(post_id)
    if post is None:
        raise HTTPException(status_code=404, detail="文章不存在")
    return post




Post = Annotated[dict[str, Any], Depends(valid_post_id)]


router = APIRouter(prefix="/posts", tags=["posts"])




@router.get("/{post_id}", response_model=PostResponse)
async def get_post(post: Post) -> dict[str, Any]:
    # 文章已经由 valid_post_id 加载,这里不再按 ID 查询。
    return post




@router.patch("/{post_id}", response_model=PostResponse)
async def update_post(
    update: PostUpdate,
    post: Post,
) -> dict[str, Any]:
    return await service.update_title(post, update.title)




app = FastAPI(title="资源存在性依赖示例")
app.include_router(router)




def self_check() -> None:
    with TestClient(app) as client:
        found = client.get("/posts/1")
        assert found.status_code == 200
        assert found.json()["id"] == 1


        updated = client.patch("/posts/1", json={"title": "依赖复用后的文章"})
        assert updated.status_code == 200
        assert updated.json()["title"] == "依赖复用后的文章"


        missing = client.get("/posts/999")
        assert missing.status_code == 404
        assert missing.json() == {"detail": "文章不存在"}


        missing_update = client.patch(
            "/posts/999", json={"title": "不会写入"}
        )
        assert missing_update.status_code == 404


    print("resource_dependency_demo self-check passed")




if __name__ == "__main__":
    self_check()

在保存文件的目录中运行:

python ch04_resource/resource_dependency_demo.py

预期输出:

resource_dependency_demo self-check passed

如果只想启动接口,可以从包含 ch04_resource/ 的工作目录运行:

python -m uvicorn ch04_resource.resource_dependency_demo:app --reload

依赖应该返回什么

这里的依赖返回完整文章,而不是只返回 True。返回对象能让详情和修改路由直接使用同一份已加载结果,也避免路由拿到布尔值后又查询一次文章。

依赖仍然不应该承担所有后续操作。它没有修改文章、没有解析请求体,也没有决定修改成功后的响应格式;它只负责“文章存在”这个前置条件。文章更新属于 service,响应字段属于 PostResponse

当资源不存在时,HTTPException(status_code=404) 在依赖中抛出,路由函数不会被调用。这样所有使用 Post 类型别名的路由都共享同一条失败路径。若未来需要区分已删除和从未存在的文章,可以让 service 返回更具体的领域结果,再在依赖中统一映射为约定的 HTTP 响应。

不要把数据库查找放进 Pydantic 字段校验器。字段校验适合检查长度、格式和类型;文章是否存在取决于外部状态,应由 service 和依赖处理。这样请求模型仍可以在没有数据库的单元测试中独立验证。

常见排错

  • 访问 /posts/999 得到 422 而不是 404:先检查 post_id 是否能被转换为整数。/posts/not-a-number 是路径参数格式错误,属于请求校验失败;/posts/999 才是资源不存在。
  • 依赖收不到路径值:检查依赖参数是否叫 post_id,以及路由是否真的使用了 {post_id}。路径参数名称需要沿调用链保持一致。
  • 路由又调用了一次 service.get_post():删除重复查询,让路由接收 post: Post。如果某个操作确实需要不同的查询视图,就单独命名另一个依赖,不要让一个依赖返回含义模糊的“大对象”。
  • 依赖直接改动共享数据导致测试互相影响:本节为了演示更新使用了内存对象;真实项目应让会话和事务管理数据生命周期,并在测试中重置数据库状态。

资料来源

3.4 按需要拆分环境配置
4.2 串联身份与资源所有权检查
温馨提示
下载编程狮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; }