[ { "id": "01", "group": "起步与 HTTP", "title": "启动第一个 FastAPI 服务", "summary": "建立 api-lab 项目,让 /health 返回 JSON,并通过交互文档发出第一次请求。", "objectives": [ "为项目创建独立的 Python 环境", "解释 main:app、端口与 --reload 的作用", "分别在浏览器和 /docs 验证同一个接口" ], "concepts": [ { "title": "服务进程与路由函数", "body": "普通脚本运行结束后会退出。API 服务由 Uvicorn 持续监听本机端口,收到 HTTP 请求后调用 FastAPI 中匹配的方法与路径。@app.get('/health') 注册的是 GET /health;函数的返回值会被序列化为 JSON 响应。" }, { "title": "虚拟环境决定从哪里导入依赖", "body": "同一台电脑可以有多个 Python。虚拟环境把当前项目的解释器入口和第三方包放在 .venv 中。用 python -m pip 安装,能使 pip 与当前 python 指向同一环境;编辑器也应选择 .venv 中的解释器。" }, { "title": "开发服务器的几个参数", "body": "main:app 表示从当前目录的 main.py 导入 app 对象。127.0.0.1 是本机地址,8000 是默认端口。--reload 会在代码变化后重启开发进程,重启也会清空之后课程中的内存数据。" } ], "steps": [ { "title": "建立目录和虚拟环境", "body": "准备 Python 3.11 或更新版本。以下两组命令按系统选择一组。激活只影响当前终端;以后重新打开终端时,要先切换到 api-lab 再激活。", "lang": "bash", "code": "# Windows PowerShell\nmkdir api-lab\ncd api-lab\npy -3 -m venv .venv\n.\\.venv\\Scripts\\Activate.ps1\n\n# macOS / Linux\nmkdir api-lab\ncd api-lab\npython3 -m venv .venv\nsource .venv/bin/activate\n\n# 激活后检查解释器路径,结果应包含 api-lab/.venv\npython -c \"import sys; print(sys.executable)\"" }, { "title": "安装依赖并记录环境", "body": "创建 requirements.txt,写入下面三行。然后在激活后的终端运行 python -m pip install -r requirements.txt。本模块使用 Pydantic v2 的 model_dump() 写法。安装成功后运行 python -m pip freeze > requirements-lock.txt,保留当前环境实际使用的版本;另一个环境可从这个锁定文件安装。 如果直接运行下载包,请使用包内已经锁定版本的 requirements.txt。", "filename": "requirements.txt", "lang": "bash", "code": "fastapi>=0.115,<1\nuvicorn[standard]>=0.30,<1\npydantic>=2,<3" }, { "title": "编写健康检查接口", "body": "在 api-lab 根目录创建 main.py。FastAPI 实例只创建一次。路由装饰器紧接在函数定义上方,返回值使用 Python 字典,JSON 编码由框架完成。", "filename": "main.py", "lang": "python", "code": "from fastapi import FastAPI\n\napp = FastAPI(title=\"API Lab\", version=\"0.1.0\")\n\n@app.get(\"/health\", tags=[\"system\"])\ndef health():\n return {\"status\": \"ok\"}" }, { "title": "启动服务并观察终端", "body": "在 main.py 所在目录运行以下命令。终端保持运行状态是正常现象;看到 Application startup complete 后再访问浏览器。服务运行期间用另一个终端执行请求,停止服务使用 Ctrl+C。", "lang": "bash", "code": "python -m uvicorn main:app --reload\n# 默认访问地址:http://127.0.0.1:8000\n# 8000 被占用时,可加 --port 8001" }, { "title": "通过浏览器和交互文档验收", "body": "先打开 http://127.0.0.1:8000/health,确认显示 {\"status\":\"ok\"}。再打开 /docs,展开 GET /health → Try it out → Execute,查看状态码、响应头和响应体。最后把 main.py 中的 ok 改为 ready 并保存,观察终端重载日志和新响应,完成后改回 ok。", "lang": "http", "code": "GET /health HTTP/1.1\nHost: 127.0.0.1:8000\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"status\":\"ok\"}" } ], "checks": [ "终端的 sys.executable 路径包含 api-lab/.venv。", "GET /health 返回 200 和 {\"status\":\"ok\"};访问未定义的 / 返回 404,这不表示服务未启动。", "/docs 中出现 GET /health,点击 Execute 后能看到真实响应。" ], "pitfalls": [ { "symptom": "ModuleNotFoundError: No module named 'fastapi'", "cause": "安装依赖和启动服务使用了不同解释器。", "fix": "激活 .venv,检查 sys.executable,再执行 python -m pip install -r requirements.txt。" }, { "symptom": "Could not import module 'main'", "cause": "当前目录没有 main.py,或者文件中存在语法错误。", "fix": "切换到 api-lab 根目录,确认文件名为 main.py,阅读终端中最早出现的 Python 错误。" }, { "symptom": "PowerShell 阻止 Activate.ps1 运行", "cause": "当前执行策略限制脚本运行。", "fix": "无需修改全局策略,可直接使用 .\\.venv\\Scripts\\python.exe -m pip install -r requirements.txt,以及 .\\.venv\\Scripts\\python.exe -m uvicorn main:app --reload。" } ], "exercise": { "task": "添加 GET /version,返回 {\"service\":\"api-lab\",\"version\":\"0.1.0\"},并在 /docs 中执行一次。", "hint": "保留已有 app,在另一个函数上方注册新的 GET 路由。", "answer": "在 main.py 末尾添加:\n@app.get('/version')\ndef version():\n return {'service': 'api-lab', 'version': '0.1.0'}\n\n保存后访问 /version,应返回 200;刷新 /docs 后应同时看到 /health 与 /version。" }, "refs": [ { "label": "Python:venv", "url": "https://docs.python.org/3/library/venv.html" }, { "label": "FastAPI:First Steps", "url": "https://fastapi.tiangolo.com/tutorial/first-steps/" }, { "label": "FastAPI:手动运行服务器", "url": "https://fastapi.tiangolo.com/deployment/manually/" } ] }, { "id": "02", "group": "起步与 HTTP", "title": "读懂一次 HTTP 请求与响应", "summary": "拆开方法、路径、查询参数、请求头和请求体,亲手发送 GET 与 POST。", "demo": "http", "objectives": [ "在请求中指出方法、URL、请求头和请求体的位置", "区分 Content-Type、Accept 与 HTTP 状态码", "用 /docs 和命令行复现同一条请求" ], "concepts": [ { "title": "请求由几个独立部分组成", "body": "以 POST /notes?source=lesson 为例:POST 是方法,/notes 是路径,source=lesson 是查询参数。请求头携带内容类型等元信息,请求体携带笔记数据。下面的 HTTP/1.1 文本用于辨认字段;浏览器也可能使用其他 HTTP 版本,业务含义一致。" }, { "title": "Content-Type 描述实际内容", "body": "请求头 Content-Type: application/json 表示请求体按 JSON 编码;Accept: application/json 表示客户端期望的响应格式。服务器仍按接口定义产生响应,并非只改 Accept 就会自动得到另一种格式。JSON 键和字符串使用双引号,布尔值使用 true / false。" }, { "title": "响应状态与业务字段分开读取", "body": "响应先给出 HTTP 状态码,再给出响应头和响应体。200 表示本次 HTTP 操作成功,422 表示请求数据未通过当前接口校验。把 {\"code\":404} 放在一个 200 响应里,不会把 HTTP 状态变成 404。" } ], "steps": [ { "title": "观察最小 GET 请求", "body": "保持第 01 课服务运行。在 /docs 执行 /health,同时查看本页的请求拆解演示。把方法、路径、Host 和响应体分别定位;GET /health 不需要请求体。", "lang": "http", "code": "GET /health HTTP/1.1\nHost: 127.0.0.1:8000\nAccept: application/json\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"status\":\"ok\"}" }, { "title": "添加临时的 POST 回显接口", "body": "把以下代码追加到 main.py。这个临时接口只读取请求体并返回,不保存笔记,也不宣称创建成功。第 03 课会用带类型的模型替换它。这里先借助 Request 观察 HTTP 数据,异步语法在后续课程集中练习。", "filename": "main.py", "lang": "python", "code": "from fastapi import Request\n\n@app.post(\"/notes\")\nasync def inspect_note(request: Request):\n data = await request.json()\n return {\"received\": data}" }, { "title": "准备 JSON 文件并发送 POST", "body": "在 api-lab 中创建 note.json,使用 UTF-8 保存。macOS / Linux 执行 curl -i -X POST http://127.0.0.1:8000/notes -H \"Content-Type: application/json\" --data-binary @note.json;Windows PowerShell 使用 curl.exe,并把 @note.json 写成引号包围的 \"@note.json\"。-i 同时显示响应头。", "filename": "note.json", "lang": "json", "code": "{\n \"title\": \"first note\",\n \"content\": \"learn HTTP\",\n \"pinned\": false\n}" }, { "title": "对照原始请求与服务器结果", "body": "命令行应返回 200 和 received 对象。改动 note.json 中的 title 后再次发送,确认响应随之变化。在浏览器开发者工具 Network 中展开 /docs 发出的请求,也能查看 Method、Request Headers、Payload 和 Response。", "lang": "http", "code": "POST /notes HTTP/1.1\nHost: 127.0.0.1:8000\nContent-Type: application/json\n\n{\"title\":\"first note\",\"content\":\"learn HTTP\",\"pinned\":false}\n\nHTTP/1.1 200 OK\nContent-Type: application/json\n\n{\"received\":{\"title\":\"first note\",\"content\":\"learn HTTP\",\"pinned\":false}}" } ], "checks": [ "能说明查询参数属于 URL,请求体位于请求头之后,两者不是同一块数据。", "命令行 POST 返回 200,并在 received 中保留 note.json 的三个字段。", "浏览器直接打开 /notes 会发出 GET;当前只注册了 POST,因此应返回 405 Method Not Allowed。" ], "pitfalls": [ { "symptom": "PowerShell 提示无法识别 -H 参数", "cause": "使用了旧版 PowerShell 的 curl 别名。", "fix": "明确执行 curl.exe;或者在 /docs 中操作。" }, { "symptom": "请求体解析失败", "cause": "把 Python 字典写法当成 JSON,例如使用单引号或 False。", "fix": "使用 note.json 文件,字符串和字段名用双引号,布尔值写 true / false。" }, { "symptom": "浏览器地址栏打不开 POST 接口", "cause": "地址栏导航通常发送 GET,没有发送预期的 POST 和 JSON 请求体。", "fix": "使用 curl、/docs 或能够配置 HTTP 方法的客户端。" } ], "exercise": { "task": "把请求体的 title 改成 second note,再发送一次。记录响应的 HTTP 状态码、Content-Type 和 received.title;最后说明 Accept 和 Content-Type 的区别。", "hint": "使用 -i 查看响应头,不要只看最后一行 JSON。", "answer": "状态码为 200;响应 Content-Type 为 application/json;received.title 为 second note。请求 Content-Type 描述这次发送的数据格式,Accept 描述客户端希望接收的格式。" }, "refs": [ { "label": "RFC 9110:HTTP 消息与内容语义", "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-6" }, { "label": "RFC 8259:JSON 语法", "url": "https://www.rfc-editor.org/rfc/rfc8259" }, { "label": "FastAPI:直接使用 Request", "url": "https://fastapi.tiangolo.com/advanced/using-request-directly/" } ] }, { "id": "03", "group": "起步与 HTTP", "title": "从路径、查询参数和 JSON 中接收数据", "summary": "为笔记接口声明参数类型,让 FastAPI 完成解析、默认值填充和基础校验。", "objectives": [ "将 note_id 放在路径中,将筛选条件放在查询参数中", "用 Pydantic 模型接收 JSON 请求体", "通过 422 响应定位错误参数" ], "concepts": [ { "title": "路径标识对象,查询参数控制读取方式", "body": "/notes/12 中的 12 表示要操作的笔记;/notes?limit=10&pinned=true 中的参数表示读取条件。参数名出现在路由路径中时,FastAPI 将其识别为路径参数。未出现在路径中的简单类型参数通常来自查询字符串。" }, { "title": "类型声明参与运行时解析", "body": "HTTP 路径和查询字符串首先都是文本。note_id: int 要求它能解析为整数;limit: int = 10 提供默认值。无法转换的值会在进入路由函数前产生 422 响应,因此函数里不用重复写 int() 和大量类型判断。" }, { "title": "请求模型决定必填与默认值", "body": "继承 BaseModel 的 NoteCreate 声明 JSON 对象的结构。title 没有默认值,所以必须发送;content 默认空字符串,pinned 默认 false。str | None 表示允许 null,不自动表示可以省略;若要允许省略,还需写默认值,例如 = None。" } ], "steps": [ { "title": "声明创建笔记的输入模型", "body": "在 main.py 的路由定义之前添加模型。删除第 02 课的 inspect_note 路由函数及其装饰器,随后用第 04 步的新 POST /notes 替换,避免同一方法和路径出现多个处理函数。", "filename": "main.py", "lang": "python", "code": "from pydantic import BaseModel\n\nclass NoteCreate(BaseModel):\n title: str\n content: str = \"\"\n pinned: bool = False" }, { "title": "添加路径参数", "body": "添加 GET /notes/{note_id}。本节先回显解析后的编号,暂不查询存储。依次请求 /notes/12 与 /notes/abc,比较状态码;整数解析失败时,错误位置会包含 path 和 note_id。", "filename": "main.py", "lang": "python", "code": "@app.get(\"/notes/{note_id}\")\ndef inspect_note_id(note_id: int):\n return {\"note_id\": note_id}" }, { "title": "添加查询参数和范围", "body": "添加 GET /notes。Annotated 把 Python 类型和 FastAPI 的参数规则写在一起。limit 默认为 10,只允许 1 到 100;pinned 未传时为 None,传 false 时是明确的 False,后续筛选中要区分这两种情况。", "filename": "main.py", "lang": "python", "code": "from typing import Annotated\nfrom fastapi import Query\n\n@app.get(\"/notes\")\ndef inspect_filters(\n limit: Annotated[int, Query(ge=1, le=100)] = 10,\n pinned: bool | None = None,\n):\n return {\"limit\": limit, \"pinned\": pinned}" }, { "title": "用类型模型替换原始请求体", "body": "定义新的 POST /notes。在 /docs 中仅发送 {\"title\":\"first note\"},观察缺省字段被补齐。本节仍是参数练习,不写入存储。model_dump() 把 Pydantic 模型转换为 Python 字典。", "filename": "main.py", "lang": "python", "code": "@app.post(\"/notes\")\ndef inspect_note_body(note: NoteCreate):\n return note.model_dump()" }, { "title": "建立有效值和无效值对照", "body": "在 /docs 连续完成以下四次调用,记录状态码与响应中的字段位置。缺少 title 的 POST 应在路由运行前失败。下一课会继续收紧 title 的长度与多余字段规则。", "lang": "http", "code": "GET /notes/12\n# 200: {\"note_id\":12}\n\nGET /notes?limit=5&pinned=false\n# 200: {\"limit\":5,\"pinned\":false}\n\nGET /notes?limit=0\n# 422,错误位置包含 [\"query\",\"limit\"]\n\nPOST /notes\nContent-Type: application/json\n\n{}\n# 422,错误位置包含 [\"body\",\"title\"]" } ], "checks": [ "GET /notes/12 返回数字 12,/notes/abc 返回 422。", "GET /notes 返回 {\"limit\":10,\"pinned\":null};?pinned=false 返回 false。", "POST /notes 只传 title 时补齐 content 和 pinned;缺少 title 返回 422。" ], "pitfalls": [ { "symptom": "把 note_id 放进查询字符串,得到列表而不是单条笔记", "cause": "GET /notes?note_id=12 匹配的是 GET /notes。本课列表路由没有声明 note_id,FastAPI 默认忽略这个额外查询参数,因此返回 200 和列表;它不会匹配 /notes/{note_id}。", "fix": "读取单条笔记时改为 GET /notes/12。先检查方法和路径,再检查查询参数;只有确实没有匹配路由或资源不存在时才会得到相应的 404。" }, { "symptom": "写了 title: str | None 后,省略 title 仍然报错", "cause": "允许 null 和允许省略是两个条件。", "fix": "如果业务允许省略,显式写默认值 = None;本例 title 应保持必填字符串。" }, { "symptom": "修改 POST /notes 后响应还像上一课", "cause": "旧路由没有移除,重复注册了相同方法和路径。", "fix": "搜索 @app.post('/notes'),只保留当前一个定义,保存并确认服务完成重载。" } ], "exercise": { "task": "为 GET /notes 添加 offset 查询参数,默认 0,只允许非负整数,并把解析结果加进响应。验证 offset=3 与 offset=-1。", "hint": "参考 limit 的 Annotated[int, Query(...)],使用 ge=0。", "answer": "在函数参数中加入 offset: Annotated[int, Query(ge=0)] = 0,并在返回字典中加入 'offset': offset。?offset=3 返回 200 且 offset 为 3;?offset=-1 返回 422,loc 包含 query、offset。" }, "refs": [ { "label": "FastAPI:路径参数", "url": "https://fastapi.tiangolo.com/tutorial/path-params/" }, { "label": "FastAPI:查询参数", "url": "https://fastapi.tiangolo.com/tutorial/query-params/" }, { "label": "FastAPI:请求体", "url": "https://fastapi.tiangolo.com/tutorial/body/" } ] }, { "id": "04", "group": "起步与 HTTP", "title": "用 Pydantic 定义输入和输出契约", "summary": "限制标题长度、拒绝拼错的字段,并通过 response_model 控制客户端能看到的数据。", "objectives": [ "用 Field 声明范围,用 extra='forbid' 发现字段拼写错误", "分开定义创建输入与接口输出模型", "区分请求校验失败与响应校验失败" ], "concepts": [ { "title": "模型把接口规则放到一起", "body": "类型描述数据形状,Field 描述长度或数值约束。本例 title 的长度为 1 到 120。extra='forbid' 会拒绝模型中未声明的字段,能及时发现 pinnd 这样的拼写错误;Pydantic 默认会忽略多余字段,因此需要显式配置。" }, { "title": "输入模型与输出模型承担不同职责", "body": "创建时客户端只提交 title、content、pinned;id 由服务端产生。NoteOut 在输入字段之外增加 id。response_model 会校验并筛选普通返回数据,未声明的内部字段不会进入响应;这也会出现在 /docs 的响应结构中。" }, { "title": "校验成功不代表全部业务规则成立", "body": "长度为 1 的字符串可能只是一个空格,类型与长度规则不会自动决定标题是否有意义。输入校验失败通常返回 422;如果路由返回的数据不符合输出模型,例如漏掉 id,则属于服务端代码错误,默认返回 500,并在终端记录具体原因。" } ], "steps": [ { "title": "收紧 NoteCreate 的输入规则", "body": "用下面定义替换原来的 NoteCreate,放在路由之前。保留其余路由。随后在 /docs 中分别发送空标题和拼错的 pinnd 字段,确认都得到 422。", "filename": "main.py", "lang": "python", "code": "from pydantic import BaseModel, ConfigDict, Field\n\nclass NoteCreate(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n\n title: str = Field(min_length=1, max_length=120)\n content: str = \"\"\n pinned: bool = False" }, { "title": "增加响应模型", "body": "在 NoteCreate 后添加 NoteOut。输出字段单独声明,避免把输入的 extra='forbid' 规则带入本节的输出过滤实验。id 在输出里必填;客户端创建时不能自行指定 id。", "filename": "main.py", "lang": "python", "code": "class NoteOut(BaseModel):\n id: int\n title: str\n content: str\n pinned: bool" }, { "title": "用 response_model 约束 POST 响应", "body": "替换 POST /notes 的装饰器和函数。本节的 id=1 仍是演示值,下一阶段才生成真实编号。返回字典故意增加 internal_source,查看 HTTP 响应时该字段应被过滤。", "filename": "main.py", "lang": "python", "code": "@app.post(\"/notes\", response_model=NoteOut)\ndef inspect_note_body(note: NoteCreate):\n return {\n \"id\": 1,\n **note.model_dump(),\n \"internal_source\": \"validation-lesson\",\n }" }, { "title": "分别制造输入与输出错误", "body": "先发送下面的空标题请求,确认 422 的错误位置包含 body、title。再临时删除返回字典中的 id,发送正常请求,观察 500 和终端中的响应校验异常。恢复 id 后再继续;不要把服务端输出错误改成客户端的 422。", "lang": "json", "code": "{\n \"title\": \"\",\n \"content\": \"title must not be empty\",\n \"pinned\": false\n}" }, { "title": "检查文档中的契约", "body": "刷新 /docs。展开 POST /notes 的 Schema,检查 title 的 minLength、maxLength,以及 NoteOut 的 id。发送 {\"title\":\"schema check\"} 后应返回下面四个字段。保存一份响应作为后续 CRUD 的验收样本。", "lang": "json", "code": "{\n \"id\": 1,\n \"title\": \"schema check\",\n \"content\": \"\",\n \"pinned\": false\n}" } ], "checks": [ "空 title 或额外 pinnd 字段均返回 422。", "正常创建演示返回 id、title、content、pinned 四个字段,不包含 internal_source。", "故意漏掉输出 id 时出现 500;恢复后为 200,当前尚未设置创建成功的 201。" ], "pitfalls": [ { "symptom": "空格标题通过了 min_length=1", "cause": "空格也是字符串字符,长度校验不执行去空格。", "fix": "根据业务要求加入 strip_whitespace 或字段校验器,去空格后再检查是否为空。" }, { "symptom": "response_model 没有过滤某个自定义响应", "cause": "直接返回 JSONResponse 等 Response 对象时,框架不会按普通返回值继续执行响应模型转换。", "fix": "本节返回字典或模型对象,让 FastAPI 使用 response_model 处理。" }, { "symptom": "把模型直接拼到字典时提示类型不匹配", "cause": "Pydantic 模型对象不等于普通字典。", "fix": "需要字典时调用 note.model_dump();不要使用旧教材中的 .dict() 作为本模块标准写法。" } ], "exercise": { "task": "拒绝只有空白的标题,并将 ' first note ' 保存为 'first note'。保持 120 字符上限。", "hint": "为 title 添加 mode='before' 的 field_validator,先去除字符串两端空白,再交给 Field 长度校验。", "answer": "从 pydantic 导入 field_validator,在 NoteCreate 内添加:\n@field_validator('title', mode='before')\n@classmethod\ndef strip_title(cls, value):\n return value.strip() if isinstance(value, str) else value\n\n只含空格会变为空字符串并返回 422;' first note ' 经解析后为 'first note'。" }, "refs": [ { "label": "Pydantic:Field 与字段约束", "url": "https://docs.pydantic.dev/latest/concepts/fields/" }, { "label": "Pydantic:验证器", "url": "https://docs.pydantic.dev/latest/concepts/validators/" }, { "label": "FastAPI:响应模型", "url": "https://fastapi.tiangolo.com/tutorial/response-model/" } ] }, { "id": "05", "group": "REST 接口", "title": "设计笔记资源与 HTTP 行为", "summary": "确定集合、单条资源、全量替换和删除的接口约定,再用请求验证方法与状态码。", "objectives": [ "用资源路径表达笔记集合和单条笔记", "区分 POST、PUT、PATCH、DELETE 的语义", "解释 200、201、204、404、405、422 与幂等性" ], "concepts": [ { "title": "先确定资源,再确定操作", "body": "本项目使用 /notes 表示笔记集合,/notes/{note_id} 表示一条笔记。读取用 GET,创建用 POST,完整替换用 PUT,删除用 DELETE。方法和路径共同确定接口,因此 GET /notes 与 POST /notes 可以共用路径。" }, { "title": "PUT 的完整替换需要明确输入规则", "body": "本课程的 PUT 要求 title、content、pinned 全部出现,替换指定笔记可编辑字段,id 保持不变。PATCH 通常表达局部更新,本模块暂不实现。若把创建模型直接用于 PUT,省略 content 会触发默认空字符串,容易被误认为保留旧值。" }, { "title": "幂等描述重复请求后的目标状态", "body": "重复相同 PUT,目标笔记应保持相同内容;重复 DELETE,目标笔记都处于不存在状态,第一次可以返回 204,第二次可以返回 404。幂等不要求每次响应完全相同。POST 创建通常每次产生新资源,不能假设自动重复发送不会多建一条。" }, { "title": "状态码也是接口契约", "body": "读取与更新成功使用 200,创建成功使用 201,删除成功且无响应体使用 204。找不到资源使用 404,路径存在但方法不支持使用 405,输入校验失败使用 422。4xx、5xx 响应不能只靠 JSON 业务字段表达,HTTP 状态也必须正确。" } ], "steps": [ { "title": "写下接口清单", "body": "以下是第 06 课要实现的目标契约。当前服务还处于参数练习阶段,不应把回显结果视为已保存。先在纸上或请求文件中列出方法、路径、输入和成功状态,后续逐项实现。", "filename": "notes-contract.http", "lang": "http", "code": "GET /notes # 查询集合,200,响应为数组\nPOST /notes # 创建,201,响应包含新 id\nGET /notes/{note_id} # 查询单条,200 或 404\nPUT /notes/{note_id} # 完整替换,200 或 404\nDELETE /notes/{note_id} # 删除,204 或 404" }, { "title": "用已有服务区分 404 与 405", "body": "在 /docs 执行 GET /health,应返回 200。然后命令行发送 POST /health,应返回 405,并检查 Allow 响应头;再 GET /missing-path,应返回 404。Windows 将下面 curl 改为 curl.exe。这两类错误都与 JSON 字段校验无关。", "lang": "bash", "code": "curl -i http://127.0.0.1:8000/health\ncurl -i -X POST http://127.0.0.1:8000/health\ncurl -i http://127.0.0.1:8000/missing-path" }, { "title": "为完整替换定义独立输入", "body": "在 main.py 的模型区添加 NoteReplace。三个字段均无默认值,确保调用方明确提交完整内容。在 Python 终端或单独脚本中使用下面示例验证;此时只定义模型,尚未注册 PUT 路由。", "filename": "main.py", "lang": "python", "code": "class NoteReplace(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n\n title: str = Field(min_length=1, max_length=120)\n content: str\n pinned: bool\n\n# 在独立 Python 终端中测试:\n# from main import NoteReplace\n# NoteReplace(title=\"updated\", content=\"\", pinned=False) # 通过\n# NoteReplace(title=\"updated\") # ValidationError:缺少两项" }, { "title": "准备替换与删除的验收请求", "body": "把完整请求体保存为 replace.json。下一课先创建一条笔记,记录真实 id,再执行两次同样 PUT 和两次 DELETE。不要写死假定 id 永远为 1;服务重载会影响当前内存。204 响应后不要继续解析 JSON。", "filename": "replace.json", "lang": "json", "code": "{\n \"title\": \"updated note\",\n \"content\": \"replace all editable fields\",\n \"pinned\": true\n}" } ], "checks": [ "GET /health 是 200;POST /health 是 405;不存在的路径是 404。", "NoteReplace 缺少 content 或 pinned 时验证失败,空 content 和 false 本身都合法。", "能解释 DELETE 第二次返回 404 仍然满足幂等,以及 204 为什么不能带 {\"ok\":true}。" ], "pitfalls": [ { "symptom": "PUT 请求只传 title,意外把 content 清空", "cause": "复用了带默认值的创建模型,未明确全量替换规则。", "fix": "使用所有可编辑字段均必填的 NoteReplace;如果需要局部更新,应另行设计 PATCH。" }, { "symptom": "客户端对成功 DELETE 调用 response.json() 后报错", "cause": "204 响应没有响应体。", "fix": "先判断状态码;遇到 204 直接按成功处理,不执行 JSON 解码。" }, { "symptom": "网络超时后重复 POST,出现两条笔记", "cause": "客户端没收到响应,并不等于服务器没创建资源。", "fix": "本地实验先查询列表核对;生产创建接口需根据业务设计幂等键等重复提交控制,不能直接假定 POST 可安全重试。" } ], "exercise": { "task": "评审三个接口:GET /delete-note?id=1、PUT /notes/1 只接收 pinned、DELETE /notes/1 返回 200 和 {\"deleted\":true}。给出本课程约定下的修改方案,并说明第三种设计是否违反 HTTP。", "hint": "分清 HTTP 协议允许的行为与课程选择的统一接口约定。", "answer": "第一种改为 DELETE /notes/1,GET 不用于用户请求的数据删除。第二种若只做局部修改应定义 PATCH,或者要求 PUT 传齐 title/content/pinned。第三种返回 200 和结果体在 HTTP 中可以成立;本课程统一选择 204 空体,因此需同时调整状态码和响应内容。" }, "refs": [ { "label": "RFC 9110:请求方法", "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-9" }, { "label": "RFC 9110:204 No Content", "url": "https://www.rfc-editor.org/rfc/rfc9110.html#section-15.3.5" }, { "label": "FastAPI:更新请求体", "url": "https://fastapi.tiangolo.com/tutorial/body-updates/" } ] }, { "id": "06", "group": "REST 接口", "title": "完成内存版笔记 CRUD", "summary": "把接口契约变成可运行程序,按创建、查询、替换、删除的顺序完成一次完整操作。", "objectives": [ "用字典保存笔记并生成不重复的进程内编号", "实现带校验与响应模型的笔记 REST 接口", "按真实创建结果串联后续请求并验证失败路径" ], "concepts": [ { "title": "请求之间需要共享状态", "body": "局部变量在函数调用结束后不用于保存下一次请求的数据。这里用模块级 notes 字典,键为整数 id,值为笔记字段。它只存在于当前 Python 进程内;重启、重载都会清空,多个 worker 也不会共享这份字典。" }, { "title": "编号与列表位置不是同一个概念", "body": "使用单独递增的 next_id 分配编号,删除笔记后不回退。不能用 len(notes)+1 生成 id,否则删掉中间一条后可能复用仍存在的编号。客户端应从创建响应读取 id,再构造单条资源路径。" }, { "title": "演示存储的运行条件", "body": "本节路由使用 async def,内部没有 await,字典读写在一次事件循环执行段中完成。以单个 Uvicorn worker 运行本地实验;不要在更新共享状态的中间加入等待操作。异步函数与等待的细节将在后面的并发课程中单独演示。" } ], "steps": [ { "title": "重建 main.py 的公共部分", "body": "先备份前几课的实验文件,再将 main.py 替换为本步骤代码,并按顺序追加第 2、3、4 步。不要同时保留临时回显路由。全部代码完成后再启动;本节不含 /version 等附加练习接口。", "filename": "main.py", "lang": "python", "code": "from typing import Annotated\n\nfrom fastapi import FastAPI, HTTPException, Query, Response\nfrom pydantic import BaseModel, ConfigDict, Field\n\napp = FastAPI(title=\"API Lab\")\n\nclass NoteCreate(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n title: str = Field(min_length=1, max_length=120)\n content: str = \"\"\n pinned: bool = False\n\nclass NoteReplace(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n title: str = Field(min_length=1, max_length=120)\n content: str\n pinned: bool\n\nclass NoteOut(BaseModel):\n id: int\n title: str\n content: str\n pinned: bool\n\nnotes: dict[int, dict] = {}\nnext_id = 1\n\n@app.get(\"/health\", tags=[\"system\"])\nasync def health():\n return {\"status\": \"ok\"}" }, { "title": "实现列表与创建", "body": "追加两个集合路由。pinned is not None 保证 false 也会触发筛选。创建成功后返回 201,并通过 Location 响应头给出新资源路径。next_id 在当前进程递增,保存时写入所有输入字段。", "filename": "main.py", "lang": "python", "code": "@app.get(\"/notes\", response_model=list[NoteOut], tags=[\"notes\"])\nasync def list_notes(\n limit: Annotated[int, Query(ge=1, le=100)] = 10,\n pinned: bool | None = None,\n):\n rows = list(notes.values())\n if pinned is not None:\n rows = [row for row in rows if row[\"pinned\"] == pinned]\n return rows[:limit]\n\n@app.post(\"/notes\", response_model=NoteOut, status_code=201, tags=[\"notes\"])\nasync def create_note(note: NoteCreate, response: Response):\n global next_id\n note_id = next_id\n next_id += 1\n row = {\"id\": note_id, **note.model_dump()}\n notes[note_id] = row\n response.headers[\"Location\"] = f\"/notes/{note_id}\"\n return row" }, { "title": "实现单条读取与全量替换", "body": "先定义统一查找函数。不存在时 raise HTTPException 会立即中断本次请求,下一课再统一其错误格式。PUT 创建全新的字段字典并保留路径 id,不把旧 content 或 pinned 合并进去。", "filename": "main.py", "lang": "python", "code": "def require_note(note_id: int) -> dict:\n if note_id not in notes:\n raise HTTPException(status_code=404, detail=\"笔记不存在\")\n return notes[note_id]\n\n@app.get(\"/notes/{note_id}\", response_model=NoteOut, tags=[\"notes\"])\nasync def get_note(note_id: int):\n return require_note(note_id)\n\n@app.put(\"/notes/{note_id}\", response_model=NoteOut, tags=[\"notes\"])\nasync def replace_note(note_id: int, note: NoteReplace):\n require_note(note_id)\n row = {\"id\": note_id, **note.model_dump()}\n notes[note_id] = row\n return row" }, { "title": "实现无响应体的删除", "body": "删除前先检查是否存在;返回 Response(status_code=204) 明确表示空响应。全部代码写好后运行 python -m uvicorn main:app --reload,打开 /docs,确认一个健康检查加五个笔记操作,共六个路由方法。", "filename": "main.py", "lang": "python", "code": "@app.delete(\"/notes/{note_id}\", status_code=204, tags=[\"notes\"])\nasync def delete_note(note_id: int):\n require_note(note_id)\n del notes[note_id]\n return Response(status_code=204)" }, { "title": "按顺序完成一轮 CRUD", "body": "使用 /docs:先 GET /notes 确認 [];POST 下列对象并记下实际 id;GET /notes/{id};PUT 提交 title='updated'、content=''、pinned=true;再 GET 确认内容已替换;DELETE 后再次 GET,确认 404。操作过程中不要修改代码触发重载。最后重新创建一条,检查编号没有因删除而回退。", "lang": "json", "code": "{\n \"title\": \"first CRUD\",\n \"content\": \"create, read, replace, delete\",\n \"pinned\": false\n}" } ], "checks": [ "空列表返回 200 和 [];创建返回 201,响应含整数 id,Location 指向 /notes/{id}。", "PUT 传齐字段时返回 200,id 保持不变;仅传 title 时返回 422。", "删除返回 204 且响应体为空;再 GET 或 DELETE 相同 id 均返回 404。", "创建两条并删除其中一条后,再创建不会覆盖另一条;服务重启后列表重新为空。" ], "pitfalls": [ { "symptom": "保存代码后刚创建的笔记找不到了", "cause": "--reload 重启了服务进程,内存字典被重新初始化。", "fix": "完成代码编辑后重新准备数据;验证一轮 CRUD 时不要中途保存服务端文件。" }, { "symptom": "筛选 pinned=false 返回全部数据", "cause": "使用 if pinned 判断是否筛选,False 被当成未提供条件。", "fix": "使用 if pinned is not None,再比较字段的布尔值。" }, { "symptom": "删除一条后,新笔记覆盖了旧笔记", "cause": "用 len(notes)+1 计算编号导致重复。", "fix": "独立维护递增 next_id,不因删除回退;本地练习保持单进程运行。" } ], "exercise": { "task": "在列表接口加入 offset>=0,先按 pinned 筛选,再返回 rows[offset:offset+limit]。创建三条笔记,第二条和第三条设为 pinned=true,验证 pinned=true&offset=1&limit=1 只返回第三条。", "hint": "分页应该作用在筛选后的结果上;把切片放在筛选后,limit 和 offset 均添加参数约束。", "answer": "添加 offset: Annotated[int, Query(ge=0)] = 0;保持 pinned 筛选顺序;把 return rows[:limit] 改为 return rows[offset:offset + limit]。按题目创建顺序,返回数组长度为 1,元素为第三条笔记;offset=-1 返回 422。" }, "refs": [ { "label": "FastAPI:响应状态码", "url": "https://fastapi.tiangolo.com/tutorial/response-status-code/" }, { "label": "FastAPI:直接返回 Response", "url": "https://fastapi.tiangolo.com/advanced/response-directly/" }, { "label": "FastAPI:处理错误", "url": "https://fastapi.tiangolo.com/tutorial/handling-errors/" } ] }, { "id": "07", "group": "REST 接口", "title": "返回可定位、格式一致的错误", "summary": "用 HTTPException 表达已知失败,统一 404、405 与 422 的响应结构,并保留字段位置。", "objectives": [ "在业务失败时抛出 HTTPException", "为 HTTPException 和 RequestValidationError 注册处理器", "保持 HTTP 状态码,同时返回稳定的错误结构" ], "concepts": [ { "title": "已知失败应在对应位置结束请求", "body": "查不到笔记是可以预期的业务结果,用 raise HTTPException(status_code=404, detail='笔记不存在') 结束当前处理。return HTTPException(...) 只是在返回一个对象,不能表达相同控制流程。未知编程错误需要修复,不能一律包装成 200。" }, { "title": "统一外层结构,保留不同错误的细节", "body": "本项目约定 {error:{code,message,details}}。HTTP 错误使用 code='http_error';请求校验使用 code='validation_error'。HTTP 状态码仍保留 404、405、422 等差异。details 中只返回定位所需的字段路径、错误类型和说明,不回显整个请求体。" }, { "title": "校验失败可能发生在路由执行之前", "body": "参数解析失败会抛出 RequestValidationError,不会进入路由里的 try/except。因此在应用级处理器统一处理。注册 Starlette 的 HTTPException 基类还能覆盖路由不存在和方法不支持等框架错误;处理时保留 Allow 等原有响应头。" } ], "steps": [ { "title": "确认已有业务错误入口", "body": "保留第 06 课代码,找到 require_note。确认它使用 raise 而非 return。请求一个不存在的 id,此时默认响应通常为 {\"detail\":\"笔记不存在\"},接下来统一其外层结构。", "filename": "main.py", "lang": "python", "code": "def require_note(note_id: int) -> dict:\n if note_id not in notes:\n raise HTTPException(status_code=404, detail=\"笔记不存在\")\n return notes[note_id]" }, { "title": "统一 HTTP 错误处理", "body": "在 main.py 中添加导入与处理器。这里演示业务 detail 为字符串;若后续传入字典,应单独约定字段映射,不把任意内部对象直接返回。保留 exc.headers,确保例如 405 的 Allow 头仍然存在。", "filename": "main.py", "lang": "python", "code": "from fastapi import Request\nfrom fastapi.responses import JSONResponse\nfrom starlette.exceptions import HTTPException as StarletteHTTPException\n\n@app.exception_handler(StarletteHTTPException)\nasync def handle_http_error(request: Request, exc: StarletteHTTPException):\n return JSONResponse(\n status_code=exc.status_code,\n headers=exc.headers,\n content={\n \"error\": {\n \"code\": \"http_error\",\n \"message\": str(exc.detail),\n \"details\": [],\n }\n },\n )" }, { "title": "统一 422 并保留字段位置", "body": "添加请求校验处理器。不要直接把 exc.errors() 中的所有对象交给 JSON 序列化;自定义校验器的 ctx 可能包含异常对象。这里只提取 JSON 可序列化的 loc、type、msg,也避免在响应中回显原始输入。", "filename": "main.py", "lang": "python", "code": "from fastapi.exceptions import RequestValidationError\n\n@app.exception_handler(RequestValidationError)\nasync def handle_validation_error(request: Request, exc: RequestValidationError):\n details = [\n {\n \"loc\": list(error[\"loc\"]),\n \"type\": error[\"type\"],\n \"msg\": error[\"msg\"],\n }\n for error in exc.errors()\n ]\n return JSONResponse(\n status_code=422,\n content={\n \"error\": {\n \"code\": \"validation_error\",\n \"message\": \"请求参数不符合接口要求\",\n \"details\": details,\n }\n },\n )" }, { "title": "用三类失败验证处理器", "body": "先 GET /notes/999999,确认 404 且 error.code='http_error'。再 GET /notes/abc 和 POST /notes 的空对象,确认 422;前者定位 path.note_id,后者定位 body.title。最后 POST /health,检查 405、http_error 以及 Allow 头。具体英文 msg 可能随依赖版本变化,客户端不要解析这段文本作业务判断。", "lang": "json", "code": "{\n \"error\": {\n \"code\": \"validation_error\",\n \"message\": \"请求参数不符合接口要求\",\n \"details\": [\n {\n \"loc\": [\"body\", \"title\"],\n \"type\": \"missing\",\n \"msg\": \"Field required\"\n }\n ]\n }\n}" } ], "checks": [ "不存在的笔记返回 404、http_error 和“笔记不存在”。", "非法 note_id 与缺失 title 都返回 422、validation_error,details.loc 能指出不同位置。", "POST /health 仍返回 405,Allow 响应头被保留。", "成功的笔记响应不被包在 error 字段中;204 删除仍然没有响应体。" ], "pitfalls": [ { "symptom": "错误响应有 message,但 HTTP 状态一直是 200", "cause": "只返回了错误字典,没有设置 JSONResponse 的 status_code。", "fix": "在异常处理器中显式设置对应状态,HTTP 错误使用 exc.status_code。" }, { "symptom": "注册 HTTPException 处理器后,未知路径仍是默认格式", "cause": "只处理 FastAPI 的异常子类,没有覆盖框架抛出的 Starlette 基类异常。", "fix": "导入并为 StarletteHTTPException 注册处理器,路由仍可抛出 FastAPI 的 HTTPException。" }, { "symptom": "自定义标题校验失败后出现 JSON 序列化异常", "cause": "把 exc.errors() 的 ctx 异常对象直接放进 JSONResponse。", "fix": "只提取 loc、type、msg 等必要字段,不直接返回任意异常对象或原始请求内容。" } ], "exercise": { "task": "对 GET /notes?limit=0、GET /notes/abc、POST /notes 的 {} 逐一记录 status、error.code 和第一条 details.loc,写出客户端应如何定位表单或请求参数错误。", "hint": "三次都是 422,但错误位置的来源不同:query、path、body。", "answer": "三次 status 都是 422,code 都是 validation_error。loc 分别为 ['query','limit']、['path','note_id']、['body','title']。客户端先按 status/code 判断错误类别,再用 loc 映射对应输入位置;msg 用于显示,不作为稳定的判断条件。" }, "refs": [ { "label": "FastAPI:覆盖异常处理器", "url": "https://fastapi.tiangolo.com/tutorial/handling-errors/" }, { "label": "Starlette:Exceptions", "url": "https://github.com/Kludex/starlette/blob/main/docs/exceptions.md" }, { "label": "Pydantic:错误处理", "url": "https://docs.pydantic.dev/latest/errors/errors/" } ] }, { "id": "08", "group": "REST 接口", "title": "复用依赖与拆分路由文件", "summary": "用 X-API-Key 练习依赖执行顺序,把 /private 放入独立 APIRouter,并通过环境变量配置课堂密钥。", "objectives": [ "用 Depends 在路由执行前完成可复用校验", "从请求头读取 X-API-Key,从环境变量读取预期值", "用 APIRouter 拆分文件并注册到同一个应用" ], "concepts": [ { "title": "Depends 声明请求处理的前置步骤", "body": "FastAPI 收到请求后先解析依赖需要的参数,执行依赖函数,再进入路由。依赖抛出 HTTPException 时,路由不会执行。同一套校验可以挂在多个接口或整个 APIRouter 上,避免每个路由重复读取请求头。" }, { "title": "Header 与查询参数的来源不同", "body": "x_api_key: str 这种普通参数默认会作为查询参数。使用 Header(alias='X-API-Key') 才从请求头读取。这里给缺失值默认 None,再由依赖统一返回课堂约定的 401;缺少请求头不会先变成默认必填参数的 422。401 响应携带 WWW-Authenticate: APIKey,说明本实验使用的凭据类型。" }, { "title": "配置值与身份系统分开理解", "body": "DEMO_API_KEY 从启动终端的环境变量读取,网页教程中的 classroom-key 仅用于本地实验。这个共享字符串只演示请求校验,不能区分用户,也没有密钥轮换或权限管理。本节只保护 /private,公开的笔记接口用于本地练习。" }, { "title": "APIRouter 负责组织,不会自动启动服务", "body": "独立文件创建 router 并注册其路由,主入口再 app.include_router(router)。依赖模块不导入 main,避免循环导入。多个文件最终仍组成同一个 FastAPI 应用,共享第 07 课注册的异常处理器。" } ], "steps": [ { "title": "建立依赖函数文件", "body": "在 api-lab 根目录新增 dependencies.py,不修改前面的笔记接口。用 secrets.compare_digest 比较字符串。未配置环境变量返回 503,缺失或错误请求头返回 401,这两种失败帮助区分服务配置和调用参数。", "filename": "dependencies.py", "lang": "python", "code": "import os\nimport secrets\nfrom typing import Annotated\n\nfrom fastapi import Header, HTTPException\n\ndef require_demo_key(\n x_api_key: Annotated[str | None, Header(alias=\"X-API-Key\")] = None,\n) -> None:\n expected = os.getenv(\"DEMO_API_KEY\")\n if not expected:\n raise HTTPException(status_code=503, detail=\"服务未配置 DEMO_API_KEY\")\n if x_api_key is None or not secrets.compare_digest(\n x_api_key.encode(\"utf-8\"), expected.encode(\"utf-8\")\n ):\n raise HTTPException(\n status_code=401,\n detail=\"X-API-Key 无效\",\n headers={\"WWW-Authenticate\": \"APIKey\"},\n )" }, { "title": "创建独立的私有路由", "body": "新增 routers 目录,其中创建空的 __init__.py 和下面的 private.py。把依赖挂在 APIRouter 上,当前文件中的接口都会执行相同校验。装饰器使用 @router.get,路径保持 /private。", "filename": "routers/private.py", "lang": "python", "code": "from fastapi import APIRouter, Depends\n\nfrom dependencies import require_demo_key\n\nrouter = APIRouter(\n tags=[\"private\"],\n dependencies=[Depends(require_demo_key)],\n)\n\n@router.get(\"/private\")\ndef read_private():\n return {\"message\": \"API key accepted\"}" }, { "title": "在主入口注册 router", "body": "在 main.py 导入路由模块,并在 app 创建之后调用 include_router。保留原有 /health、笔记路由和两个异常处理器。刷新 /docs,确认出现 private 分组及 X-API-Key 参数;如果没有出现,先检查是否真正注册。", "filename": "main.py", "lang": "python", "code": "from routers import private\n\n# 放在 app = FastAPI(...) 之后\napp.include_router(private.router)\n\n# 目录结构\n# api-lab/\n# main.py\n# dependencies.py\n# routers/\n# __init__.py\n# private.py" }, { "title": "设置当前终端环境并重新启动", "body": "先 Ctrl+C 停止旧服务,在同一个终端按系统选择一组命令。新启动的进程继承该终端环境;在另一窗口设置变量不会修改正在运行的服务。classroom-key 是公开教学校验值。", "lang": "bash", "code": "# Windows PowerShell\n$env:DEMO_API_KEY = \"classroom-key\"\npython -m uvicorn main:app --reload\n\n# macOS / Linux\nexport DEMO_API_KEY=\"classroom-key\"\npython -m uvicorn main:app --reload" }, { "title": "验证缺失、错误与正确密钥", "body": "在 /docs 连续执行 GET /private:不填写头部、填写 wrong-key、填写 classroom-key。前两次返回 401,第三次返回 200。第 07 课错误处理器会把失败转换为统一 error 对象。再访问 /notes,确认该本地练习接口未要求密钥。", "lang": "bash", "code": "# macOS / Linux;Windows PowerShell 使用 curl.exe\ncurl -i http://127.0.0.1:8000/private\ncurl -i http://127.0.0.1:8000/private -H \"X-API-Key: wrong-key\"\ncurl -i http://127.0.0.1:8000/private -H \"X-API-Key: classroom-key\"" } ], "checks": [ "/docs 显示 /private 和 X-API-Key 请求头输入项。", "配置环境变量后,缺失或错误密钥返回 401 和 http_error;正确密钥返回 200 和 {\"message\":\"API key accepted\"}。", "未设置 DEMO_API_KEY 的新服务进程返回 503,而不会因为请求头与配置都为空而通过。", "拆分文件后 /health、原有笔记 CRUD 与错误结构仍然可用。" ], "pitfalls": [ { "symptom": "已在客户端写 X-API-Key,却显示密钥无效", "cause": "服务读取的头部来源声明不正确,或者客户端把值放到了查询字符串。", "fix": "依赖明确使用 Header(alias='X-API-Key');在 Network 或 curl 输出中核对 Request Headers。" }, { "symptom": "环境变量已设置,接口仍返回 503", "cause": "变量设置在另一个终端,或者旧服务进程没有重启。", "fix": "在设置变量的同一个终端停止并重启 Uvicorn。" }, { "symptom": "新增 private.py 后 /private 返回 404", "cause": "只创建了 router,没有在主 app 上 include_router,或启动时目录不正确。", "fix": "确认 main.py 注册 private.router,并从 api-lab 根目录启动 main:app。" } ], "exercise": { "task": "在 routers/private.py 中新增 GET /private/status,返回 {\"enabled\":true}。复用 router 上的依赖,分别验证无头部与正确头部。再说明为什么这仍不能判断是哪位学生发出的请求。", "hint": "只添加 @router.get('/private/status') 的函数,不必复制校验代码。", "answer": "添加:\n@router.get('/private/status')\ndef private_status():\n return {'enabled': True}\n\n无 X-API-Key 返回 401;classroom-key 返回 200 和 {'enabled':true}。两个接口自动复用 router 依赖。所有人使用同一公开密钥,服务只能判断是否匹配,无法识别具体用户。" }, "refs": [ { "label": "FastAPI:依赖注入", "url": "https://fastapi.tiangolo.com/tutorial/dependencies/" }, { "label": "FastAPI:Header 参数", "url": "https://fastapi.tiangolo.com/tutorial/header-params/" }, { "label": "FastAPI:多文件应用", "url": "https://fastapi.tiangolo.com/tutorial/bigger-applications/" }, { "label": "Python:compare_digest", "url": "https://docs.python.org/3/library/secrets.html#secrets.compare_digest" } ] }, { "id": "09", "group": "异步与调用", "title": "使用 async / await 处理 I/O 等待", "summary": "用两个并发请求复现阻塞,掌握 async / await 在接口中的实际作用。", "objectives": [ "写出不阻塞事件循环的 /slow 接口", "测量串行与并发请求的耗时差异", "判断同步 I/O 应改用异步客户端,还是移交线程" ], "concepts": [ { "title": "async def 定义协程函数", "body": "调用后得到可等待对象;await 等待结果,并在操作尚未完成时让出执行机会。仅给函数添加 async 不会把内部的阻塞代码变为异步。" }, { "title": "等待与计算分开判断", "body": "网络响应、磁盘访问属于 I/O;大量 Python 计算属于 CPU 工作。异步可以交错等待,本课不通过 async 加速计算,也不在接口里引入后台任务队列。" }, { "title": "FastAPI 的执行位置", "body": "FastAPI 调用普通 def 路由时使用线程池;async def 路由在事件循环中执行。你在 async def 内直接调用普通辅助函数时,FastAPI 不会自动把辅助函数移到线程。" } ], "steps": [ { "title": "增加一个可测量的等待接口", "body": "开始前,在已激活的虚拟环境中运行 python -m pip install \"httpx>=0.27,<1\",第 09 节的客户端脚本会导入它。下载包的 requirements.txt 已包含此依赖。\n\n在 api-lab 的应用中加入路由。delay 限制在 0–5 秒,避免误输很大数值;运行单个服务进程进行本课实验。", "code": "import asyncio\nfrom fastapi import Query\n\n@app.get(\"/slow\")\nasync def slow(delay: float = Query(default=1, ge=0, le=5)):\n await asyncio.sleep(delay)\n return {\"delay\": delay}", "lang": "python", "filename": "main.py" }, { "title": "同时发出两次请求", "body": "新建脚本,保持服务已启动后执行 python concurrent_check.py。先建立一个客户端,再让两次请求同时等待;打印状态码和总耗时。页面演示是浏览器模拟,真实耗时以此脚本为准。", "code": "import asyncio\nfrom time import perf_counter\nimport httpx\n\nasync def main():\n async with httpx.AsyncClient(base_url=\"http://127.0.0.1:8000\") as client:\n started = perf_counter()\n results = await asyncio.gather(\n client.get(\"/slow\", params={\"delay\": 1}),\n client.get(\"/slow\", params={\"delay\": 1}),\n )\n print([r.status_code for r in results])\n print(f\"elapsed={perf_counter() - started:.2f}s\")\n\nasyncio.run(main())", "lang": "python", "filename": "concurrent_check.py" }, { "title": "复现阻塞,再恢复", "body": "暂时把 await asyncio.sleep(delay) 换成 time.sleep(delay),并 import time。保存后等服务完成重载,再运行相同脚本。单进程时两次等待通常接近 2 秒;恢复 async sleep 后通常接近 1 秒。另开终端访问 /health,观察阻塞时它也要等待。结果会受机器和调度开销影响,不以毫秒误差判断对错。", "code": "curl.exe \"http://127.0.0.1:8000/health\"", "lang": "bash" }, { "title": "为无法替换的同步 I/O 选择线程", "body": "优先使用库提供的异步方法;若必须在 async def 路由中调用同步 I/O,用 asyncio.to_thread。此处 slow_sync 用 sleep 模拟同步 I/O,实际替换为该库的函数;线程不是本课 CPU 计算加速方案。", "code": "import time\n\ndef slow_sync(delay: float):\n time.sleep(delay)\n return {\"delay\": delay}\n\n# 放在 async def 路由内:\n# result = await asyncio.to_thread(slow_sync, delay)\n# return result", "lang": "python" } ], "checks": [ "异步版本两个 delay=1 请求均返回 200,总耗时通常接近 1 秒。", "阻塞版本在单进程下耗时明显增加,恢复后 /health 不再等待 slow 的 sleep 结束。", "/slow?delay=-1 与 /slow?delay=6 都返回 422。" ], "pitfalls": [ { "symptom": "写了 async def,其他请求仍卡住", "cause": "路由内使用 time.sleep、同步 HTTP 客户端或长时间计算,占住事件循环。", "fix": "定位耗时调用;I/O 改用 await 支持的 API,必要时把同步 I/O 放到线程。" }, { "symptom": "两次 await 请求仍耗时约 2 秒", "cause": "先 await 第一次,再创建第二次,代码仍是串行等待。", "fix": "用 asyncio.gather 同时调度两个请求,再比较结果。" }, { "symptom": "出现 coroutine was never awaited", "cause": "调用异步函数但没有 await,也没有交给任务调度。", "fix": "在 async 函数内 await 结果;脚本最外层用 asyncio.run(main())。" } ], "exercise": { "task": "把脚本改成同时请求 3 个 delay=1 的 /slow,再增加一次 /health,记录总耗时及各状态码。", "hint": "把第三个 /slow 和 /health 一并放进 gather;不要依次 await。", "answer": "四个请求应全部返回 200。使用非阻塞等待时,总耗时通常仍接近最长的一次等待,即约 1 秒;若接近 3 秒,先检查服务是否恢复了 asyncio.sleep。" }, "refs": [ { "label": "FastAPI:并发与 async / await", "url": "https://fastapi.tiangolo.com/async/" }, { "label": "Python:协程、gather 与 to_thread", "url": "https://docs.python.org/3/library/asyncio-task.html" } ], "demo": "async" }, { "id": "10", "group": "异步与调用", "title": "使用 HTTPX 调用服务与调试 CORS", "summary": "复用 HTTPX 客户端,处理超时与上游错误,并定位浏览器跨域问题。", "objectives": [ "在应用生命周期中创建和关闭 AsyncClient", "区分超时、连接失败和非成功状态码", "说明为什么 curl 正常而浏览器请求失败" ], "concepts": [ { "title": "一次请求有两个方向", "body": "浏览器到 FastAPI 是入站请求;FastAPI 到另一个服务是出站请求。调用失败时,先确定错误发生在哪一段。" }, { "title": "客户端与连接池", "body": "AsyncClient 可以复用连接。应用启动时创建,退出时关闭;不要在每次高频请求内重新创建客户端。connect、read、write、pool 是不同阶段的超时,read 通常是等待下一块数据的上限,不是请求总时限。" }, { "title": "CORS 由浏览器检查", "body": "Origin 由协议、主机和端口组成。http://localhost:5500 与 http://127.0.0.1:5500 是两个来源;curl 和服务端 HTTPX 不执行浏览器的 CORS 检查。CORS 也不替代 API 认证。" } ], "steps": [ { "title": "把客户端交给 lifespan 管理", "body": "安装 httpx。把现有 FastAPI() 初始化改为使用 lifespan;若应用已有 lifespan,把下面的客户端管理合并进去,不要再创建第二个 app。上游使用本机已完成的 /slow,让超时实验不依赖公网。", "code": "from contextlib import asynccontextmanager\nimport httpx\nfrom fastapi import FastAPI\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n timeout = httpx.Timeout(3.0, connect=1.0)\n async with httpx.AsyncClient(\n base_url=\"http://127.0.0.1:8000\", timeout=timeout\n ) as client:\n app.state.http = client\n yield\n\napp = FastAPI(lifespan=lifespan)", "lang": "python", "filename": "main.py" }, { "title": "把出站失败转换为可判断的响应", "body": "增加 /upstream-demo。默认 delay=1 会成功,delay=4 超过 read 超时。这里调用本机接口只是本地实验;正式业务中使用服务方的固定地址,避免让用户任意指定出站 URL。raise_for_status 会把非 2xx 响应转为 HTTPStatusError。", "code": "from fastapi import HTTPException, Query, Request\n\n@app.get(\"/upstream-demo\")\nasync def upstream_demo(\n request: Request,\n delay: float = Query(default=1, ge=0, le=5),\n):\n try:\n response = await request.app.state.http.get(\n \"/slow\", params={\"delay\": delay}\n )\n response.raise_for_status()\n return {\"upstream\": response.json()}\n except httpx.TimeoutException as exc:\n raise HTTPException(504, \"上游响应超时\") from exc\n except httpx.HTTPStatusError as exc:\n raise HTTPException(502, \"上游返回非成功状态\") from exc\n except httpx.RequestError as exc:\n raise HTTPException(502, \"无法连接上游服务\") from exc", "lang": "python", "filename": "main.py" }, { "title": "在两个页面来源之间复现 CORS", "body": "API 保持运行在 8000 端口。把下面中间件加入现有 app;已有 CORSMiddleware 时修改原配置,不要重复添加。本实验只允许 http://127.0.0.1:5500。如果使用下载的参考项目,先暂时从 allow_origins 中移除 http://localhost:5500,才能复现后面来源不匹配的情况。", "code": "from fastapi.middleware.cors import CORSMiddleware\n\napp.add_middleware(\n CORSMiddleware,\n allow_origins=[\"http://127.0.0.1:5500\"],\n allow_credentials=False,\n allow_methods=[\"GET\", \"POST\", \"PUT\", \"DELETE\"],\n allow_headers=[\"Content-Type\", \"X-API-Key\"],\n)", "lang": "python", "filename": "main.py" }, { "title": "建立前端页面并从 5500 访问 API", "body": "在 api-lab 中新建 cors-demo.html,粘贴下面内容。另开一个终端,在 api-lab 目录执行 python -m http.server 5500,保留 API 的 8000 终端。打开 http://127.0.0.1:5500/cors-demo.html 并点击按钮,在 Network 中观察 OPTIONS 预检和 POST;JSON 的 Content-Type 会触发本次跨域预检。再通过 http://localhost:5500/cors-demo.html 打开同一文件,对照来源不匹配时的浏览器错误。此时简单的 GET /health 不一定产生 OPTIONS。", "code": "\n\n\n