1. 响应
路径函数完成后,FastAPI 需要把 Python 结果转换成 HTTP 响应。一个响应不只有 JSON 数据,还包括状态码、响应头和媒体类型:
1HTTP/1.1 200 OK2Content-Type: application/json34{"answer":"FastAPI 是一个 Python Web 框架。"}
FastAPI 默认很适合 JSON API。返回字典、列表或 Pydantic 模型时,它会把结果编码成 JSON,并设置合适的 Content-Type。
本文的完整可请求案例位于 src/fastapi/api/res/,每个下划线命名的 Python 文件对应一个独立路由模块;它们统一由 res/__init__.py 聚合。文章中的说明性表格、HTTP 报文和文字解释不对应项目文件。
res/__init__.py 负责声明响应案例的领域前缀,并聚合各个案例。上层 api/router.py 将整个响应案例模块挂载到 /api/v1:
01from fastapi import APIRouter0203from . import (04create_response,05direct_json_response,06documented_responses,07dynamic_status,08exclude_unset,09file_response,10no_content,11response_classes,12response_cookie,13response_header,14response_model,15return_model,16serialization,17)1819# /api/v1 由上层聚合路由统一添加,本模块只声明自己的领域前缀。20router = APIRouter(prefix="/res", tags=["responses"])2122router.include_router(serialization.router)23router.include_router(response_model.router)24router.include_router(return_model.router)25router.include_router(create_response.router)26router.include_router(dynamic_status.router)27router.include_router(exclude_unset.router)28router.include_router(response_header.router)29router.include_router(response_cookie.router)30router.include_router(response_classes.router)31router.include_router(direct_json_response.router)32router.include_router(file_response.router)33router.include_router(no_content.router)34router.include_router(documented_responses.router)
因此,案例文件中的相对路径会统一组合成 /api/v1/res/...。
2. 序列化
JSON 只支持有限的数据类型,但 Python 还包含日期、UUID 和模型对象。FastAPI 会根据路径函数有没有声明响应模型,选择不同的序列化方式:
| 返回方式 | FastAPI 的处理 |
|---|---|
声明 response_model 或有效的返回类型 | 使用 Pydantic 校验、过滤并序列化响应 |
| 没有声明响应模型 | 使用 jsonable_encoder 转换,再交给 JSONResponse |
直接返回 Response 子类 | 原样发送,不再自动校验和转换内容 |
我们先来看没有显式响应模型时最常见的写法:
下面的案例请求一个固定 UUID 对应的会话,并同时展示正文、状态码、响应头和响应 URL:
01from datetime import datetime, timezone02from uuid import UUID0304from fastapi import APIRouter0506router = APIRouter()0708@router.get("/conversations/{conversation_id}")09async def get_conversation(conversation_id: UUID) -> dict[str, UUID | datetime]:10return {11"id": conversation_id,12"created_at": datetime.now(timezone.utc),13}
UUID 和时间会转换成 JSON 字符串。时间字段应明确时区,跨系统传输时通常使用 UTC 和 ISO 8601 格式,不要返回依赖服务器本地时区的模糊时间。
这个案例没有声明响应模型,所以 FastAPI 会先把 UUID 和时间转换成 JSON 能够表示的数据。如果接口声明了 Pydantic 响应模型,FastAPI 会直接使用 Pydantic 完成响应校验和序列化,不需要在路径函数中手动调用 model_dump() 或 jsonable_encoder()。
3. 响应模型
使用 response_model 可以声明并校验公开响应:
下面的案例可以直接观察公开响应中只有 id 和 name,服务端返回的 password_hash 会被响应模型过滤:
01from fastapi import APIRouter02from pydantic import BaseModel0304router = APIRouter()0506class UserRead(BaseModel):07id: str08name: str0910@router.get("/users/{user_id}", response_model=UserRead)11async def get_user(user_id: str) -> dict[str, str]:12return {13"id": user_id,14"name": "小微",15"password_hash": "internal-value",16}
响应中只会保留 UserRead 声明的字段,password_hash 不会发送给客户端。这种过滤是重要的安全边界,但不能代替正确的数据分层:业务代码也不应该随意把敏感对象交给响应层。
可以直接把返回类型写成 Pydantic 模型:
这个案例请求使用返回类型声明响应模型的接口,它与上一个案例采用不同声明方式,但都会经过响应校验和序列化:
01from fastapi import APIRouter02from pydantic import BaseModel0304router = APIRouter()0506class UserRead(BaseModel):07id: str08name: str0910@router.get("/typed-users/{user_id}")11async def get_user(user_id: str) -> UserRead:12return UserRead(id=user_id, name="小微")
返回类型和 response_model 都能参与运行时响应校验、序列化、字段过滤和文档生成。它们不是只给编辑器看的类型提示。
两者同时存在时,response_model 优先。一般可以按下面的方式选择:
| 情况 | 推荐写法 |
|---|---|
| 返回值本身就是公开模型 | 使用返回类型 -> UserRead |
| 函数返回字典或内部对象,但公开契约不同 | 使用 response_model=UserRead |
直接返回 Response 子类 | 返回类型写 Response 或具体子类 |
| 类型只供 Python 工具使用,不希望生成响应模型 | 设置 response_model=None |
响应数据不符合模型时,通常意味着服务端实现违反了自己的契约。FastAPI 会把它作为服务器错误处理,而不是向客户端返回 422。422 主要表示客户端提交的请求无法通过校验。
4. 输入输出
创建资源时,输入和输出应使用不同模型:
下面的案例会提交会话标题。请求成功后,可以在响应元数据中观察 201 状态码以及浏览器允许读取的响应头:
01from datetime import datetime, timezone02from uuid import uuid40304from fastapi import APIRouter, Response, status05from pydantic import BaseModel0607router = APIRouter()080910class ConversationCreate(BaseModel):11title: str121314class ConversationRead(BaseModel):15id: str16title: str17created_at: datetime181920@router.post(21"/conversations",22response_model=ConversationRead,23status_code=status.HTTP_201_CREATED,24)25async def create_conversation(26payload: ConversationCreate,27response: Response,28) -> ConversationRead:29conversation_id = str(uuid4())30response.headers["Location"] = f"/api/v1/res/conversations/{conversation_id}"3132return ConversationRead(33id=conversation_id,34title=payload.title,35created_at=datetime.now(timezone.utc),36)
客户端只提供标题,服务器负责生成 ID 和时间。响应模型把这种所有权写进了接口契约。201 Created 表示服务器创建了新资源,Location 响应头进一步告诉客户端可以从哪里访问它。
5. 状态码
固定状态码写在路径装饰器中,动态状态码可以通过 Response 参数调整:
第一次提交新的 item_id 时接口返回 201,再次提交同一个 ID 时返回 200。可以修改 ID 和查询参数 value 观察状态变化:
01from fastapi import APIRouter, Response, status02from pydantic import BaseModel0304router = APIRouter()050607class ItemRead(BaseModel):08id: str09value: str101112items: dict[str, str] = {}131415@router.put(16"/items/{item_id}",17response_model=ItemRead,18responses={19201: {20"model": ItemRead,21"description": "创建了新资源",22},23},24)25async def upsert_item(26item_id: str,27value: str,28response: Response,29) -> dict[str, str]:30created = item_id not in items31items[item_id] = value3233if created:34response.status_code = status.HTTP_201_CREATED35response.headers["Location"] = f"/api/v1/res/items/{item_id}"3637return {"id": item_id, "value": value}
只有业务结果确实存在多种成功状态时才动态修改。接口行为越稳定,客户端越容易处理。
这里还要区分实际响应和接口文档。给注入的 Response 设置 status_code 会改变本次请求的实际状态码,但 OpenAPI 无法根据函数中的条件分支自动推断 201,因此还需要使用装饰器的 responses 参数声明这个分支。responses 只补充文档,不会替我们执行状态切换。
常见的成功状态码可以这样理解:
| 状态码 | 含义 | 响应正文 |
|---|---|---|
200 OK | 请求成功并返回结果 | 通常有 |
201 Created | 已经创建新资源 | 通常返回资源,并可提供 Location |
202 Accepted | 已接收任务,但尚未处理完成 | 通常返回任务标识或查询地址 |
204 No Content | 请求成功,但没有内容需要返回 | 不能有 |
304 Not Modified | 客户端缓存仍然有效 | 不能有 |
6. 默认字段
响应模型可以控制默认字段是否输出。例如数据记录中有许多可选字段时,可以只返回实际赋值的内容:
下面的响应只包含服务端实际设置的 name,默认字段不会出现在 data 中:
01from fastapi import APIRouter02from pydantic import BaseModel0304router = APIRouter()050607class UserProfile(BaseModel):08name: str09bio: str | None = None10theme: str = "system"111213@router.get(14"/profiles/{user_id}",15response_model=UserProfile,16response_model_exclude_unset=True,17)18async def get_profile(user_id: str) -> dict[str, str]:19return {"name": "小微"}
响应只包含 name,因为 bio 和 theme 没有被实际设置。这里判断的是字段是否被显式赋值:即使客户端明确传入 bio=None 或 theme="system",值刚好与默认值相同,response_model_exclude_unset=True 仍会保留这些字段。
FastAPI 还提供两个容易混淆的选项:
| 参数 | 排除规则 |
|---|---|
response_model_exclude_unset | 排除没有显式赋值的字段 |
response_model_exclude_defaults | 排除值等于字段默认值的字段 |
response_model_exclude_none | 排除值为 None 的字段 |
此外,response_model_include 和 response_model_exclude 可以临时选择部分字段,但 OpenAPI 仍然会引用完整模型的 Schema,文档和实际响应容易产生理解偏差。公开接口长期需要不同字段时,优先创建独立的响应模型。
这些选项适合稀疏数据,但也可能让同一接口的字段忽隐忽现。稳定公开契约通常更容易使用,不要只为了减少几个字节就默认开启。
7. 响应头
FastAPI 可以注入 Response,在保留普通返回值和响应模型能力的同时设置响应头:
下面的案例会请求健康状态,并把浏览器能够读取的响应头一起显示出来:
1from fastapi import APIRouter, Response23router = APIRouter()456@router.get("/response-health")7async def health(response: Response) -> dict[str, str]:8response.headers["X-Service-Version"] = "0.1.0"9return {"status": "ok"}
分页链接、缓存策略、限流信息和 Request ID 都可能放在响应头中。浏览器跨域读取自定义响应头时,还要在 CORS 配置中通过 expose_headers 明确暴露。
8. Cookie
响应对象也可以设置 Cookie:
下面的请求允许浏览器接收凭证,用于观察设置 Cookie 的接口行为:
01from fastapi import APIRouter, Response0203router = APIRouter()040506@router.post("/sessions")07async def create_session(response: Response) -> dict[str, bool]:08response.set_cookie(09key="session_id",10value="signed-session-value",11httponly=True,12secure=True,13samesite="lax",14max_age=3600,15)16return {"created": True}
httponly 可以阻止前端 JavaScript 读取 Cookie,secure 表示只通过 HTTPS 发送,samesite 控制跨站请求携带规则。真正的会话值还需要安全随机、签名或服务端存储,不能直接相信客户端传回的任意文本。
9. 响应类
不是所有响应都是 JSON。FastAPI 提供多种 Response 类:
下面两个案例分别请求纯文本和重定向响应。重定向请求会记录原始地址和浏览器自动跟随后到达的最终地址:
01from fastapi import APIRouter02from fastapi.responses import PlainTextResponse, RedirectResponse0304router = APIRouter()050607@router.get("/robots.txt", response_class=PlainTextResponse)08async def robots() -> str:09return "User-agent: *\nDisallow: /"101112@router.get("/documentation")13async def documentation() -> RedirectResponse:14return RedirectResponse(url="/docs")
RedirectResponse 默认使用 307,它会要求客户端在跳转后保留原请求方法。如果业务需要永久跳转或希望改变请求方法,应根据协议显式选择 301、302、303、307 或 308,而不是默认把所有重定向都理解成 302。
常见类型包括:
| 类型 | 用途 |
|---|---|
JSONResponse | 手动控制 JSON 响应 |
PlainTextResponse | 纯文本 |
HTMLResponse | HTML 内容 |
FileResponse | 发送磁盘文件 |
RedirectResponse | HTTP 重定向 |
StreamingResponse | 持续输出数据流 |
直接返回一个 Response 对象时,开发者接管了状态码、内容和媒体类型。FastAPI 不会再按照普通 Pydantic 响应模型处理内容,因此要自己保证格式与文档一致。
这里不要把注入的 Response 参数和直接返回的 Response 对象混为一谈。前者是一个临时响应对象,FastAPI 会提取其中的状态码、响应头和 Cookie,再继续处理路径函数返回的数据;后者已经是最终响应,FastAPI 会将它原样发送。
response_class=PlainTextResponse 会告诉 FastAPI 如何构造响应,并把媒体类型写入 OpenAPI;return PlainTextResponse(...) 则直接返回已经构造好的对象。需要动态状态码或完全控制内容时使用后者,固定格式优先使用装饰器参数。
如果先用 response_model 约束数据,又直接返回 JSONResponse,不要期待响应模型继续过滤内容。直接 Response 是一条绕过自动响应处理的通道。
直接构造 JSONResponse 时,内容必须已经能够被 JSON 编码。datetime、UUID、Pydantic 模型等对象需要先转换:
这个案例用于观察手动构造的 JSON 响应以及时间字段最终得到的字符串形式:
01from datetime import datetime, timezone0203from fastapi import APIRouter04from fastapi.encoders import jsonable_encoder05from fastapi.responses import JSONResponse0607router = APIRouter()080910@router.get("/build-info")11async def get_build_info() -> JSONResponse:12content = {13"version": "0.1.0",14"generated_at": datetime.now(timezone.utc),15}16return JSONResponse(content=jsonable_encoder(content))
如果已经声明了 Pydantic 响应模型,就不需要手动做这一步。只有确实需要自己控制响应对象时,才接管这部分转换工作。
文件下载则可以使用 FileResponse:
下面的案例以 Blob 接收 PDF,只展示响应状态、文件名、媒体类型和文件大小:
01from pathlib import Path0203from fastapi import APIRouter04from fastapi.responses import FileResponse0506router = APIRouter()07report_path = Path(__file__).parent / "files" / "report.pdf"080910@router.get("/reports/latest", response_class=FileResponse)11async def download_report() -> FileResponse:12return FileResponse(13path=report_path,14media_type="application/pdf",15filename="fastapi-report.pdf",16)
media_type 决定 Content-Type,filename 会让 FileResponse 生成用于下载的 Content-Disposition。文件不存在、路径是否允许访问等检查仍应由业务代码负责,不能直接把客户端传入的任意路径交给 FileResponse。
10. 空响应
删除资源后可以返回 204:
下面的案例发送 DELETE 请求。成功后可以看到 204 状态码,并确认响应中没有正文:
01from fastapi import APIRouter, Response, status0203router = APIRouter()040506@router.delete(07"/conversations/{conversation_id}",08status_code=status.HTTP_204_NO_CONTENT,09)10async def delete_conversation(conversation_id: str) -> Response:11return Response(status_code=status.HTTP_204_NO_CONTENT)
204 表示响应没有正文,不应再返回 {"success": true}。如果客户端确实需要删除结果或新状态,就使用 200 并声明相应模型。
11. 其他响应
一个接口可能除了成功响应,还会返回业务错误。可以通过装饰器的 responses 参数把这些分支写入 OpenAPI:
下面两个案例请求同一个接口:第一个得到正常会话,第二个使用不存在的 ID 得到文档中声明的 404 错误响应:
01from fastapi import APIRouter02from fastapi.responses import JSONResponse03from pydantic import BaseModel0405router = APIRouter()060708class ConversationRead(BaseModel):09id: str10title: str111213class ErrorResponse(BaseModel):14code: str15message: str161718conversations = {19"conversation-1": {"title": "新会话"},20}212223@router.get(24"/documented-conversations/{conversation_id}",25response_model=ConversationRead,26responses={27404: {28"model": ErrorResponse,29"description": "会话不存在",30},31},32)33async def get_conversation(34conversation_id: str,35) -> dict[str, str] | JSONResponse:36conversation = conversations.get(conversation_id)3738if conversation is None:39return JSONResponse(40status_code=404,41content={42"code": "conversation_not_found",43"message": "会话不存在",44},45)4647return {48"id": conversation_id,49"title": conversation["title"],50}
responses 主要补充文档,并不会自动把所有异常转换成这个结构。这里仍然需要在失败分支中明确返回 404;实际项目也可以由下一篇介绍的异常处理器统一生成错误响应。
除了 model 和 description,responses 还可以声明不同媒体类型、示例、响应头和 OpenAPI links。它也能为主成功响应补充说明,并与 response_model、status_code 合并生成最终文档。不过这些内容仍然只是契约描述,运行时代码必须返回与契约一致的状态码、媒体类型和数据结构。
12. 契约
响应模型应该面向调用方,而不是简单映射内部对象:
- 只公开客户端真正需要的字段;
- 时间、枚举和可空字段保持稳定格式;
- 敏感字段不进入公开模型;
- 列表接口考虑分页信息;
- 错误响应使用独立的错误契约;
- 不要频繁改变同一字段的数据类型。
对于 AI 接口,普通响应可以返回消息 ID、会话 ID、内容、模型标识和结束原因。供应商返回的完整原始对象不应未经筛选直接暴露给网页。