小白python入门 - 42. 请求体与 Pydantic 模型

1. 本课定位:是什么、为何重要

上一课书签 API 已经能「读列表、读详情、删除」,参数来自路径和查询串。可是创建、更新资源时,客户端通常要发一整段结构化 JSON——标题、URL、是否收藏——而不是把长字段都塞进 Query。手写dict.get东一块西一块,很容易漏校验,也容易在响应里把内部字段一起泄漏出去。

本课引入Pydantic 模型与 FastAPI 的 Body 绑定:用类描述数据形状,框架负责解析、校验、生成文档;并用 Create / Update / Out 分模型,把「入库字段」和「对外字段」拆开。数据仍用内存存储。学完你应能 POST 创建、PATCH 部分更新,读懂 422 的loc,并确认internal_score不会出现在响应里。

概念一句话
请求体 BodyHTTP 正文里的载荷,API 里多为 JSON
模型 ModelBaseModel子类,字段 + 类型 + 约束
response_model规定响应长什么样,避免内部字段漏出
exclude_unset部分更新时只应用客户端真正传了的字段

为何重要:无边界模型 = 脏数据入库 + 敏感字段出站。

对比已学:

已学本课
Path/Query 简单类型Body 嵌套结构
内存里直接塞 dictCreate/Update/Out分模型
422 来自路径类型错422 也可来自 Body 校验失败
只有读删加上创建与部分更新

2. 本质:入站校验、出站过滤

可以把模型想成海关:入关检查护照字段全不全、格式对不对;出关只盖允许出境的章,内部备注条不给外人看。FastAPI 在进路由函数前完成入站解析,在返回时按response_model再滤一遍。

上一课失败分 404/422;本课 422 会更常见——空标题、非法 URL、类型不对都会在边界被拦住。先建立「边界合同」直觉,再拆三个模型,避免一个类打天下。

本质:JSON → 模型实例(失败 422)→ 业务用属性 → 按 Out 序列化响应。

客户端 JSON | v Pydantic 校验 ----失败---> 422 + loc/msg | 成功 v 路由函数业务逻辑 | v response_model 过滤 -----> 响应 JSON

3. 约束与常见坑

模型用错比不用更危险:一个模型既当创建又当更新,会导致「更新必须传全字段」或「响应带回哈希」。部分更新若model_dump()不带exclude_unset,没传的字段可能变成 None 覆盖原值。

这一节把分模型、exclude_unset、response_model 过滤语义钉死。注意:过滤输出 ≠ 修改内存对象——internal_score仍可在 store 里,只是不返回。

约束:

  1. Create / Update / Out 分开——入库字段 ≠ 对外字段。
  2. HttpUrlField(min_length=...)等在边界拦住脏数据。
  3. 部分更新用exclude_unset=True,避免「没传的字段被写成 null」。
  4. 响应模型会丢掉未声明字段(如internal_score)。
  5. Content-Type 需为application/json(curl 要带 Header)。

常见坑:

现象正确直觉
一个模型打天下更新被迫传全字段;或泄露敏感字段拆 Create/Update/Out
更新用全量 dump未传字段变Nonemodel_dump(exclude_unset=True)
把内部 dict 当 API 契约字段漂移、泄密明确 Out 模型
忽略 422 的loc不知道哪错loc/msg
忘记 Content-Type解析失败或怪错JSON Header
以为 response_model 会删内存字段调试以为「存没了」只影响序列化输出

4. 三种模型对照

Create 描述「新建必须提供什么」;Update 字段全可选,服务「改一点」;Out 描述「客户端永远只能看见什么」。三者字段可以重叠,但职责不同。

书签域字段保持简单:title、url、is_favorite,外加 Out 的 id。内部internal_score只存在 store,不进 Out。

模型方向典型字段
BookmarkCreate客户端 → 服务端title, url, is_favorite
BookmarkUpdate客户端 → 服务端字段全可选
BookmarkOut服务端 → 客户端id + 安全字段
frompydanticimportBaseModel,Field,HttpUrlclassBookmarkCreate(BaseModel):title:str=Field(min_length=1,max_length=200)url:HttpUrl is_favorite:bool=FalseclassBookmarkUpdate(BaseModel):title:str|None=Field(default=None,min_length=1,max_length=200)url:HttpUrl|None=Noneis_favorite:bool|None=NoneclassBookmarkOut(BaseModel):id:inttitle:strurl:stris_favorite:bool
写法vs说明
参数body: BookmarkCreateQueryq: str前者吃 Body,后者吃查询串
response_model=Outreturn dict前者锁定对外形状
Field约束业务里if边界校验优先模型

5. 方法与能力按用途归组

不要求背完整 Pydantic 文档。入门会这几类能力即可:约束、导出、响应声明、嵌套(了解)、从 ORM 构造(下下课)。

表格当抽屉:需要时知道去哪找,而不是一次记光所有 API。

用途能力
约束FieldHttpUrl;邮箱类需额外包(了解)
导出model_dump()/model_dump(exclude_unset=True)
响应response_model=...status_code=201
从 ORMOut 上model_config = {"from_attributes": True}(第 44 课)
嵌套字段类型为另一个BaseModellist[...]

HttpUrl 注意:入站是 URL 类型;写入 dict/存储时常str(body.url)


6. POST 与 PATCH 语义

POST 创建:成功常用201,响应体是新建资源(Out)。PATCH 部分更新:只传要改的字段。PUT 全量替换本课不展开,避免和 PATCH 搅在一起。

状态码与模型课绑定:没有 Body 模型时,创建接口几乎写不稳。

方法典型用途本课状态码
POST/bookmarks新建201 + Out
PATCH/bookmarks/{id}改部分字段200 + Out
GET读(可加 response_model)200
DELETE204(上节)
修改前(无模型)修改后
手动 if 检查 titleField min_length
return 全量 dictresponse_model 滤掉内部字段
PATCH 覆盖成 Noneexclude_unset 只改传入项

7. 阅读 422 响应

校验失败时,FastAPI 返回的 JSON 里detail常是列表,每项有locmsgtype。会读loc就能快速定位是 body 的哪个字段错了。

这是联调基本功:不要只看「失败了」,要看「哪里失败」。

字段含义
loc错误位置,如["body","title"]
msg人话/校验信息
type错误类型代码

空 title 实验预期:状态码 422,loctitle


8. 落地场景:书签创建与收藏切换

场景仍是书签 API:新建一条、只改是否收藏、确认内部评分永不返回。内存 store 用自增 id,重启丢失——与 41 课一致。

把「防泄密」当成功能需求,而不是可选美化。

场景做法
新建书签POST + Create → 201 + Out
改是否收藏PATCH + Update(只传is_favorite
防泄密internal_score不进 Out
非法标题422,不进 store

9. 小步示例:response_model 过滤

用最小片段理解「内存有、响应无」。综合实践会把片段拼成完整项目。

item={"id":1,"title":"Example","url":"https://example.com","is_favorite":True,"internal_score":42,}# 若 response_model=BookmarkOut,响应不含 internal_score
位置internal_score
_store[1]可以有
HTTP 响应 JSON不应有

10. 环境准备

依赖与 41 课相同;本课增加schemas.py。Windows 推荐 Cygwin/WSL 跑 heredoc。

mkdir-p~/python-lab/src/day42/routerscd~/python-lab/src/day42 pipinstall'fastapi>=0.110''uvicorn[standard]>=0.27'

11. 综合实践:完整可运行脚本

写入 schemas、router、main,启动后用三条 curl:合法创建、非法 title、PATCH 只改收藏。对照表检查状态码与字段。

服务占前台时另开终端。Windows 用 Cygwin/WSL 或手建文件。

mkdir-p~/python-lab/src/day42/routerscd~/python-lab/src/day42cat>schemas.py<<'EOF' from pydantic import BaseModel, Field, HttpUrl class BookmarkCreate(BaseModel): title: str = Field(min_length=1, max_length=200) url: HttpUrl is_favorite: bool = False class BookmarkUpdate(BaseModel): title: str | None = Field(default=None, min_length=1, max_length=200) url: HttpUrl | None = None is_favorite: bool | None = None class BookmarkOut(BaseModel): id: int title: str url: str is_favorite: bool EOFcat>routers/bookmarks.py<<'EOF' from fastapi import APIRouter, HTTPException from schemas import BookmarkCreate, BookmarkOut, BookmarkUpdate router = APIRouter(prefix="/bookmarks", tags=["bookmarks"]) _store: dict[int, dict] = {} _next_id = 1 @router.get("", response_model=list[BookmarkOut]) def list_bookmarks(): return list(_store.values()) @router.post("", response_model=BookmarkOut, status_code=201) def create_bookmark(body: BookmarkCreate): global _next_id item = { "id": _next_id, "title": body.title, "url": str(body.url), "is_favorite": body.is_favorite, "internal_score": 42, } _store[_next_id] = item _next_id += 1 return item @router.get("/{bookmark_id}", response_model=BookmarkOut) def get_bookmark(bookmark_id: int): item = _store.get(bookmark_id) if not item: raise HTTPException(status_code=404, detail="bookmark not found") return item @router.patch("/{bookmark_id}", response_model=BookmarkOut) def update_bookmark(bookmark_id: int, body: BookmarkUpdate): item = _store.get(bookmark_id) if not item: raise HTTPException(status_code=404, detail="bookmark not found") data = body.model_dump(exclude_unset=True) if "url" in data and data["url"] is not None: data["url"] = str(data["url"]) item.update(data) return item @router.delete("/{bookmark_id}", status_code=204) def delete_bookmark(bookmark_id: int): if bookmark_id not in _store: raise HTTPException(status_code=404, detail="bookmark not found") del _store[bookmark_id] return None EOFcat>routers/__init__.py<<'EOF' EOF cat > main.py << 'EOF' from fastapi import FastAPI from routers import bookmarks app = FastAPI(title="Day42 Bookmark API", version="0.1.0") app.include_router(bookmarks.router) @app.get("/health") def health(): return {"status": "ok"} EOFuvicorn main:app--reload--host127.0.0.1--port8000

验证:

curl-s-XPOST http://127.0.0.1:8000/bookmarks\-H"Content-Type: application/json"\-d'{"title":"Example","url":"https://example.com","is_favorite":true}'curl-s-XPOST http://127.0.0.1:8000/bookmarks\-H"Content-Type: application/json"\-d'{"title":"","url":"https://example.com"}'curl-s-XPATCH http://127.0.0.1:8000/bookmarks/1\-H"Content-Type: application/json"\-d'{"is_favorite":false}'curl-shttp://127.0.0.1:8000/bookmarks/1

预期:

请求结果
合法 POST201;JSONinternal_score
空 title422;locbody/title
PATCH只改is_favorite,title 仍在
GET 详情仍无internal_score

关键语义:response_model过滤输出 ≠ 删除内存里的internal_score


12. 嵌套与列表(了解)

真实书签可能带 tags 列表或 owner 嵌套对象。入门知道「字段类型可以是 list 或另一个 BaseModel」即可,本课作业不强制嵌套。

列表响应可用response_model=list[BookmarkOut],综合实践已示范。

classTag(BaseModel):name:strclassBookmarkCreateNested(BaseModel):title:strurl:HttpUrl tags:list[Tag]=[]

13. 常见问答

Q:Create 和 Out 都有 title,为何还要两个类?
A:Out 需要 id;Create 不应让客户端指定 id;未来 Out 还可能隐藏更多字段。

Q:Update 全是 Optional 会不会太松?
A:PATCH 语义就是可选;可用业务规则要求「至少改一个字段」(进阶)。

Q:非法 URL 是 422 还是 400?
A:Pydantic/FastAPI 校验失败通常 422。

Q:能直接return body吗?
A:Create 没有 id;应构造带 id 的资源再按 Out 返回。


14. 自我检查清单

  • 能解释 Body 与 Query 的差别
  • 会写 Create/Update/Out 三个模型
  • 会 POST 201 + response_model
  • 会 PATCH + exclude_unset
  • 会读 422 的 loc
  • 确认 internal_score 不出现在响应
  • curl 带 Content-Type: application/json

15. 与前后课衔接

关系
41Path/Query/读删 → 本课 Body 写
43模型稳定 → 依赖与配置分层
44dict store → ORM;Out 加 from_attributes

总结

带走:Body 用模型校验;Create/Update/Out 分离;422 读 loc;response_model 防泄密;部分更新 exclude_unset。模型是边界合同,不是数据库表的镜像。

  • 请求体用 Pydantic 在边界校验。
  • Create / Update / Out 分模型,避免一个类打天下。
  • response_model锁定对外形状,过滤内部字段。
  • PATCH 用model_dump(exclude_unset=True)做部分更新。
  • 校验失败读loc/msg;空 title、坏 URL 多为 422。
  • HttpUrl入库时常转为str;内存仍可有内部字段。

小练笔

先做再看答案。可选实践:故意漏掉 url 字段,观察 422 loc。

题 1

response_model=BookmarkOut的作用?

题 2

为何密码哈希不该出现在 UserOut?

题 3

exclude_unset=True解决什么问题?

题 4

空 title 更可能得到 404 还是 422?

题 5

为「只改 url」设计 Update 请求 JSON 示例。

题 6

POST 创建成功更常见状态码?

A. 200 B. 201 C. 204

题 7

判断:response_model 会从数据库/内存里物理删除未声明字段。

题 8

curl POST JSON 时为什么常需要Content-Type: application/json

题 9(可选实践)

POST 一条合法书签后,响应 JSON 中搜索internal_score,应找不到。

题 10

Create 模型里应不应该包含id字段让客户端指定?为什么?


小练笔参考答案

题 1

按 Out 过滤/校验响应并写入 OpenAPI。

题 2

敏感内部数据,返回即泄露。

题 3

部分更新时只应用客户端真正传入的字段。

题 4

422

题 5

{"url":"https://new.example"}(其它字段不传)。

题 6

B

题 7

(主要影响序列化输出)

题 8

声明正文是 JSON,便于框架正确解析 Body。

题 9

以你运行为准;响应不应出现该键。

题 10

一般不应;id 由服务端分配,避免冲突与伪造。(合理即可)