FastAPI 快速入门

Catalogue
  1. 1. 先跑通一个最小服务
  2. 2. 路由是 HTTP 方法和路径的组合
  3. 3. 路径参数、查询参数和校验时机
  4. 4. POST 请求体由 Pydantic 模型声明
    1. 类型转换不是无条件的
  5. 5. 请求校验、业务异常和依赖
  6. 6. response_model 约束最终响应
  7. 7. 400 和 422 到底有什么不同
    1. 为什么 FastAPI 经常返回 422
  8. 8. 自动文档不是运行结果,而是接口契约
  9. 9. 一个完整的小型 API
  10. 10. 遇到错误时按阶段排查
  11. 结语
  12. 参考资料

这篇文章整理了笔者学习 FastAPI 的过程,并将其转化为一套可以按步骤操作和验证的实践教程。目标是完成两个动作:创建路由,以及正确处理请求。重点保留了学习中最容易混淆的几组边界:400422、请求校验和响应校验、FastAPI 应用和 Uvicorn 服务器。

1. 先跑通一个最小服务

本文示例在以下环境中验证:FastAPI 0.116.1、Pydantic 2.8.2、Starlette 0.47.2、Uvicorn 0.35.0。版本不同可能影响个别错误信息,但不改变本文的基本处理链。

先创建虚拟环境并安装 FastAPI:

1
2
3
4
python -m venv .venv
source .venv/bin/activate # macOS / Linux
# Windows PowerShell 使用:.venv\Scripts\Activate.ps1
pip install "fastapi[standard]"

main.py 中写一个最小的 /hello 路由:

1
2
3
4
5
6
7
8
9
10
from fastapi import FastAPI

app = FastAPI()


@app.get("/hello")
def hello(name: str | None = None):
if name is None:
return {"message": "hello world"}
return {"message": f"hello {name}"}

name: str | None = None 表示:name 可以是字符串,也可以是 None;如果请求没有提供 name,就使用默认值 None。因此 GET /hello?name=Lin 时,name 是字符串 "Lin",而直接访问 GET /hello 时,nameNone。由于它有默认值,FastAPI 会把 name 识别为可选的查询参数。

main.py 所在目录启动:

1
uvicorn main:app --reload

这里的 main:app 不是一个 URL:冒号左边是 Python 模块路径,右边是模块中的应用对象名。app = FastAPI() 创建的是应用对象,它保存路由并负责请求处理;Uvicorn 是服务器进程,负责监听端口、接收连接并调用这个应用。

访问下面两个地址,可以看到同一个路由如何读取查询参数:

1
2
3
4
5
GET /hello
-> {"message": "hello world"}

GET /hello?name=Lin
-> {"message": "hello Lin"}

FastAPI 应用和 Uvicorn 之间通过 ASGI(Asynchronous Server Gateway Interface)协作。ASGI 是应用与服务器之间的接口规范,不是业务路由本身。传统的 WSGI 主要面向同步调用;ASGI 为异步调用、长连接等场景提供了更合适的接口。对入门代码而言,只要先记住这三层即可:

1
浏览器/客户端 -> Uvicorn(监听端口) -> ASGI 应用(FastAPI 路由和业务逻辑)

更多 ASGI 介绍,可参考这篇文章>>

2. 路由是 HTTP 方法和路径的组合

同一个路径可以注册不同的 HTTP 方法。FastAPI 选择处理函数时同时看方法和路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from fastapi import FastAPI

app = FastAPI()


@app.get("/items")
def list_items():
return {"method": "GET"}


@app.post("/items")
def create_item():
return {"method": "POST"}


@app.get("/items/{item_id}")
def get_item(item_id: int):
return {"item_id": item_id}

对应关系是:

请求 结果
GET /items 执行 list_items
POST /items 执行 create_item
GET /items/42 执行 get_itemitem_id42
GET /unknown 404 Not Found,没有匹配的路径
POST /items/42 如果只注册了 GET,对应路径存在但方法不允许,返回 405 Method Not Allowed

404405 的差别:前者是路径没有匹配到,后者是路径匹配到了但 HTTP 方法不在注册列表中。

3. 路径参数、查询参数和校验时机

路径占位符必须和函数参数同名。没有出现在路径中的简单类型参数,FastAPI 默认把它解释为查询参数:

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

app = FastAPI()


@app.get("/books/{book_id}")
def get_book(book_id: int, detail: bool = False, sort: str = "title"):
return {
"book_id": book_id,
"detail": detail,
"sort": sort,
}

这里 book_id 是路径参数,detailsort 是查询参数:

1
2
3
4
5
GET /books/42
-> {"book_id": 42, "detail": false, "sort": "title"}

GET /books/42?detail=true&sort=最新
-> {"book_id": 42, "detail": true, "sort": "最新"}

detailsort 有默认值,所以可以省略;如果写成 q: str 而不提供默认值,GET /search 会在进入函数前返回 422

1
2
3
@app.get("/search")
def search(q: str, limit: int = 10):
return {"q": q, "limit": limit}

GET /search?q=fastapi&limit=abc 同样返回 422,因为 limit 声明为 int,字符串 abc 无法转换为整数。

关键执行顺序是:

1
路由匹配 -> 参数转换和校验 -> 函数体

因此:

  • GET /books/abc 能匹配路径,但 abc 不能转换成 int,返回 422,函数体不会执行。
  • GET /books/42 转换成功,函数体执行,通常返回 200

这里的 422 不是“资源不存在”。/users/abc/users/999 应该分开判断:前者是参数校验失败,后者在 999 是合法整数的前提下,进入函数查询数据库,查不到用户时才是 404

4. POST 请求体由 Pydantic 模型声明

POST 请求的 JSON body 不会因为使用了 POST 就自动变成某种结构。需要用 Pydantic 的 BaseModel 明确声明字段,再把模型类型写到路由函数参数上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from fastapi import FastAPI
from pydantic import BaseModel


class PersonIn(BaseModel):
name: str
age: int


app = FastAPI()


@app.post("/people")
def create_person(person: PersonIn):
return {"name": person.name, "age": person.age}

person: PersonIn 同时完成两件事:告诉 FastAPI 从请求体读取 JSON,并让 Pydantic 按 PersonIn 校验和转换字段。下面的请求会得到 200,函数收到的 person.age 是整数 20

1
{"name": "Lin", "age": "20"}

下面的请求得到 422,函数体不会执行:

1
{"name": "Lin", "age": "unknown"}

同理,缺少 age 也会得到 422。Pydantic 默认会忽略模型没有声明的额外字段,因此 city 不会自动出现在返回值中:

1
{"name": "Lin", "age": 20, "city": "Shanghai"}

类型转换不是无条件的

在当前 Pydantic v2 环境中,int 字段可以把可解析的数字字符串转换成整数,但 str 字段不会把整数 123 自动转换成字符串。也就是说,下面两种行为并不对称:

1
2
age: int,输入 "20"       -> 通过,得到 20
name: str,输入 123 -> 默认校验失败

这种不对称是有意的:目标是让运行时数据更接近接口声明,避免把本来应该是字符串的字段悄悄接收成整数。

PS:不要把一个字段的转换经验推广到所有字段,最终规则要看目标类型和 Pydantic 版本。

5. 请求校验、业务异常和依赖

FastAPI 的请求处理可以画成一条更完整的链:

1
2
3
4
5
6
客户端请求
-> 路由匹配
-> 提取并校验请求数据,同时解析 Depends 依赖
-> 路由函数体
-> response_model 响应校验和过滤
-> 最终 HTTP 响应

先记住这条处理链即可,下一章节会详细介绍 response_model 如何校验和过滤响应。

需要注意,“校验请求数据”和“解析依赖”不是两条严格串行、互不交错的流水线。FastAPI 会在调用路由函数前汇总参数并求解依赖;某个端点参数校验失败时,路由函数不会执行,但不依赖这个错误参数的依赖函数可能已经执行。

Depends 用来声明接口依赖的处理步骤。下面的依赖函数要求一个 token 查询参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from fastapi import Depends, FastAPI, HTTPException

app = FastAPI()


def require_admin(token: str):
if token != "admin":
raise HTTPException(status_code=403, detail="Forbidden")
return True


@app.get("/admin/items/{item_id}")
def read_item(
item_id: int,
is_admin: bool = Depends(require_admin),
):
if item_id == 0:
raise HTTPException(status_code=404, detail="Not found")
return {"item_id": item_id}

这段代码的执行范围如下:

请求 状态码 执行情况
/admin/items/abc?token=admin 422 require_admin 可能已执行;item_id 校验失败,路由函数不执行
/admin/items/1 422 token 是依赖的必填参数,依赖函数还没执行
/admin/items/1?token=wrong 403 依赖函数执行并主动抛出异常,路由函数不执行
/admin/items/0?token=admin 404 依赖通过,路由函数执行后主动抛出异常
/admin/items/1?token=admin 200 依赖通过,路由函数正常返回

如果认证信息应该放在请求头,不要继续使用普通的 str 参数;应显式使用 Header,否则 FastAPI 会把它当作查询参数处理。

例如,把依赖函数改成下面这样,token 就会从名为 token 的请求头中读取:

1
2
3
4
5
6
7
from fastapi import Header, HTTPException


def require_admin(token: str = Header(...)):
if token != "admin":
raise HTTPException(status_code=403, detail="Forbidden")
return True

请求时把 token 放在请求头,而不是 URL 的查询字符串中:

1
curl -H "token: admin" "http://127.0.0.1:8000/admin/items/1"

Header(...) 中的 ... 表示这个请求头是必填的;如果完全不发送 token,FastAPI 会在依赖函数执行前返回 422

6. response_model 约束最终响应

请求模型约束“函数能接收什么”,response_model 约束“接口对外返回什么”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from fastapi import FastAPI
from pydantic import BaseModel


class UserOut(BaseModel):
name: str


app = FastAPI()


@app.post("/users", status_code=201, response_model=UserOut)
def create_user():
return {"name": "Lin", "password": "secret"}

最终响应是:

1
{"name": "Lin"}

password 虽然出现在函数返回字典中,但不在 UserOut 中,所以被响应模型过滤。status_code=201 则把这次成功响应标记为资源创建成功。

响应校验发生在函数返回之后。如果声明:

1
2
3
class ArticleOut(BaseModel):
title: str
pages: int

但函数只返回 {"title": "FastAPI 入门"},当前环境下会发生响应校验失败,客户端通常看到 500 Internal Server Error。这和请求体缺少字段不同:请求体缺字段是在函数执行前返回 422,响应缺字段是服务器没有兑现自己声明的响应契约,应按服务端错误排查。

7. 400 和 422 到底有什么不同

状态码 通用语义 FastAPI 入门场景
400 Bad Request 请求作为一个有效请求无法被服务器处理,语义范围较宽 应用或中间件主动判定请求报文/通用请求不合适,并显式抛出 HTTPException(400, ...)
401 Unauthorized 缺少或无效的认证凭证 没有有效登录凭证,通常还应配合 WWW-Authenticate
403 Forbidden 请求被理解,但当前身份不被允许 token 校验通过请求格式、但权限不足,或依赖主动拒绝
404 Not Found 路径或资源不存在 /unknown 没有路由,或合法 user_id 查不到用户
405 Method Not Allowed 路径存在,但 HTTP 方法不允许 只有 GET 路由,却发送 POST
415 Unsupported Media Type 请求体媒体类型不被支持 服务只接受某种 Content-Type
422 Unprocessable Content 内容语法可以理解,但字段语义不能按声明处理 FastAPI 默认的路径、查询、请求头和 Pydantic 请求体校验失败
500 Internal Server Error 服务端处理自身出错 函数返回值不符合 response_model

为什么 FastAPI 经常返回 422

在 FastAPI 中,下面这些声明式校验失败通常都会被统一包装成 422

  • user_id: int 收到 abc
  • limit: int 收到 abc
  • Pydantic 模型缺少必填字段;
  • 字段值无法转换或不满足约束;
  • 在本文验证的 FastAPI 0.116.1 中,请求体 JSON 解码失败也以 422json_invalid 错误返回。

这说明 FastAPI 的默认行为确实比“语法错误一律 400”的简化口诀更具体,但不能据此把 422 当成 FastAPI 私有定义。HTTP 的通用语义仍然来自规范;框架可以选择自己的默认映射,项目也可以通过异常处理器改写映射。

400 仍然有明确用途。例如,下面的接口绕过 Pydantic 请求体模型,手动解析 JSON,并把 JSON 语法错误主动映射为 400

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from json import JSONDecodeError

from fastapi import FastAPI, HTTPException, Request

app = FastAPI()


@app.post("/manual-json")
async def manual_json(request: Request):
try:
payload = await request.json()
except JSONDecodeError:
raise HTTPException(status_code=400, detail="Malformed JSON")
return {"received": payload}

这里的 400 不是 Pydantic 自动产生的,而是应用作者主动做出的接口约定。如果改用 payload: SomeModel 声明请求体,解析和字段校验会交回 FastAPI;在本文版本中,坏 JSON 和字段校验错误默认都返回 422。实际项目应在团队 API 规范中统一选择,不要让同一类错误在不同接口间随机使用 400422

8. 自动文档不是运行结果,而是接口契约

启动服务后,FastAPI 默认提供:

  • /docs:Swagger UI;
  • /redoc:ReDoc;
  • /openapi.json:OpenAPI schema 原文。

文档中的参数类型、是否必填、请求体结构和响应结构来自 Python 类型声明、Pydantic 模型、路径装饰器和 response_model。它展示的是接口的预期契约,不是某一次请求实际返回的 JSON。

例如:

1
2
3
4
5
6
7
8
class BookOut(BaseModel):
title: str
pages: int


@app.get("/book", response_model=BookOut)
def get_book():
return {"title": "FastAPI", "pages": 120}

删除 response_model=BookOut 后,函数仍可能返回同样的字典,但自动文档不再明确保证 titlepages 这两个响应字段。运行时数据和 OpenAPI schema 是两个层次,排查文档问题时不要把它们混在一起。

9. 一个完整的小型 API

下面把前面的路由、请求模型、响应模型、依赖和异常组合起来。它也是一个适合自己复制运行的练习:

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
from fastapi import Depends, FastAPI, HTTPException
from pydantic import BaseModel


app = FastAPI()


class ProductIn(BaseModel):
title: str
quantity: int


class ProductOut(BaseModel):
title: str
id: int
quantity: int


def require_admin(token: str):
if token != "admin":
raise HTTPException(status_code=403, detail="Forbidden")
return True


@app.post(
"/products/{product_id}",
status_code=201,
response_model=ProductOut,
)
def create_product(
product_id: int,
product: ProductIn,
is_admin: bool = Depends(require_admin),
):
if product_id == 0:
raise HTTPException(status_code=404, detail="Not found")

return {
"id": product_id,
"title": product.title,
"quantity": product.quantity,
"internal_cost": 1,
}

用下面的请求验证每一层:

1
2
3
curl -X POST "http://127.0.0.1:8000/products/7?token=admin" \
-H "Content-Type: application/json" \
-d '{"title":"FastAPI 入门","quantity":"2"}'

预期状态码是 201,响应中只有 idtitlequantityinternal_cost 会被 ProductOut 过滤。再试几个故障请求:

1
2
3
4
POST /products/abc?token=admin       -> 422,product_id 校验失败
POST /products/0?token=admin -> 404,函数体内主动抛出异常
POST /products/7 -> 422,依赖缺少 token
POST /products/7?token=wrong -> 403,依赖主动拒绝

10. 遇到错误时按阶段排查

不要先盯着状态码猜原因,先问“请求走到了哪一步”:

  1. 404:确认路径是否注册;如果路径存在,再确认是不是把方法写错导致 405
  2. 422:查看响应体里的 detail,重点看 locpathqueryheader 还是 body;这是声明式请求校验没有通过。
  3. 函数没有打印或数据库查询记录:先检查校验和依赖,校验失败时函数体不会执行。
  4. 进入函数后得到 404:这是业务查找结果,不是参数类型错误。
  5. 返回 500 且使用了 response_model:检查函数返回字典是否缺少模型必填字段、类型是否不符合模型。
  6. token 明明放在请求头却仍然提示缺少参数:检查依赖参数是否声明为 Header,普通 str 参数默认来自查询参数。

这条排查路径比死记“某状态码代表某句话”更可靠:先定位阶段,再判断是框架默认校验、业务主动异常,还是服务器自己的响应契约出了问题。

结语

FastAPI 入门真正需要掌握的是处理链,而不是装饰器数量:@app.get@app.post 注册方法和路径;函数签名声明参数来源与类型;Pydantic 校验请求体;Depends 插入认证等前置步骤;response_model 约束对外响应;OpenAPI 文档把这些声明展示成可操作的契约。

值得注意的是:/users/abc422 是参数校验失败,/users/999404 是资源查找失败;请求校验失败不会进入函数,而响应模型失败通常属于服务端错误。掌握这几个边界后,后续接数据库、认证和更复杂的业务逻辑,仍然可以沿着同一条处理链定位问题。

参考资料