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
    28
    from 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


    @app.post("/signup", response_model=UserOut, status_code=201)
    def signup(user: UserIn):
    return user # 直接返回入参模型,靠 response_model 裁掉密码
    1
    2
    POST /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
    @app.post("/signup2")
    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
    @app.post("/signup-exclude", response_model=UserOut, response_model_exclude={"role"})
    def signup_exclude(user: UserIn):
    return user


    @app.post("/signup-none", response_model=UserOut, response_model_exclude_none=True)
    def signup_none(user: UserIn):
    return UserOut(username=user.username, balance=user.balance,
    role="user", realname=None)


    @app.post("/signup-unset", response_model=UserOut, response_model_exclude_unset=True)
    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
    @app.get("/profile")
    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
    46
    from fastapi import FastAPI, File, Response, UploadFile
    from fastapi.responses import (FileResponse, HTMLResponse, JSONResponse,
    PlainTextResponse, RedirectResponse,
    StreamingResponse)

    app = FastAPI()


    @app.get("/html", response_class=HTMLResponse)
    def html():
    return "<h1>标题</h1>"


    @app.get("/plain")
    def plain():
    return PlainTextResponse("纯文本", media_type="text/plain")


    @app.get("/redirect")
    def redirect():
    return RedirectResponse("/plain")


    @app.get("/stream")
    def stream():
    def gen():
    for i in range(3):
    yield "chunk%d|" % i
    return StreamingResponse(gen(), media_type="text/plain")


    @app.get("/file")
    def file_ep():
    return FileResponse(__file__, filename="probe.json")


    @app.get("/sethead")
    def sethead(response: Response):
    response.headers["X-Custom"] = "abc"
    response.set_cookie("theme", "dark", max_age=60, samesite="lax")
    return {"ok": True}


    @app.get("/json-custom")
    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
      18
      import 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


      @app.get("/stream-threadpool")
      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 的响应继续跑断言;不加这个参数会直接把异常抛到测试里。
  • 校验错误里的 loc 在响应侧是从字段名开始的(实测 ('n',)),不像请求侧带 body 前缀,写日志时别按同一套解析。

文档与元信息

  • 应用级的元信息全部进 info 与顶层字段,实测一份配齐的:

    meta_app.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    from 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
    8
    info: {"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
    @meta.get("/x", tags=["users"], summary="拿 x", description="详细描述",
    response_description="返回体说明", deprecated=True,
    responses={404: {"description": "没找到"},
    200: {"description": "成功", "model": UserOut}})
    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-依赖注入与异常中间件。

root_path 与反向代理

  • 服务挂在 /api/v1 这种前缀后面时,root_path 决定文档与 openapi.json 里的地址对不对,实测:

    root_path_demo.py
    1
    2
    3
    4
    5
    6
    rp = FastAPI(root_path="/api/v1")


    @rp.get("/ping")
    def ping():
    return {"ok": True}
    1
    2
    3
    servers: [{'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
    3
    separate_input_output_schemas  默认=True
    strict_content_type 默认=True
    telemetry 默认=None
  • strict_content_type=True(新版默认打开)的实测差别,只有一种情况变了:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    strict_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) 回到宽容模式。
  • 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、测试与部署的监控一节。

参考

本文结束 感谢您的阅读
正在加载今日诗词....