3.2 分离创建更新与响应模型
分离创建更新与响应模型
创建、局部更新和输出是三个不同的契约。创建文章需要标题和正文,PATCH 只需要接收实际要改的字段,响应还要带上服务端生成的 ID、作者和时间。让三种操作共用一个“大而全”的模型,通常会把服务端字段暴露成可写字段,也会让部分更新无法区分“没有提交”和“明确要清空”。
为三种操作各写最小模型
下面的例子故意不建立复杂的模型继承树。三个模型的字段有少量重复,但每个模型的读写边界一眼可见:
| 模型 | 调用方可以提交或接收的字段 | 不应由调用方决定的字段 |
|---|---|---|
PostCreate |
标题、正文、状态、摘要 | ID、作者、创建时间、内部备注 |
PostUpdate |
要修改的标题、正文、状态、摘要 | ID、作者、创建时间 |
PostResponse |
服务端返回的完整公开文章 | 内部备注等内部字段 |
请求模型设置 extra="forbid",因此客户端尝试提交 author_id 或 created_at 时会直接得到输入错误,而不会静默混入业务数据。是否拒绝额外字段也可以作为团队约定,但必须明确,不能把安全边界交给默认行为。
<!-- file: ch03_contracts/create_update_response.py -->
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field
class ArticleStatus(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
class PostCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(min_length=1, max_length=120)
content: str = Field(min_length=1, max_length=10_000)
status: ArticleStatus = ArticleStatus.DRAFT
summary: str | None = Field(default=None, max_length=240)
class PostUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
# 缺少 title 表示“不修改”;显式 null 由业务规则决定是否允许。
title: str | None = Field(default=None, min_length=1, max_length=120)
content: str | None = Field(default=None, min_length=1, max_length=10_000)
status: ArticleStatus | None = None
# summary 允许用 null 清空。
summary: str | None = Field(default=None, max_length=240)
class PostResponse(BaseModel):
id: int
title: str
content: str
status: ArticleStatus
summary: str | None
author_id: int
created_at: datetime
app = FastAPI(title="创建、更新与响应模型")
POSTS: dict[int, dict[str, object]] = {}
@app.post("/posts", response_model=PostResponse, status_code=201)
def create_post(payload: PostCreate) -> dict[str, object]:
post_id = max(POSTS, default=0) + 1
record: dict[str, object] = {
"id": post_id,
**payload.model_dump(),
"author_id": 7,
"created_at": datetime.now(timezone.utc),
# 这个字段可以存在于内部记录,但不在 PostResponse 中。
"internal_note": "仅供审核使用",
}
POSTS[post_id] = record
return record
@app.patch("/posts/{post_id}", response_model=PostResponse)
def update_post(post_id: int, payload: PostUpdate) -> dict[str, object]:
record = POSTS.get(post_id)
if record is None:
raise HTTPException(status_code=404, detail="文章不存在")
changes = payload.model_dump(exclude_unset=True)
if any(
value is None and field != "summary"
for field, value in changes.items()
):
raise HTTPException(status_code=422, detail="标题、正文和状态不能清空")
record.update(changes)
return record
def self_check() -> None:
POSTS.clear()
with TestClient(app) as client:
created = client.post(
"/posts",
json={
"title": "模型边界",
"content": "创建请求不负责提供作者和时间。",
},
)
created_body = created.json()
assert created.status_code == 201
assert set(created_body) == {
"id",
"title",
"content",
"status",
"summary",
"author_id",
"created_at",
}
assert "internal_note" not in created_body
assert datetime.fromisoformat(created_body["created_at"]).tzinfo is not None
# 省略 title:只修改 summary,title 保持原值。
patched = client.patch("/posts/1", json={"summary": None})
assert patched.status_code == 200
assert patched.json()["title"] == "模型边界"
assert patched.json()["summary"] is None
# 显式 null 会进入 changes;标题、正文和状态都不允许清空。
for field in ("title", "content", "status"):
null_value = client.patch("/posts/1", json={field: None})
assert null_value.status_code == 422
# 服务端字段不属于客户端写入契约。
forbidden = client.patch("/posts/1", json={"author_id": 99})
assert forbidden.status_code == 422
# model_dump(mode="json") 将枚举和 datetime 变成 JSON 兼容值。
public = PostResponse.model_validate(POSTS[1])
json_ready = public.model_dump(mode="json")
assert json_ready["status"] == "draft"
assert isinstance(json_ready["created_at"], str)
print("create_update_response self-check passed")
if __name__ == "__main__":
self_check()
在保存文件的目录运行:
python ch03_contracts/create_update_response.py
预期输出为 create_update_response self-check passed。TestClient 检查了响应字段过滤、时区日期、PATCH 的两种“未提交/显式空值”语义和额外字段拒绝。
PATCH 的 exclude_unset 解决什么问题
PostUpdate 中的字段都有默认值 None,这样 JSON 中可以不提供任何更新字段,但单看模型实例无法知道 None 是默认填入的,还是调用方显式传了 null。Pydantic v2 的 model_dump(exclude_unset=True) 会根据模型的字段设置记录保留哪些键:
PostUpdate(content="新正文").model_dump(exclude_unset=True)
## {"content": "新正文"}
PostUpdate(summary=None).model_dump(exclude_unset=True)
## {"summary": None}
因此更新服务可以只遍历返回字典中的字段,保留未提交的原值,同时根据字段规则决定 null 是否代表清空。对于不能被清空的标题、正文和状态,示例把显式 null 转成 422;对于允许清空的摘要,则把 None 写回记录。数据库更新时还应使用同一组变更字段,避免接口层和持久化层产生不同语义。
如果接口语义是完整替换,应设计单独的 PUT 模型,让所有必填字段都必须出现;如果是局部修改,才使用 PATCH 模型和 exclude_unset=True。不要只根据 HTTP 方法名称猜测行为,要在文档、校验和服务实现中保持一致。
响应模型是输出过滤器
response_model=PostResponse 会让 FastAPI 按响应模型校验并序列化返回值,同时只输出模型声明的字段。示例内部记录中的 internal_note 没有出现在 HTTP 响应中,但这不等于可以把任何敏感字段先放进响应对象再依赖过滤;日志、异常、调试接口和其他返回路径仍可能泄露它们。更稳妥的做法是从查询层就选择公开字段,响应模型再作为最后一道契约检查。
日期时间字段应使用带时区的 datetime。例子用 datetime.now(timezone.utc) 生成 UTC 时间,Pydantic 的 JSON 模式会输出 ISO 8601 字符串。model_dump(mode="json") 适合得到 JSON 兼容字典;需要完整 JSON 字符串时再使用 model_dump_json()。不要为了把 datetime 变成字符串就给所有模型加一层自定义基类。
只有统一格式确实跨越很多模型、内建序列化不满足要求时,才考虑 field_serializer。如果业务要求的是“把无时区时间解释为 UTC”或“把带时区时间转换到某个显示时区”,这两个动作含义不同:前者是在补充缺失信息,后者是在改变表示时区,必须先确定数据来源和产品约定。
创建、更新和响应模型的检查清单
- 创建模型是否只包含客户端能够决定的字段?
- PATCH 模型是否能区分省略字段和显式
null? - 服务端生成的 ID、作者、时间是否由服务层填充?
- 响应模型是否明确排除了内部备注、密码、令牌和调试信息?
- 日期是否始终带有明确时区,序列化结果是否与客户端契约一致?
资料来源
- 主要参考:fastapi-best-practices 中文 README 的 Pydantic 模型与响应序列化主题。本节保留“输入模型和输出模型分工”的实践方向,按 Pydantic v2 的
model_dump和 FastAPI 响应模型行为重写示例。 - 官方文档:FastAPI 响应模型、Pydantic 序列化、Pydantic 模型配置。

免费 AI IDE


更多建议: