FastAPI-响应模型与OpenAPI文档
这篇讲什么
response_model是响应这一层的「安全网」:它按输出模型再校验一遍、裁掉没声明的字段,所以密码这类字段不会顺着return user漏出去。- 输出侧的坑和输入侧不一样:响应校验失败是
ResponseValidationError→ 客户端看到 500,不是 422;status_code=204时返回值会被直接丢掉。 - 文档三件套怎么改、怎么藏、
root_path与strict_content_type这两个默认值会带来什么行为变化,全部实测过。
response_model 做对了什么
-
最典型的需求:入参有密码,出参绝不能有。声明了
response_model之后可以直接把入参模型返回:response_model.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28from decimal import Decimal
from typing import Optional
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class UserIn(BaseModel):
username: str
password: str
realname: Optional[str] = None
role: str = "user"
balance: Decimal = Decimal("0")
extra_note: str = "内部字段"
class UserOut(BaseModel):
username: str
realname: Optional[str] = None
role: str
balance: Decimal
def signup(user: UserIn):
return user # 直接返回入参模型,靠 response_model 裁掉密码1
2POST /signup {"username":"lilei","password":"123456","realname":"李雷","balance":"88.80"}
-> 201 {"username": "lilei", "realname": "李雷", "role": "user", "balance": "88.80"}-
password、extra_note都不在输出里,不用手写del。 -
反过来,不写
response_model就没人帮你裁,实测:1
GET /dict-leak -> 200 {'username': 'u', 'password': 'secret'}
所以接口一律显式声明输出模型,这条既是规范也是防泄露。
-
-
也可以不写
response_model=,直接把返回类型注解成模型,效果一样且更简洁:return_annotation.py 1
2
3
def signup2(user: UserIn) -> UserOut:
return user官方现在推荐这种写法(少一个参数,IDE 也认)。两种都实测可用。
-
输出模型还会再校验并转换一次,实测返回字符串但模型是
int:1
2@app.get("/coerce", response_model=Out2) # Out2.n: int
返回 {"n": "123"} -> {'n': 123}好处是 ORM 里的
Decimal/datetime能被统一成 JSON 形态;坏处是「转换不过」会炸在服务端,见下面第 4 节。
裁剪字段的六个开关
-
装饰器上有六个
response_model_*参数,实测四种效果:exclude_switches.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
def signup_exclude(user: UserIn):
return user
def signup_none(user: UserIn):
return UserOut(username=user.username, balance=user.balance,
role="user", realname=None)
def signup_unset(user: UserIn):
return UserOut(username=user.username, role="user", balance=Decimal("1"))1
2
3/signup-exclude -> 200 {"username": "lilei", "realname": "李雷", "balance": "88.80"}
/signup-none -> 200 {"username": "lilei", "role": "user", "balance": "88.80"}
/signup-unset -> 200 {"username": "lilei", "role": "user", "balance": "1"} -
六个开关的分工(默认值都是「不裁」):
-
response_model_include/response_model_exclude:白名单 / 黑名单,传字段名集合。 -
⚠️ 嵌套路径要特别小心,实测三种给法的结果:
1
2
3
4
5模型 Person{name, secret, address{city, zip_code, geo}}
include={"name", "address"} -> {'name': 'lilei', 'address': {'city': 'SH', 'zip_code': '000000', 'geo': '1,2'}}
include={"name", "address.city"} -> {'name': 'lilei'} # 父字段没在名单里,整块被丢
exclude={"secret", "address.geo"} -> {'name': 'lilei', 'address': {'city': 'SH', 'zip_code': '000000', 'geo': '1,2'}}也就是说 include 里只写
address.city不生效(要连父字段一起给),而exclude里的嵌套路径实测也没裁掉geo。要精确控制子字段,最稳的是单独定义一个精简的输出模型,别指望这两个参数。 -
response_model_exclude_none:把值为None的字段整个去掉,适合「不想让响应里出现一堆 null」。 -
response_model_exclude_unset:只输出显式传过值的字段,适合 PATCH 这类「改了哪个返回哪个」。 -
response_model_by_alias:默认True,按别名输出;想回到 Python 字段名就设False。 -
response_model_exclude_defaults:连「等于默认值」的字段也去掉,比exclude_unset更狠。
-
-
想临时按请求决定裁什么,别在装饰器上写死,用
model_dump自己控制:manual_dump.py 1
2
3
4
5
6
def profile(detail: bool = False):
data = UserOut(username="u", role="admin", balance=Decimal("1"),
realname=None).model_dump(
exclude_none=not detail, exclude={"balance"} if not detail else set())
return data实测这样输出与开关版一致,好处是条件逻辑写在代码里、能在测试中断言。
直接返回 Response
-
需要完全掌控响应(状态码、头、媒体类型)时,返回
Response的子类,实测八种:responses.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46from fastapi import FastAPI, File, Response, UploadFile
from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse,
PlainTextResponse, RedirectResponse,
StreamingResponse)
app = FastAPI()
def html():
return "<h1>标题</h1>"
def plain():
return PlainTextResponse("纯文本", media_type="text/plain")
def redirect():
return RedirectResponse("/plain")
def stream():
def gen():
for i in range(3):
yield "chunk%d|" % i
return StreamingResponse(gen(), media_type="text/plain")
def file_ep():
return FileResponse(__file__, filename="probe.json")
def sethead(response: Response):
response.headers["X-Custom"] = "abc"
response.set_cookie("theme", "dark", max_age=60, samesite="lax")
return {"ok": True}
def json_custom():
return JSONResponse({"a": 1}, status_code=202, headers={"X-From": "json"})1
2
3
4
5
6
7
8/html -> 200 | ct=text/html; charset=utf-8 | len=15
/plain -> 200 | ct=text/plain; charset=utf-8 | len=9
/redirect -> 307 | ct=None | len=0
/stream -> 200 | ct=text/plain; charset=utf-8 | len=21
/file -> 200 | ct=application/json | len=9090
Content-Disposition: attachment; filename="probe.json"
/sethead -> 200 | X-Custom=abc | Set-Cookie: theme=dark; Max-Age=60; Path=/; SameSite=lax
/json-custom -> 202 | ct=application/json | len=7-
重定向是 307 不是 302,实测
RedirectResponse默认给 307(保留方法与请求体),要 302 得传status_code=302。 -
FileResponse的Content-Type是按文件名猜的(probe.json→application/json),filename=决定浏览器另存为什么名字;它还会自动处理Range、ETag与Last-Modified。 -
StreamingResponse接同步生成器就行,但同步生成器里做阻塞 IO 会卡事件循环,要换成iterate_in_threadpool(...),实测:stream_threadpool.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import time
from fastapi import FastAPI
from fastapi.concurrency import iterate_in_threadpool
from fastapi.responses import StreamingResponse
app = FastAPI()
def sync_gen():
for i in range(3):
time.sleep(0.01) # 假装在读文件/查同步库
yield "row%d" % i
async def stream_tp():
return StreamingResponse(iterate_in_threadpool(sync_gen()), media_type="text/plain")1
GET /stream-threadpool -> 200 row0row1row2
直接把
sync_gen()交给StreamingResponse也能出正确内容,差别在于前者把每次next()丢进线程池、事件循环不被占住(异步一节有对照数据,见 FastAPI-基本入门)。 -
想给普通返回值加头/Cookie,不用返回
Response,直接在参数里声明response: Response再改它,实测有效——这个response只是「待会儿要发出去的那个壳」,别return它。
-
-
204 的坑实测得很干脆:返回值会被丢掉。
1
2
3@app.get("/two-oh-four-bad", status_code=204)
def two_bad(): return {"should": "not appear"}
-> 204 body 长度 0 ''按 HTTP 语义 204 就是没有响应体,所以删除类接口写
status_code=204就别指望返回 JSON;要回消息就改成 200。
响应校验失败是 500
-
输出不符合
response_model时,Pydantic 抛ResponseValidationError,客户端看到的是 500:1
2
3
4
5@app.get("/bad-out", response_model=Out2) # Out2.n: int
def bad_out(): return {"n": "abc"}
服务端 -> ResponseValidationError: 1 validation error
客户端 -> 500 Internal Server Error- 这点和入参的 422 完全不同:入参错是用户的错,出参错是服务的错,所以它必须是 500,也不该被
exception_handler包装成 4xx 蒙混过去。 - 常见触发场景就三个:ORM 字段名和模型对不上、返回了
None但模型不允许、Decimal/datetime之外的自定义类型没配序列化。 - 调试时把
TestClient(app, raise_server_exceptions=False)用起来,能拿到 500 的响应继续跑断言;不加这个参数会直接把异常抛到测试里。
- 这点和入参的 422 完全不同:入参错是用户的错,出参错是服务的错,所以它必须是 500,也不该被
-
校验错误里的
loc在响应侧是从字段名开始的(实测('n',)),不像请求侧带body前缀,写日志时别按同一套解析。
文档与元信息
-
应用级的元信息全部进
info与顶层字段,实测一份配齐的:meta_app.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15from fastapi import FastAPI
meta = FastAPI(
title="商店 API",
summary="一句话简介",
description="## 详细说明\n支持 **Markdown**",
version="2.1.0",
openapi_tags=[{"name": "users", "description": "用户相关"},
{"name": "items", "description": "商品相关"}],
contact={"name": "维护人", "email": "ops@example.com"},
license_info={"name": "MIT"},
terms_of_service="https://example.com/tos",
servers=[{"url": "https://api.example.com", "description": "生产"}],
openapi_external_docs={"url": "https://example.com/docs", "description": "更多文档"},
)1
2
3
4
5
6
7
8info: {"title": "商店 API", "summary": "一句话简介",
"description": "## 详细说明\n支持 **Markdown**",
"termsOfService": "https://example.com/tos",
"contact": {"name": "维护人", "email": "ops@example.com"},
"license": {"name": "MIT"}, "version": "2.1.0"}
servers: [{'url': 'https://api.example.com', 'description': '生产'}]
tags: [{'name': 'users', 'description': '用户相关'}, {'name': 'items', ...}]
externalDocs: {'description': '更多文档', 'url': 'https://example.com/docs'}openapi_tags是给「分组」写描述的,路由上的tags=["users"]只是挂名字,两处的名字要对得上。description支持 Markdown,Swagger UI 会渲染出来,是放「鉴权怎么用、分页规则、错误码表」的地方。
-
单个接口上的文档参数实测:
op_meta.py 1
2
3
4
5
6
)
def meta_ep():
return {"username": "u", "role": "user", "balance": "1"}1
2
3
4
5{"summary": "拿 x", "description": "详细描述", "deprecated": true, "tags": ["users"],
"responses": {"200": {"description": "成功",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/UserOut"}}}},
"404": {"description": "没找到"}},
"operationId": "meta_ep_x_get"}responses=是「额外声明别的状态码」的地方,文档里能出现、还能带model,但它不会自动帮你返回,实际状态码还得自己raise。operationId是「函数名 + 路径 + 方法」拼的(实测meta_ep_x_get),要固定就传operation_id=。
-
三个内置路径的开关与改法在 FastAPI-基本入门里实测过(
docs_url=None三个要一起关),这里补两个进阶用法:- 换 UI:
docs_url=None后自己挂一个get("/docs")返回别的 Swagger UI 页面,官方文档给了完整示例。 - 调 UI 参数:
swagger_ui_parameters={"persistAuthorization": True},实测能传,改的是注入到/docs里的那段 JS 配置。 - 给文档加登录:
FastAPI(dependencies=[Depends(verify_admin)], docs_url=...)会把这层依赖挂到所有路由上,粒度太粗;正确做法是只给 docs 路由单独包一层,见 FastAPI-依赖注入与异常中间件。
- 换 UI:
root_path 与反向代理
-
服务挂在
/api/v1这种前缀后面时,root_path决定文档与openapi.json里的地址对不对,实测:root_path_demo.py 1
2
3
4
5
6rp = FastAPI(root_path="/api/v1")
def ping():
return {"ok": True}1
2
3servers: [{'url': '/api/v1'}]
paths: ['/ping']
GET /ping 里的 request.url.path -> '/ping'- 也就是说应用级
root_path不改真实路由,也不改request.url.path,只往servers里写前缀,让 Swagger UI 的「Try it out」打到正确地址。 - 但用命令行
uvicorn --root-path /api/v1时行为不一样,实测request.url.path会变成/api/v1/ping(URL 里带上了前缀)。两种root_path不是一回事,别混用,细节见 FastAPI-WebSocket、测试与部署。 - nginx 剥掉前缀转发时,还要配合 uvicorn 的
--root-path或--proxy-headers,否则url_path_for、重定向、OAuth 回调都会缺前缀,部署侧的写法见 FastAPI-WebSocket、测试与部署。
- 也就是说应用级
两个新的默认值
-
0.162 的
FastAPI()构造参数实测有 37 个,其中两个会直接影响行为,值得单独记:1
2
3separate_input_output_schemas 默认=True
strict_content_type 默认=True
telemetry 默认=None -
strict_content_type=True(新版默认打开)的实测差别,只有一种情况变了:1
2
3
4
5
6
7
8
9
10strict_content_type=True
json 正确 -> 200 {"n":1}
text/plain -> 422 model_attributes_type
无 Content-Type -> 422 model_attributes_type # 关键差别
application/xml -> 422 model_attributes_type
urlencoded -> 422 model_attributes_type
strict_content_type=False
无 Content-Type -> 200 {"n":1} # 照样当 JSON 解析
其余四种 -> 与 True 相同- 结论:「不带 Content-Type 的 JSON 请求」在新版会被拒,老脚本用
requests.post(url, data=json.dumps(...))(不会自动加application/json)就会撞上 422。 - 修法有两种:客户端补上
Content-Type: application/json(推荐),或服务端FastAPI(strict_content_type=False)回到宽容模式。
- 结论:「不带 Content-Type 的 JSON 请求」在新版会被拒,老脚本用
-
separate_input_output_schemas=True的意思是「输入和输出模型分别生成 schema」,实测同一个模型时两边都指向$ref: Dual,只有当输入/输出用了别名或不同模型时才会拆成两个定义;components 里实测多出的是HTTPValidationError与ValidationError这两个框架自带模型。 -
telemetry是原生 OpenTelemetry 配置入口,配套的opentelemetry-*(实测opentelemetry-api 1.45.2)已随fastapi[standard]装好。实测四种传法都不会报错、接口照常 200:1
2
3
4默认 -> 200 {'ok': True}
telemetry=None -> 200 {'ok': True}
telemetry={"tracing": False} -> 200 {'ok': True}
telemetry={"nope": 1} -> 200 {'ok': True} # 乱写的键也不拦也就是说它是个宽松的 dict 配置,效果要接了 OpenTelemetry provider 才看得出来,别指望传错键会启动失败。接入方式见 FastAPI-WebSocket、测试与部署的监控一节。