Course Builder个人学习工作台 · 本地模式
Modern Python:面向有经验开发者的回归路线

FastAPI:从类型契约到 HTTP API

目标:能够把已经验证过的 Pydantic 模型和异步服务暴露为 FastAPI endpoint,并用 TestClient 验证成功与失败路径。

FastAPI 根据函数签名判断数据来源:路径模板中的参数来自 path,简单标量通常来自 query,Pydantic model 通常来自 request body。它把 Pydantic JSON Schema 放入 OpenAPI,并提供交互文档。

async def 不是“更快”的装饰:当 endpoint 调用支持 await 的数据库或 HTTP client 时使用 async;如果第三方库只有阻塞同步 API,普通 def endpoint 会由 FastAPI 放到线程池。你在 async endpoint 中直接调用的普通工具函数不会被自动转线程。

安装与应用

运行:

uv add "fastapi[standard]"
uv add --dev httpx

建立 src/textlab/api.py:

from __future__ import annotations

import asyncio

from fastapi import FastAPI, HTTPException

from textlab.schemas import MessageInput, MessageOutput

app = FastAPI(title="TextLab", version="1.0.0")


async def classify_async(message: MessageInput) -> MessageOutput:
    await asyncio.sleep(0)
    category = "refund" if "退款" in message.text else "general"
    return MessageOutput(
        text=message.text,
        category=category,
        priority=message.priority,
    )


@app.get("/health")
async def health() -> dict[str, str]:
    return {"status": "ok"}


@app.post("/messages", response_model=MessageOutput)
async def create_message(message: MessageInput) -> MessageOutput:
    try:
        async with asyncio.timeout(2):
            return await classify_async(message)
    except TimeoutError as exc:
        raise HTTPException(status_code=504, detail="processing timed out") from exc

启动:

uv run fastapi dev src/textlab/api.py

预期观察:终端显示服务地址,打开 http://127.0.0.1:8000/docs 可以看到 /health 和 /messages。请求:

curl -s http://127.0.0.1:8000/messages \
  -H 'content-type: application/json' \
  -d '{"text":"我要退款","priority":2,"metadata":{"source":"curl"}}'

预期 JSON:

{"text":"我要退款","category":"refund","priority":2}

HTTP 层应该保持薄

route 负责:接收/返回模型、认证/依赖、HTTP 状态映射。业务分类和并发 orchestration 应在可独立测试的 service 中。当前 classify_async 仍放在同一文件是为了先观察框架;Capstone 会把它移动到 service。

不要用 except Exception 把所有失败都映射成 500。已知输入错误由 Pydantic 产生 422,已知领域错误和 timeout 应有明确映射,未知错误让全局处理与日志系统记录。

引导实验:用 TestClient 固定契约

建立 tests/test_api.py:

from fastapi.testclient import TestClient

from textlab.api import app

client = TestClient(app)


def test_health() -> None:
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}


def test_create_message() -> None:
    response = client.post(
        "/messages",
        json={"text": "我要退款", "priority": 2, "metadata": {"source": "test"}},
    )
    assert response.status_code == 200
    assert response.json()["category"] == "refund"


def test_rejects_unknown_field() -> None:
    response = client.post(
        "/messages",
        json={
            "text": "hello",
            "metadata": {"source": "test"},
            "pritorty": 3,
        },
    )
    assert response.status_code == 422

运行:

uv run pytest tests/test_api.py -q

预期输出:3 passed。TestClient 调用是普通同步调用;若测试还要直接 await async database function,才需要 async test 工具。

独立练习

新增 GET /messages/{message_id} 的内存实现:不存在返回 404。为避免测试顺序污染,用 FastAPI dependency 或 app factory 注入一个新 dict,而不是永久 module global。

参考设计

先写纯 repository 类或 dict wrapper,测试中创建新的 app/repository。最小验收:

def test_missing_message_returns_404() -> None:
    response = client.get("/messages/missing")
    assert response.status_code == 404
    assert response.json() == {"detail": "message not found"}

若选择暂时使用 module dict,至少提供 pytest fixture 在每个测试前清空;第 17 课会用数据库替换。

参考资料