4.4 用一致的资源路径支持依赖复用
用一致的资源路径支持依赖复用
路径、HTTP 方法和参数名称共同表达接口的资源关系。文章列表、文章详情和某位作者的文章列表虽然返回的数据相近,但它们的资源范围不同;把这些关系写清楚,依赖才容易复用,嵌套资源也才有机会进行完整的归属检查。
本节围绕 posts 和 authors 设计最小路由集:
| 路由 | 含义 | 失败重点 |
|---|---|---|
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 定义为替换全部可编辑字段,所以标题和正文都必须提供;服务端维护的 id、author_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 或请求体。分页、筛选和排序的边界属于接口契约,需结合数据库查询一起验证,不能只靠内存列表的切片来推断生产性能。
资料来源
- 主要参考:fastapi-best-practices 中文 README 的“遵循 REST 规范”部分。本节保留统一路径参数以复用依赖、检查嵌套资源归属的主题,并补充 PUT/PATCH 的可运行对照。
- 官方参考:FastAPI 路径参数、FastAPI 依赖项。