codecamp

4.4 用一致的资源路径支持依赖复用

用一致的资源路径支持依赖复用

路径、HTTP 方法和参数名称共同表达接口的资源关系。文章列表、文章详情和某位作者的文章列表虽然返回的数据相近,但它们的资源范围不同;把这些关系写清楚,依赖才容易复用,嵌套资源也才有机会进行完整的归属检查。

本节围绕 postsauthors 设计最小路由集:

路由 含义 失败重点
GET /posts 查看文章列表 返回当前可见文章集合
GET /posts/{post_id} 查看单篇文章 post_id 不存在时 404
GET /authors/{author_id}/posts 查看某位作者的文章 作者不存在时 404
GET /authors/{author_id}/posts/{post_id} 在作者范围内查看一篇文章 两个 ID 都存在但不归属时仍失败
PUT /posts/{post_id} 替换全部可编辑字段 请求体需要标题和正文
PATCH /authors/{author_id}/posts/{post_id} 在作者范围内局部修改 先验证作者与文章的关系

这是一套清晰的团队约定,不是唯一的 REST 命名标准。真实项目还要考虑公开 slug、版本前缀、分页和权限策略;本节只验证资源关系和更新语义。

保持依赖需要的参数名一致

文章依赖统一声明 post_id,作者依赖统一声明 author_id。嵌套路径也使用这两个名字,因此 valid_author_post() 可以组合两个已有依赖,再检查文章的 author_id 是否等于作者的 ID。

只验证两个 ID 各自存在是不够的:作者 10 和文章 2 可能都存在,但文章 2 属于作者 20。若直接返回文章,调用者就能通过替换路径中的作者 ID 绕过资源范围。示例在关系不匹配时返回 404,把它当作该嵌套资源不存在;具体项目也可以选择其他一致的授权响应,但必须测试并固定行为。

<!-- file: ch04_paths/resource_paths_demo.py -->

from __future__ import annotations


from typing import Annotated, Any


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




class AuthorResponse(BaseModel):
    id: int
    name: str




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




class PostReplace(BaseModel):
    """PUT 使用的全部可编辑字段。服务端字段不由客户端替换。"""


    title: str = Field(min_length=1, max_length=80)
    content: str = Field(min_length=1, max_length=2_000)




class PostPatch(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=80)
    content: str | None = Field(default=None, min_length=1, max_length=2_000)




AUTHORS: dict[int, dict[str, Any]] = {
    10: {"id": 10, "name": "Alice"},
    20: {"id": 20, "name": "Bob"},
}


POSTS: list[dict[str, Any]] = [
    {
        "id": 1,
        "title": "Alice 的第一篇文章",
        "content": "文章内容一。",
        "author_id": 10,
    },
    {
        "id": 2,
        "title": "Bob 的第一篇文章",
        "content": "文章内容二。",
        "author_id": 20,
    },
]




async def valid_post_id(post_id: int) -> dict[str, Any]:
    for post in POSTS:
        if post["id"] == post_id:
            return post
    raise HTTPException(status_code=404, detail="文章不存在")




async def valid_author_id(author_id: int) -> dict[str, Any]:
    author = AUTHORS.get(author_id)
    if author is None:
        raise HTTPException(status_code=404, detail="作者不存在")
    return author




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




async def valid_author_post(
    author: Author,
    post: Post,
) -> dict[str, Any]:
    """同时确认作者、文章存在,并确认文章属于该作者。"""
    if post["author_id"] != author["id"]:
        raise HTTPException(status_code=404, detail="作者下不存在这篇文章")
    return post




AuthorPost = Annotated[dict[str, Any], Depends(valid_author_post)]
app = FastAPI(title="资源路径与依赖复用示例")




@app.get("/posts", response_model=list[PostResponse])
async def list_posts() -> list[dict[str, Any]]:
    return POSTS




@app.get("/posts/{post_id}", response_model=PostResponse)
async def get_post(post: Post) -> dict[str, Any]:
    return post




@app.get("/authors/{author_id}/posts", response_model=list[PostResponse])
async def list_author_posts(author: Author) -> list[dict[str, Any]]:
    return [post for post in POSTS if post["author_id"] == author["id"]]




@app.get(
    "/authors/{author_id}/posts/{post_id}",
    response_model=PostResponse,
)
async def get_author_post(post: AuthorPost) -> dict[str, Any]:
    return post




@app.put("/posts/{post_id}", response_model=PostResponse)
async def replace_post(
    replacement: PostReplace,
    post: Post,
) -> dict[str, Any]:
    post["title"] = replacement.title
    post["content"] = replacement.content
    return post




@app.patch(
    "/authors/{author_id}/posts/{post_id}",
    response_model=PostResponse,
)
async def patch_author_post(
    patch: PostPatch,
    post: AuthorPost,
) -> dict[str, Any]:
    changes = patch.model_dump(exclude_unset=True)
    if not changes:
        raise HTTPException(status_code=422, detail="至少提供一个要修改的字段")
    if any(value is None for value in changes.values()):
        raise HTTPException(status_code=422, detail="标题和正文不能为 null")
    post.update(changes)
    return post




def self_check() -> None:
    with TestClient(app) as client:
        all_posts = client.get("/posts")
        assert all_posts.status_code == 200
        assert len(all_posts.json()) == 2


        detail = client.get("/posts/1")
        assert detail.status_code == 200
        assert detail.json()["author_id"] == 10


        author_posts = client.get("/authors/10/posts")
        assert author_posts.status_code == 200
        assert [post["id"] for post in author_posts.json()] == [1]


        nested = client.get("/authors/10/posts/1")
        assert nested.status_code == 200


        relationship_mismatch = client.get("/authors/10/posts/2")
        assert relationship_mismatch.status_code == 404
        assert relationship_mismatch.json()["detail"] == "作者下不存在这篇文章"


        replaced = client.put(
            "/posts/1",
            json={"title": "完整替换后的标题", "content": "完整替换后的正文"},
        )
        assert replaced.status_code == 200
        assert replaced.json()["content"] == "完整替换后的正文"


        patched = client.patch(
            "/authors/10/posts/1",
            json={"title": "只修改标题"},
        )
        assert patched.status_code == 200
        assert patched.json()["title"] == "只修改标题"
        assert patched.json()["content"] == "完整替换后的正文"


        empty_patch = client.patch("/authors/10/posts/1", json={})
        assert empty_patch.status_code == 422


        null_patch = client.patch(
            "/authors/10/posts/1",
            json={"title": None},
        )
        assert null_patch.status_code == 422


    print("resource_paths_demo self-check passed")




if __name__ == "__main__":
    self_check()

运行检查:

python ch04_paths/resource_paths_demo.py

PUT 和 PATCH 的边界

本示例把 PUT 定义为替换全部可编辑字段,所以标题和正文都必须提供;服务端维护的 idauthor_id 不接受客户端替换。PATCH 允许只传标题或只传正文,代码通过 model_dump(exclude_unset=True)区分“没有传字段”和字段的显式值。

这不是为了规定所有项目的唯一语义,而是让团队先选定并测试一种可理解的契约。本例不允许标题和正文为空,所以显式传入 null 会返回 422。若项目把 null 定义为清空字段,还要让模型类型和更新逻辑区分“未传”和“传入 null”;不能只把所有字段都设成可选,就声称 PATCH 语义完整。

设计嵌套路由时检查真实关系

嵌套路径最容易遗漏的是关系检查。GET /authors/10/posts/2 中,作者 10 和文章 2 都单独存在,但文章 2 属于作者 20。valid_author_post() 先复用两条存在性依赖,再验证 post["author_id"] == author["id"],因此路由收到的 post 已经满足作者范围。

如果列表接口将来支持分页,分页参数应属于列表路由的查询参数,例如 /authors/10/posts?offset=0&limit=20;不要把它们塞进资源 ID 或请求体。分页、筛选和排序的边界属于接口契约,需结合数据库查询一起验证,不能只靠内存列表的切片来推断生产性能。

资料来源

4.3 理解请求内缓存与依赖执行方式
5.1 统一数据库命名与约束
温馨提示
下载编程狮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; }