--- id: python.fastapi.routing-validation title: 路由和参数校验:路径 / 查询 / 请求体、pydantic 模型、响应模型 type: lesson domain: python stack: [fastapi] tags: [路由, pydantic, 参数校验, 响应模型, OpenAPI] level: basic prerequisites: [python.python.typing-errors-testing] est_hours: 2 related: [python.fastapi.deps-auth-errors, backend.gin.routing-binding, python.fastapi.interview] week: [4] order: 1 --- # 路由和参数校验:路径 / 查询 / 请求体、pydantic 模型、响应模型 ## 解释说明 - **参数来源由类型和位置决定**:路径里出现的参数 → 路径参数;简单类型(int、str)→ 查询参数;pydantic 模型 → JSON 请求体;`Header()`、`Cookie()` 显式声明。 - **校验**:`Field(ge=1, le=100, min_length=1, pattern=...)`、`Query(...)`、`Path(...)`;`Literal` 限定枚举值;校验失败自动返回 **422**,带出错字段和原因。 - **响应模型**:返回类型标注或 `response_model=`,会过滤掉模型之外的字段——防止把密码哈希等内部字段返回出去。 - **自动文档**:启动后访问 `/docs`(Swagger UI),前端联调直接用。 - **`async def` 还是 `def`**:路由里调用的都是异步库 → `async def`;调用同步阻塞库 → 普通 `def`(FastAPI 会放到线程池执行)。**不要在 `async def` 里调用阻塞函数。** - **APIRouter**:按模块拆分路由,`app.include_router(router, prefix="/v1/docs")`。 和 Gin 对照:`c.Param` ↔ 路径参数,`c.Query` ↔ 查询参数,`ShouldBindJSON` + `binding` 标签 ↔ pydantic 模型 + `Field`。 ## 案例 知识库的文档接口:创建文档(标题 1~200 字、标签最多 10 个、可见范围只能是 `private / team / public`)、按 ID 查询、分页列表(页码 ≥ 1,每页 1~100)。用户传了 `"visibility": "everyone"`,FastAPI 自动返回 422 和具体字段,不用手写校验代码。 ## 代码示例 ```python from datetime import datetime, timezone from typing import Annotated, Literal from fastapi import APIRouter, FastAPI, HTTPException, Path, Query from fastapi.testclient import TestClient from pydantic import BaseModel, Field class DocCreate(BaseModel): title: str = Field(min_length=1, max_length=200) content: str = Field(min_length=1) tags: list[str] = Field(default_factory=list, max_length=10) visibility: Literal["private", "team", "public"] = "team" class DocOut(BaseModel): # 响应模型:只暴露这些字段 id: int title: str tags: list[str] visibility: str created_at: datetime class Page(BaseModel): items: list[DocOut] total: int router = APIRouter(prefix="/v1/docs", tags=["文档"]) DB: dict[int, dict] = {} @router.post("", status_code=201) async def create_doc(body: DocCreate) -> DocOut: doc_id = len(DB) + 1 DB[doc_id] = {**body.model_dump(), "id": doc_id, "owner_secret": "内部字段", "created_at": datetime.now(timezone.utc)} return DocOut(**DB[doc_id]) # 只取模型里声明的字段 @router.get("/{doc_id}") async def get_doc(doc_id: Annotated[int, Path(ge=1)]) -> DocOut: if doc_id not in DB: raise HTTPException(status_code=404, detail="文档不存在") return DocOut(**DB[doc_id]) @router.get("") async def list_docs(page: Annotated[int, Query(ge=1)] = 1, size: Annotated[int, Query(ge=1, le=100)] = 20, tag: str | None = None) -> Page: docs = [d for d in DB.values() if tag is None or tag in d["tags"]] start = (page - 1) * size return Page(items=[DocOut(**d) for d in docs[start:start + size]], total=len(docs)) app = FastAPI() app.include_router(router) c = TestClient(app) r = c.post("/v1/docs", json={"title": "年假制度", "content": "……", "tags": ["hr"]}) print(r.status_code, r.json()["id"], "owner_secret" in r.json()) # 201 1 False r = c.post("/v1/docs", json={"title": "", "content": "x", "visibility": "everyone"}) print(r.status_code, [e["loc"] for e in r.json()["detail"]]) # 422 [['body', 'title'], ['body', 'visibility']] print(c.get("/v1/docs", params={"size": 500}).status_code) # 422 print(c.get("/v1/docs/99").json()) # {'detail': '文档不存在'} print(c.get("/v1/docs", params={"tag": "hr"}).json()["total"]) # 1 ``` ## 易错点 - **在 `async def` 路由里调用同步数据库驱动或 `requests`**:阻塞事件循环,吞吐骤降。 - **直接返回 ORM 对象或字典而不声明响应模型**:内部字段(密码哈希、内部备注)泄露。 - **可变默认值**:`tags: list[str] = []` 在 pydantic 里是安全的(会复制),但养成 `default_factory` 的习惯更清晰。 - **路由顺序**:`/docs/{doc_id}` 写在 `/docs/stats` 之前,`stats` 会被当成 doc_id 去解析,返回 422。固定路径放在前面。 - **422 的错误格式直接给前端**:默认格式偏技术化,通常用异常处理器转换成团队统一的格式(见下一课)。 - **pydantic v1 和 v2 写法混用**:v2 用 `model_dump()`、`model_validate()`、`field_validator`;网上很多旧教程是 v1 写法(`dict()`、`validator`)。 ## 面试常问 见 [FastAPI 面试题](interview.md):FastAPI 为什么快、参数校验原理、async def 和 def 的区别、响应模型的作用。