3.2 分离创建更新与响应模型

2026-09-05 15:06 更新

分离创建更新与响应模型

创建、局部更新和输出是三个不同的契约。创建文章需要标题和正文,PATCH 只需要接收实际要改的字段,响应还要带上服务端生成的 ID、作者和时间。让三种操作共用一个“大而全”的模型,通常会把服务端字段暴露成可写字段,也会让部分更新无法区分“没有提交”和“明确要清空”。

为三种操作各写最小模型

下面的例子故意不建立复杂的模型继承树。三个模型的字段有少量重复,但每个模型的读写边界一眼可见:

模型 调用方可以提交或接收的字段 不应由调用方决定的字段
PostCreate 标题、正文、状态、摘要 ID、作者、创建时间、内部备注
PostUpdate 要修改的标题、正文、状态、摘要 ID、作者、创建时间
PostResponse 服务端返回的完整公开文章 内部备注等内部字段

请求模型设置 extra="forbid",因此客户端尝试提交 author_idcreated_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 passedTestClient 检查了响应字段过滤、时区日期、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”或“把带时区时间转换到某个显示时区”,这两个动作含义不同:前者是在补充缺失信息,后者是在改变表示时区,必须先确定数据来源和产品约定。

创建、更新和响应模型的检查清单

  1. 创建模型是否只包含客户端能够决定的字段?
  2. PATCH 模型是否能区分省略字段和显式 null
  3. 服务端生成的 ID、作者、时间是否由服务层填充?
  4. 响应模型是否明确排除了内部备注、密码、令牌和调试信息?
  5. 日期是否始终带有明确时区,序列化结果是否与客户端契约一致?

资料来源

以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号