小白python入门 - 42. 请求体与 Pydantic 模型
1. 本课定位:是什么、为何重要
上一课书签 API 已经能「读列表、读详情、删除」,参数来自路径和查询串。可是创建、更新资源时,客户端通常要发一整段结构化 JSON——标题、URL、是否收藏——而不是把长字段都塞进 Query。手写
dict.get东一块西一块,很容易漏校验,也容易在响应里把内部字段一起泄漏出去。本课引入Pydantic 模型与 FastAPI 的 Body 绑定:用类描述数据形状,框架负责解析、校验、生成文档;并用 Create / Update / Out 分模型,把「入库字段」和「对外字段」拆开。数据仍用内存存储。学完你应能 POST 创建、PATCH 部分更新,读懂 422 的
loc,并确认internal_score不会出现在响应里。
| 概念 | 一句话 |
|---|---|
| 请求体 Body | HTTP 正文里的载荷,API 里多为 JSON |
| 模型 Model | BaseModel子类,字段 + 类型 + 约束 |
| response_model | 规定响应长什么样,避免内部字段漏出 |
| exclude_unset | 部分更新时只应用客户端真正传了的字段 |
为何重要:无边界模型 = 脏数据入库 + 敏感字段出站。
对比已学:
| 已学 | 本课 |
|---|---|
| Path/Query 简单类型 | Body 嵌套结构 |
| 内存里直接塞 dict | Create/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 过滤 -----> 响应 JSON3. 约束与常见坑
模型用错比不用更危险:一个模型既当创建又当更新,会导致「更新必须传全字段」或「响应带回哈希」。部分更新若
model_dump()不带exclude_unset,没传的字段可能变成 None 覆盖原值。这一节把分模型、exclude_unset、response_model 过滤语义钉死。注意:过滤输出 ≠ 修改内存对象——
internal_score仍可在 store 里,只是不返回。
约束:
- Create / Update / Out 分开——入库字段 ≠ 对外字段。
HttpUrl、Field(min_length=...)等在边界拦住脏数据。- 部分更新用
exclude_unset=True,避免「没传的字段被写成 null」。 - 响应模型会丢掉未声明字段(如
internal_score)。 - Content-Type 需为
application/json(curl 要带 Header)。
常见坑:
| 坑 | 现象 | 正确直觉 |
|---|---|---|
| 一个模型打天下 | 更新被迫传全字段;或泄露敏感字段 | 拆 Create/Update/Out |
| 更新用全量 dump | 未传字段变None | model_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: BookmarkCreate | Queryq: str | 前者吃 Body,后者吃查询串 |
response_model=Out | 裸return dict | 前者锁定对外形状 |
Field约束 | 业务里if | 边界校验优先模型 |
5. 方法与能力按用途归组
不要求背完整 Pydantic 文档。入门会这几类能力即可:约束、导出、响应声明、嵌套(了解)、从 ORM 构造(下下课)。
表格当抽屉:需要时知道去哪找,而不是一次记光所有 API。
| 用途 | 能力 |
|---|---|
| 约束 | Field、HttpUrl;邮箱类需额外包(了解) |
| 导出 | model_dump()/model_dump(exclude_unset=True) |
| 响应 | response_model=...、status_code=201 |
| 从 ORM | Out 上model_config = {"from_attributes": True}(第 44 课) |
| 嵌套 | 字段类型为另一个BaseModel或list[...] |
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 |
| DELETE | 删 | 204(上节) |
| 修改前(无模型) | 修改后 |
|---|---|
| 手动 if 检查 title | Field min_length |
| return 全量 dict | response_model 滤掉内部字段 |
| PATCH 覆盖成 None | exclude_unset 只改传入项 |
7. 阅读 422 响应
校验失败时,FastAPI 返回的 JSON 里
detail常是列表,每项有loc、msg、type。会读loc就能快速定位是 body 的哪个字段错了。这是联调基本功:不要只看「失败了」,要看「哪里失败」。
| 字段 | 含义 |
|---|---|
loc | 错误位置,如["body","title"] |
msg | 人话/校验信息 |
type | 错误类型代码 |
空 title 实验预期:状态码 422,loc含title。
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预期:
| 请求 | 结果 |
|---|---|
| 合法 POST | 201;JSON无internal_score |
| 空 title | 422;loc含body/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. 与前后课衔接
| 课 | 关系 |
|---|---|
| 41 | Path/Query/读删 → 本课 Body 写 |
| 43 | 模型稳定 → 依赖与配置分层 |
| 44 | dict 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 由服务端分配,避免冲突与伪造。(合理即可)