FastAPI

用类型注解声明请求、响应与依赖关系,构建可验证、可测试并自动生成OpenAPI契约的Python API。

难度 进阶 时长 标准深度约10分钟
版本 FastAPI 0.141.1 / Python 3.14
what

FastAPI是一个基于ASGI的Python API框架。它从类型注解中提取请求规则、响应结构和OpenAPI描述。

when

当HTTP服务以JSON契约、输入验证、依赖组合和异步I/O为中心时,可以选择FastAPI。

how

用Pydantic模型描述请求与响应,用Depends组合认证和资源,再通过HTTP测试验证状态码、响应体和契约。

是什么,为什么存在

FastAPI 是一个构建HTTP API的Python Web框架。它使用Starlette处理Web与并发机制,使用Pydantic解析和验证数据,并把路由声明转换为OpenAPI文档。一个函数签名同时服务于运行时、编辑器和接口文档,减少了三套描述逐渐分叉的机会。

FastAPI要解决的核心问题不是“怎样返回JSON”,而是怎样让API边界保持明确。路径参数来自URL,简单标量通常来自查询字符串,Pydantic模型通常来自请求体,而显式的Header、Cookie、Query和Body元数据可以覆盖默认推断。解析失败时,请求会在进入业务处理函数之前得到结构化的422响应。

FastAPI本身是应用框架,不是生产网络服务器。 部署时仍要由兼容ASGI的服务器加载应用,并单独配置进程、代理、超时和观测边界。

它适合契约清晰的JSON服务、内部微服务和需要等待数据库或外部API的I/O密集型端点。需要内置管理后台、模板系统和完整ORM约定的应用,可能更适合Django;只有少量固定端点的脚本,也未必需要完整框架。框架选择应由接口边界和运维需求决定,而不是未经上下文限定的性能排名。

Python类型注解本身不会验证网络输入。FastAPI在运行时读取这些注解,并调用Pydantic完成转换与校验,因此item_id: int同时是Python类型信息和请求解析规则。业务授权、数据库唯一性和并发写入不变量仍需单独实现,不能由类型注解替代。

工作原理

FastAPI应用是一个 ASGI 应用。ASGI服务器把请求事件交给应用,路由器根据HTTP方法与路径选择路径操作函数,依赖系统先解析所需值,随后才调用处理函数。返回值经过响应序列化后变成ASGI响应事件。

参数来源由函数签名与路由模板共同决定。名称出现在路径模板中的参数来自路径;str、int、bool等简单类型通常来自查询字符串;Pydantic模型来自JSON请求体。Annotated可以把Python类型与FastAPI元数据放在一起,同时保留编辑器和静态检查器看到的原始类型。

依赖注入(dependency injection) 通过Depends声明处理函数运行前需要的值。依赖也可以声明子依赖,因此认证、租户选择、数据库会话和共享查询参数可以组成一张有向图。依赖可以抛出HTTPException提前结束请求,也可以通过一次yield在请求前获取资源、在请求后清理资源。

默认情况下,同一个依赖可调用对象在一次请求中只执行一次,结果会被需要它的节点复用。这种缓存只存在于当前请求,不是跨请求缓存;需要同一请求内重新求值时,才使用Depends(..., use_cache=False)。测试覆盖也按依赖可调用对象的身份定位,所以覆盖键必须是原始函数本身。

处理函数的返回注解或装饰器上的response_model会建立 响应模型(response model) 。FastAPI根据它验证、序列化并过滤返回数据;当两者同时存在时,response_model优先。输出过滤是重要的安全边界,但前提是公开模型只声明允许返回的字段。

这些声明还会生成 OpenAPI 文档。路径、参数、请求体、响应体和依赖引入的安全要求都会进入模式,默认可在/openapi.json获取。Swagger UI和ReDoc是该模式的视图,不是另一份独立契约。

async def路径操作由事件循环直接等待,适合调用支持await的客户端。普通def路径操作和普通同步依赖会在线程池中执行,适合必须使用阻塞库的边界。处理函数内部直接调用的普通辅助函数不会被FastAPI自动移入线程池,所以async def中的一次阻塞调用仍会阻塞事件循环。

示例

路径参数与查询参数

第一个应用依靠签名推断两个参数的来源。测试客户端在进程内调用ASGI应用,不需要监听真实端口。

parameter_sources.py
from fastapi import FastAPI
from fastapi.testclient import TestClient

app = FastAPI()


@app.get("/items/{item_id}")
def read_item(item_id: int, details: bool = False) -> dict[str, int | bool]:
    return {"item_id": item_id, "details": details}


client = TestClient(app)

valid = client.get("/items/7", params={"details": "true"})
print(valid.status_code, valid.json())

invalid = client.get("/items/not-an-int")
error = invalid.json()["detail"][0]
print(invalid.status_code, error["loc"], error["type"])
200 {'item_id': 7, 'details': True}
422 ['path', 'item_id'] int_parsing

item_id的名称出现在路由模板中,所以来自路径;带默认值的details来自查询字符串。FastAPI把文本true转为布尔值,把无法转换为整数的路径值报告为int_parsing错误,错误位置明确指向path.item_id。

分离输入模型与输出模型

输入模型接受供应商备注,但公开响应模型没有这个字段。响应过滤由路由声明完成,不依赖处理函数记得手工删除内部字段。

request_response_models.py
from fastapi import FastAPI, status
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field


class ProductIn(BaseModel):
    name: str = Field(min_length=2)
    price_cents: int = Field(gt=0)
    supplier_note: str


class ProductOut(BaseModel):
    name: str
    price_cents: int


app = FastAPI()


@app.post("/products", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(product: ProductIn) -> ProductIn:
    return product


client = TestClient(app)
created = client.post(
    "/products",
    json={"name": "Keyboard", "price_cents": 8900, "supplier_note": "internal"},
)
print(created.status_code, created.json())

invalid = client.post(
    "/products",
    json={"name": "Keyboard", "price_cents": 0, "supplier_note": "internal"},
)
error = invalid.json()["detail"][0]
print(invalid.status_code, error["loc"], error["type"])
201 {'name': 'Keyboard', 'price_cents': 8900}
422 ['body', 'price_cents'] greater_than

第一次请求返回201,而supplier_note没有出现在响应中。第二次请求在处理函数运行前失败,因为price_cents没有满足大于零的约束;客户端因此得到可定位到body.price_cents的错误。

认证依赖与测试覆盖

依赖可以集中读取请求头并拒绝请求。测试通过app.dependency_overrides替换外部边界,而不需要给每个路径操作增加测试分支。

dependencies.py
from typing import Annotated

from fastapi import Depends, FastAPI, Header, HTTPException, status
from fastapi.testclient import TestClient

app = FastAPI()


def current_user(x_api_key: Annotated[str | None, Header()] = None) -> str:
    if x_api_key != "secret-key":
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="invalid API key",
        )
    return "ada"


Viewer = Annotated[str, Depends(current_user)]


@app.get("/reports/{name}")
def read_report(name: str, viewer: Viewer) -> dict[str, str]:
    return {"report": name, "viewer": viewer}


client = TestClient(app)
print(client.get("/reports/daily").status_code)
print(client.get("/reports/daily", headers={"X-API-Key": "secret-key"}).json())

app.dependency_overrides[current_user] = lambda: "test-user"
print(client.get("/reports/daily").json())
app.dependency_overrides.clear()
401
{'report': 'daily', 'viewer': 'ada'}
{'report': 'daily', 'viewer': 'test-user'}

缺少正确请求头时,依赖抛出的HTTPException阻止处理函数运行。覆盖依赖后,同一端点无需密钥即可测试;最后清空覆盖很关键,否则之后的测试会继续绕过认证。

yield依赖的复用与清理

带yield的依赖把资源获取与释放放在同一个函数中。这里同一依赖在一个处理函数里声明两次,但默认请求缓存只打开一次会话。

yield_dependency.py
from typing import Annotated, Iterator

from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient

events: list[str] = []
app = FastAPI()


def open_session() -> Iterator[str]:
    events.append("open")
    try:
        yield "session-1"
    finally:
        events.append("close")


Session = Annotated[str, Depends(open_session)]


@app.get("/session")
def inspect_session(primary: Session, secondary: Session) -> dict[str, bool]:
    events.append("handler")
    return {"same_value": primary is secondary}


response = TestClient(app).get("/session")
print(response.status_code, response.json())
print(events)
200 {'same_value': True}
['open', 'handler', 'close']

事件顺序表明资源先打开,随后处理请求,最后进入finally清理。两个参数得到同一个缓存值,因此same_value为True;下一次HTTP请求会建立自己的依赖缓存和资源生命周期。

陷阱

修复方法: 用默认值表达是否必填,例如value: str | None = None。对路径、查询、请求头和请求体分别测试缺失、显式空值与格式错误,因为它们的传输语义不同。

修复方法: 为外部响应定义字段允许列表,并用返回注解或response_model应用它。测试应断言敏感字段不存在,而不只是断言期望字段存在。

修复方法: 有异步客户端时使用它并正确await;必须使用阻塞库时,把边界放进普通def路径操作,或显式交给线程执行。负载测试要观察事件循环停顿和取消路径,不能只检查单个请求能否返回。

修复方法: 在依赖中认证主体,并让数据查询同时按资源标识和主体或租户范围过滤。测试至少覆盖他人的有效标识,因为格式正确的越权请求最容易被只测422的套件漏掉。

修复方法: 在fixture的finally中删除特定覆盖或清空映射,并运行随机顺序测试。覆盖应指向原始依赖可调用对象;覆盖包装函数或另一个等价函数不会命中依赖图中的节点。

深入 依赖图、缓存与清理范围

依赖图、缓存与清理范围

FastAPI根据可调用对象及其参数递归构建依赖图。一个公共子依赖被多个上层依赖引用时,默认只在当前请求中求值一次,并把同一结果传给所有使用方。这个行为适合数据库会话和当前用户;对随机数或必须重新读取的状态关闭缓存前,应先确认重复执行不会破坏资源所有权。

带yield的依赖只能产生一个值。默认的scope="request"会在响应发送后运行退出代码;scope="function"则在路径操作函数返回后、响应发送前清理。流式响应如果还要读取该资源,就不能过早选择函数范围。

依赖退出代码必须放进finally,异常处理后若不转换为新的HTTP错误,通常应重新抛出原异常。吞掉异常会让框架看不到真实失败,也会模糊事务是提交还是回滚。资源依赖的测试应覆盖处理函数抛错,而不只是成功响应。

依赖覆盖按原始可调用对象查找,不按函数名称或签名查找。这使测试可以替换认证、时钟或数据库边界,同时保留路径操作本身。覆盖映射属于应用对象的可变全局测试状态,因此fixture必须负责恢复它。

OpenAPI是可测试的接口产物

FastAPI从路径操作和Pydantic模型生成OpenAPI,而不是从注释猜测接口。模式描述可接受的请求和承诺的响应,但不会自动表达数据库约束、跨字段业务规则的全部含义或授权策略。无法从类型系统得出的规则仍需写进领域代码、描述和测试。

/openapi.json可以作为契约测试输入。团队可以断言操作标识、参数必填性、响应状态和安全方案没有意外变化,再用真实HTTP测试验证实现行为。只给文档UI截图做审查会漏掉机器可见的破坏性变更。

输入模型和输出模型应分别命名,因为二者通常不是同一个契约。创建请求可能包含密码或内部命令字段,公开响应则可能增加服务器生成的标识和时间戳,但必须删除秘密。复用一个“万能模型”会把内部存储结构与外部API锁在一起。

版本控制也不能只靠在路径前加/v2。如果必填字段、默认值、枚举成员或错误状态改变,客户端观察到的契约已经变化。审查生成代码时,应比较OpenAPI差异,并为兼容性决定留下显式记录。

验证边界之外

请求验证负责把不可信字节转换为已知形状,并返回可定位的客户端错误。它不负责证明用户身份、对象所有权、库存未被并发请求消耗或写入满足数据库唯一约束。这些规则分别属于认证与授权、事务和数据库约束。

清晰的路径操作会协调这些边界,而不是承载所有实现细节。依赖提供请求上下文,Pydantic模型处理传输形状,领域服务执行规则,数据库维护并发下的不变量,响应模型限制公开表示。这样的分工也让AI生成的局部代码更容易逐层验证。

路由顺序与应用组合

路由不仅是一组互不相关的装饰器。路由器按注册结果匹配方法和路径,因此静态路径与动态路径重叠时,注册顺序会影响选择。例如,/users/me应放在/users/{user_id}前面,否则文本me可能先被当成路径参数处理。

APIRouter可以把前缀、标签、依赖和响应声明应用到一组路径操作。应用通过include_router()组合这些路由,而不是在每个文件中创建互不相连的FastAPI实例。组合点也是审查版本前缀和全组认证要求的合适位置。

路由层级适合声明的内容
FastAPI应用全局中间件、生命周期与顶层依赖
APIRouter功能前缀、标签与一组共享依赖
路径操作具体输入、响应、状态码与细粒度依赖

路径操作名称需要在生成的OpenAPI中保持可区分。客户端生成器和监控系统可能使用operationId,所以复制路由后应检查是否出现重复或意外变化。函数名称只是默认来源之一,公开契约需要显式审查。

不要用中间件替代所有依赖,也不要用依赖替代所有中间件。中间件适合包裹每个请求和响应的横切行为,依赖适合为特定路径操作提供经过验证的值和提前拒绝。需要访问解析后的用户或租户时,依赖通常能表达更准确的范围。

大型应用拆分后,契约仍由最终注册到应用上的路由集合生成。单独测试路由模块可以提供快速反馈,但还要对组合后的应用检查重复路径、遗漏前缀和全局依赖。只有组合测试能看到真实发布的/openapi.json。

错误语义与异常边界

错误响应应区分客户端输入、访问控制和服务器缺陷。请求数据无法解析时,FastAPI默认返回422;应用可以用HTTPException表达预期的HTTP拒绝。未处理异常和响应模型验证失败通常表示服务器没有履行自己的契约,不应伪装成客户端错误。

情况典型状态负责边界测试重点
请求形状无效422FastAPI与Pydanticdetail.loc指向正确来源
未认证401认证依赖包含适当认证挑战信息
已认证但无权限403授权策略有效的他人资源仍被拒绝
资源不存在404受范围限制的查询不泄露其他租户资源是否存在
领域冲突409领域服务与数据库并发写入仍保持不变量
服务内部缺陷500异常处理与观测记录关联标识,不泄露堆栈

自定义异常处理器可以统一错误信封,但不能丢掉可定位的信息。把所有异常都转换为200加错误字段,会破坏HTTP客户端、缓存和监控对成功与失败的判断。转换异常时,应保留稳定的机器代码,并让人类消息独立演进。

依赖中抛出的HTTPException会阻止后续依赖和路径操作继续运行,已经进入的yield依赖仍需清理。清理代码本身也可能失败,因此日志应同时保留原始请求失败与清理失败,而不是只留下最后一个异常。事务依赖必须明确由哪一层决定提交或回滚。

响应验证失败与请求验证失败方向相反。前者说明服务产生了不符合公开模型的数据,修复点在应用代码;把它改写成422会错误地归责客户端。测试应让此类失败在开发和持续集成中显眼,而不是用宽泛的异常处理器隐藏它。

测试HTTP边界

直接调用路径操作函数只能测试普通Python逻辑。它会绕过路由匹配、参数来源、依赖图、异常转换、响应过滤和序列化,因此不能证明API契约成立。至少一层测试必须通过ASGI测试客户端发送HTTP请求。

一组有价值的端点测试通常覆盖以下维度:

  1. 合法请求的状态码、响应头和完整公开响应体。
  2. 每个输入来源的缺失值、错误类型、边界值和未知字段策略。
  3. 未认证、权限不足、跨租户标识和不存在资源之间的差异。
  4. 依赖失败、处理函数失败和响应序列化失败时的资源清理。

断言整个错误对象可能对Pydantic的措辞变化过于敏感,但只断言状态码又太宽松。更稳定的做法是检查状态码、错误位置、机器错误类型和应用定义的错误代码,再对确实属于公开契约的消息做精确断言。

如果应用使用lifespan初始化连接池或其他共享资源,应让测试客户端以上下文管理器方式运行。这样启动与关闭逻辑都会执行,测试才能发现初始化顺序和清理问题。单纯实例化客户端并不等于验证了应用生命周期。

异步测试只有在测试本身需要等待异步数据库或其他协程时才有必要。无论测试函数是同步还是异步,断言对象仍是HTTP可观察行为,而不是处理函数内部实现。不要为了访问内部状态而绕开依赖边界。

最后应把生成的OpenAPI纳入变更审查,但避免把含有无关排序变化的巨大快照作为唯一保护。可以先规范化模式,再比较路径、方法、参数、请求模式、响应模式和安全要求。契约差异应与行为测试一起审查,因为模式相同的实现仍可能返回错误数据或执行错误授权。

延伸阅读

检查点

4个问题 · 1道输出预测题 · 1道找错题

下一篇 WSGI与ASGI Testing 即将上线 HTTPX客户端 Django Backend security 即将上线
复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?