FastAPI-路由与参数

这篇讲什么

  • FastAPI 的入参只有四种来源:路径、查询串、请求头、Cookie,全部靠「函数签名 + 类型注解」声明,框架自动做转换、校验和文档。
  • 最关键的一条规矩:默认值只能写在参数上(= 1),不能写进 Query(...) 标记里,写错是注册路由时直接 AssertionError,不是运行时才发现。
  • 校验失败统一返回 422,detail 是一个列表,每项有 type / loc / msg / input / ctx,本篇把常见错误类型逐个跑出来对照。

声明入参的两种写法

  • 同一个「必填整型查询参数」有两种写法,效果一样:

    params_style.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    from typing import Annotated, Optional

    from fastapi import FastAPI, Query

    app = FastAPI()


    # 写法一:官方推荐,标记放 Annotated 里,默认值留在参数上
    @app.get("/v1")
    def style_annotated(page: Annotated[int, Query(ge=1)] = 1):
    return {"page": page}


    # 写法二:老写法,标记整个当默认值用
    @app.get("/v2")
    def style_default(page: int = Query(1, ge=1)):
    return {"page": page}

    实测两者都能跑,且写法二不会给任何 DeprecationWarning(本机 warnings.catch_warnings 里跑,告警条数 0):

    1
    2
    /old-style -> {'x': 1, 'y': 2}
    告警条数: 0
  • 那为什么还要用 Annotated?三个实际理由:

    • 能复用。把「一个分页参数」抽成一个类型别名,多个接口共用,老写法做不到:

      shared_query.py
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      from typing import Annotated

      from fastapi import Query

      PageQuery = Annotated[int, Query(ge=1, description="页码,从 1 开始")]
      SizeQuery = Annotated[int, Query(ge=1, le=100)]


      def list_items(page: PageQuery = 1, size: SizeQuery = 20):
      return {"page": page, "size": size}
    • 类型检查器认得。page: int = Query(1) 里 page 的声明类型是 int,但默认值实际是个 Query 对象,mypy/pyright 会报「int 不可能等于 Query」;Annotated 写法没有这个问题。

    • 默认值和校验说明分得开,读签名时一眼能看出哪个是默认值、哪个是约束。

  • ⚠️ 唯一会踩的坑,实测五种标记全都一样:

    1
    2
    3
    4
    5
    Cookie  -> AssertionError: `Cookie` default value cannot be set in `Annotated` for 'theme'. Set the default value with `=` instead.
    Header -> AssertionError: `Header` default value cannot be set in `Annotated` for 'xt'. Set the default value with `=` instead.
    Query -> AssertionError: `Query` default value cannot be set in `Annotated` for 'x'. Set the default value with `=` instead.
    Body -> AssertionError: `Body` default value cannot be set in `Annotated` for 'payload'. Set the default value with `=` instead.
    Path -> AssertionError: Path parameters cannot have a default value
    • 也就是说 Annotated[int, Query(default=5)] 是错的,要写 Annotated[int, Query(ge=0)] = 5。
    • 路径参数根本不能有默认值(它必须出现在 URL 里)。
    • 报错时机是「注册路由那一刻」,服务直接起不来,好处是不会留到线上才炸。

路径参数

  • 路径里的 {...} 会按声明的类型自动转换,实测:

    path_types.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
    import uuid
    from enum import Enum
    from typing import Annotated

    from fastapi import FastAPI, Path
    from pydantic import BaseModel


    class Kind(str, Enum):
    cat = "cat"
    dog = "dog"


    app = FastAPI()


    @app.get("/items/{item_id}")
    def by_int(item_id: int):
    return {"item_id": item_id, "type": type(item_id).__name__}


    @app.get("/orders/{order_uid}")
    def by_uuid(order_uid: uuid.UUID):
    return {"order_uid": str(order_uid), "version": order_uid.version}


    @app.get("/pets/{kind}")
    def by_enum(kind: Kind):
    return {"kind": kind.value, "raw": kind.name}


    @app.get("/hex/{token}")
    def by_pattern(token: Annotated[str, Path(pattern=r"^[0-9a-f]{6}$")]):
    return {"token": token}
    1
    2
    3
    4
    5
    6
    7
    8
    9
    /items/42   -> 200 {'item_id': 42, 'type': 'int'}
    /items/abc -> 422 type=int_parsing msg='Input should be a valid integer, unable to parse string as an integer'
    /items/1.5 -> 422 type=int_parsing # 小数也不会偷偷截断
    /orders/123e4567-e89b-12d9-a456-426614174000 -> 200 {'order_uid': '123e...', 'version': 1}
    /orders/not-a-uuid -> 422 type=uuid_parsing msg='Input should be a valid UUID, invalid character: found `n` at 0'
    /pets/cat -> 200 {'kind': 'cat', 'raw': 'cat'}
    /pets/bird -> 422 type=enum msg="Input should be 'cat' or 'dog'" ctx={'expected': "'cat' or 'dog'"}
    /hex/abcdef -> 200 {'token': 'abcdef'}
    /hex/XYZ -> 422 type=string_pattern_mismatch msg="String should match pattern '^[0-9a-f]{6}$'"
    • 传进来的确实是 int/UUID/Enum,不是字符串,type(item_id).__name__ 实测就是 int。
    • 枚举要继承 str, Enum 才能既当路径值又当字符串用;非法值直接 422,ctx.expected 会把允许的值列出来。
    • 正则约束走 Path(pattern=...)。老名字 regex= 实测还能跑但会告警:FastAPIDeprecationWarning: regex has been deprecated, please use pattern instead,新代码一律写 pattern=。
  • 路径参数名要和 URL 里的占位符完全一致,写错不会报错但拿不到值——FastAPI 会把它当成一个「同名查询参数」去找,实测把 item_id 写成 id 之后请求 /items/42 得到 422 type=missing, loc=['query','id']:

    1
    2
    3
    @app.get("/items/{item_id}")
    def by_int(id: int): ...
    # GET /items/42 -> 422 {'type': 'missing', 'loc': ['query', 'id'], 'msg': 'Field required'}

    排查「明明 URL 里有值却提示 Field required」时,第一件事就是核对这两个名字。更隐蔽的是它不会拒绝路径段,而是把路径当不存在、转去查询串里找 id,实测 /items/42?id=7 返回 200 {'id': 7}——线上表现就是「传了值却拿到另一个数」。

  • 一个容易被忽略的行为:路径参数声明了类型,匹配上路由但转换失败时返回的是 422,不是 404:

    1
    2
    3
    /users/me -> 200 {'who': 'me'}
    /users/7 -> 200 {'who': 'by_id', 'user_id': 7}
    /users/xx -> 422 # 不是 404

    对前端来说 422 意味着「路径格式不对」,跟「资源不存在」是两回事;要返回 404 得自己查库后 raise HTTPException(404)。

查询参数

  • 没出现在路径里的参数默认就是查询参数,实测各种形态:

    query_demo.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    from typing import Annotated, Optional

    from fastapi import FastAPI, Query

    app = FastAPI()


    @app.get("/q")
    def q(
    a: int, # 必填
    b: Annotated[str, Query(min_length=3, max_length=8)] = "abc",
    c: Annotated[Optional[list[int]], Query()] = None, # 可重复
    skip: Annotated[int, Query(ge=0, le=100, description="跳过几条")] = 0,
    old: Annotated[Optional[str], Query(deprecated=True)] = None,
    ):
    return {"a": a, "b": b, "c": c, "skip": skip, "old": old}
    1
    2
    3
    4
    5
    6
    7
    /q?a=1                 -> 200 {'a': 1, 'b': 'abc', 'c': None, 'skip': 0, 'old': None}
    /q -> 422 [{'type': 'missing', 'loc': ['query', 'a'], 'msg': 'Field required'}]
    /q?a=1&b=xy -> 422 type=string_too_short ctx={'min_length': 3}
    /q?a=1&c=1&c=2&c=3 -> 200 {'c': [1, 2, 3]}
    /q?a=1&skip=200 -> 422 type=less_than_equal ctx={'le': 100}
    /q?a=1&c=notint -> 422 type=int_parsing loc=['query', 'c', 0]
    /q?a=1&zzz=9 -> 200 # 多余的查询参数直接忽略
    • 必填就是「没有默认值」,不需要写 Query(...);要加约束才用标记。
    • 重复同名参数收进列表,条件是声明成 list[int];loc 里会带上出错的下标(['query','c',0] 表示第一个值不是整数)。
    • 多余的参数不会报错也不会进函数,想拒绝得自己校验(或者用 Request.query_params 手动看)。
  • Query 常用的参数(实测签名与行为):

    • alias:URL 上用的名字,函数里可以用合法的 Python 名:

      alias_demo.py
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      from typing import Annotated, Optional

      from fastapi import FastAPI, Query

      app = FastAPI()


      @app.get("/alias")
      def alias(q: Annotated[Optional[str], Query(alias="keyword")] = None):
      return {"q": q}
      1
      2
      /alias?keyword=猫 -> 200 {'q': '猫'}
      /alias?q=猫 -> 200 {'q': None} # 原名不认,只认 alias

      注意是替换而不是追加:写了 alias 之后原来的名字就不再接受。

    • min_length / max_length / pattern:字符串约束。

    • ge / gt / le / lt / multiple_of:数值约束。

    • deprecated=True:文档里划掉并标 deprecated,实测生成的 schema:

      1
      2
      3
      {'name': 'old', 'in': 'query', 'required': False,
      'schema': {'anyOf': [{'type': 'string'}, {'type': 'null'}], 'deprecated': True, 'title': 'Old'},
      'deprecated': True}
    • include_in_schema=False:不进文档,但接口照样能访问(实测 /v2/hidden 不在 paths 里,请求仍返回 200)。想真正下线接口要用 deprecated + 版本前缀,别指望藏起来。

    • validation_alias / serialization_alias:Pydantic v2 的别名机制,和 alias 的区别见 FastAPI-请求体与Pydantic校验。

  • 用法跟 Query 一样,只是标记换成 Header / Cookie:

    head_cookie.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    from typing import Annotated, Optional

    from fastapi import FastAPI, Header

    app = FastAPI()


    @app.get("/head")
    def head(user_agent: Annotated[Optional[str], Header()] = None,
    x_trace: Annotated[Optional[str], Header(convert_underscores=False)] = None,
    xt: Annotated[Optional[str], Header(alias="X-Trace-2")] = None):
    return {"user_agent": user_agent, "x_trace": x_trace, "xt": xt}
    1
    2
    发送 User-Agent: probe/1.0 + x_trace: abc + X-Trace-2: second
    -> {'user_agent': 'probe/1.0', 'x_trace': 'abc', 'xt': 'second'}
  • 三个必须记住的差别:

    • Header 默认会把下划线转成连字符再匹配,所以 user_agent 能收到 User-Agent(HTTP 头习惯用连字符,而 Python 变量名不能用连字符)。实测 TestClient 不带该头时拿到的是它自己塞的 user_agent: 'testclient'。

    • convert_underscores=False 就是字面名,实测这时参数 x_trace 只能收到名为 x_trace 的头,发 X-Trace 收到 None。

    • Cookie 没有连字符问题,参数名就是 cookie 名;缺失时走默认值,实测:

      1
      2
      带 cookie session=s-1, theme=dark -> {'session': 's-1', 'theme': 'dark'}
      不带 cookie -> {'session': None, 'theme': 'light'}
  • 要拿全部头或做原始校验,别一个个声明,直接用 Request:

    raw_request.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    from fastapi import FastAPI, Request

    app = FastAPI()


    @app.get("/raw")
    def raw(request: Request):
    return {
    "headers": dict(request.headers),
    "cookies": request.cookies,
    "query": dict(request.query_params),
    "client_ip": request.client.host if request.client else None,
    }

    request.headers 是大小写不敏感的 Headers 对象,dict(...) 会把它拍平成小写键。

路由的匹配顺序

  • FastAPI 是自上而下第一个匹配就用,不是「最具体的赢」。所以静态路径必须写在动态路径前面:

    order.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    from fastapi import FastAPI

    app = FastAPI()


    @app.get("/users/me")
    def me():
    return {"who": "me"}


    @app.get("/users/{user_id}")
    def user_by_id(user_id: int):
    return {"who": "by_id", "user_id": user_id}
    1
    2
    /users/me -> 200 {'who': 'me'}
    /users/7 -> 200 {'who': 'by_id', 'user_id': 7}
    • 如果把 /users/me 挪到后面,me 会被 {user_id} 抢走,然后因为不是整数返回 422——这是 FastAPI 最经典的顺序 bug。

    • 用 APIRouter 分包时同理:先 include_router 的先生效,实测两个 router 互换注册顺序,同一个 /x/5 的结果就从 {'from': 'a'}(动态路由抢走)变成 {'from': 'b'}(静态命中):

      1
      2
      先 include 动态路由再静态: GET /x/5 -> {'from': 'a'}
      先 include 静态再动态: GET /x/5 -> {'from': 'b'}

      跨文件的顺序问题最难查,所以路由清单要按「静态在前」的约定排。

  • 路径里还能声明可选段和正则段,但 FastAPI 不像 Flask 那样写 <int:x>,一律靠 {name} + 类型注解;需要复杂匹配就配 Path(pattern=...)。

  • redirect_slashes 默认开着,斜杠不匹配时补一个 307(实测细节见 FastAPI-基本入门的「工程骨架」一节)。关掉它就在构造时传 FastAPI(redirect_slashes=False),实测这时少一个斜杠就是纯 404:

    1
    2
    路由是 @app.get("/sl/")
    redirect_slashes=False: GET /sl -> 404 | GET /sl/ -> 200
  • 给路由起名字方便反向解析:

    named_route.py
    1
    2
    3
    4
    5
    6
    7
    8
    from fastapi import FastAPI

    app = FastAPI()


    @app.get("/users/{user_id}", name="user_detail")
    def user_detail(user_id: int):
    return {"user_id": user_id}
    1
    app.url_path_for('user_detail', user_id=3) -> '/users/3'

    模板里生成链接、测试里避免硬编码路径都用得上;不传 name 时要用函数名,实测 url_path_for("get_user", user_id=3) 也能拿到 /users/3。

分组与文档标记

  • APIRouter 上能带一组路由的公共设置,实测 prefix、tags、deprecated、include_in_schema 都生效:

    router_meta.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    from fastapi import APIRouter, FastAPI

    router = APIRouter(prefix="/v2", tags=["v2"], deprecated=True)


    @router.get("/shown")
    def shown():
    return {"ok": True}


    @router.get("/hidden", include_in_schema=False)
    def hidden():
    return {"ok": True}


    app = FastAPI()
    app.include_router(router)
    1
    2
    3
    /v2/shown 的 deprecated: True
    /v2/hidden 在文档里: False | 但能访问: 200
    paths: [..., '/v2/shown']
  • @app.get(...) 能传的参数实测有 24 个,常用的记一下就行:

    1
    2
    3
    4
    5
    6
    path, response_model, status_code, tags, dependencies, summary, description,
    response_description, responses, deprecated, operation_id,
    response_model_include, response_model_exclude, response_model_by_alias,
    response_model_exclude_unset, response_model_exclude_defaults,
    response_model_exclude_none, include_in_schema, response_class, name,
    callbacks, openapi_extra, generate_unique_id_function

    其中 response_model_* 那六个是响应裁剪,归到 FastAPI-响应模型与OpenAPI文档 讲。

  • 单个接口的文档信息在装饰器里给,常用的四个:

    • summary:一句话标题(文档列表里显示)。
    • description:支持 Markdown,写业务规则的地方。
    • response_model:响应结构,见 FastAPI-响应模型与OpenAPI文档。
    • openapi_extra:往这个接口的 OpenAPI 片段里塞任意字段,官方文档没写的东西用它补。
  • 函数文档字符串会被自动当成 description,这是最省事的写法:

    docstring.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    from fastapi import FastAPI

    app = FastAPI()


    @app.get("/goods")
    def goods():
    """返回商品列表。

    支持 `page` 和 `size` 两个分页参数,`size` 最大 100。
    """
    return {"goods": []}
    1
    2
    3
    4
      description = '返回商品列表。

    支持 `page` 和 `size` 两个分页参数,`size` 最大 100。'
    summary = 'Goods'
    • docstring 第一段进 description,Markdown 原样保留。
    • 没写 summary 时它自动取函数名的 Title Case(goods → Goods),所以函数名会直接出现在文档标题上,起名字时顺手把可读性带上。

422 响应体怎么读

  • 所有校验失败都是同一个结构,实测一个缺参的例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    {
    "detail": [
    {
    "type": "missing",
    "loc": ["query", "a"],
    "msg": "Field required",
    "input": null
    }
    ]
    }
    • detail 是列表,多个字段出错就有多条。
    • loc 第一项是来源(path / query / header / cookie / body),后面是字段路径;列表元素出错会带下标,实测 ["query", "c", 0]。
    • input 是原始输入值(未转换的),前端可以直接回填。
    • ctx 只有部分类型有,用来放约束值,实测 less_than_equal 带 {'le': 100}、enum 带 {'expected': "'cat' or 'dog'"}。
  • 本机实测到的 type 取值(够覆盖大部分场景):

    1
    2
    3
    4
    5
    6
    7
    missing                 字段没给
    int_parsing 转不成整数
    uuid_parsing 转不成 UUID
    enum 不在枚举里
    string_too_short 长度小于 min_length
    string_pattern_mismatch 正则不匹配
    less_than_equal 大于 le
    • 实测 0.162 的输出里没有 url 字段(Pydantic 文档里那种带 url 的错误是它自己 model_validate 抛异常时的形态,FastAPI 序列化后只留上面这几项),所以排查要靠 type 去查 Pydantic 的错误清单。
  • 想改这个结构(比如统一成 {code, message}),有两条路:只改文案用 Query(description=...),改结构要覆盖 422 的处理函数,写法在 FastAPI-依赖注入与异常中间件。

参考

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