codecamp

6.2 为成功和失败响应写清文档

为成功和失败响应写清文档

接口文档不仅要告诉调用方“这个 URL 存在”,还要说明请求体、成功状态、返回字段和可预期的失败形状。FastAPI 的路径操作参数可以把这些信息写入 OpenAPI:tags 组织接口,summarydescription 解释用途,response_model 描述数据,status_code 描述成功状态,responses 补充其他状态。

这些参数只负责生成文档,不会自动实现异常处理。声明了 409 不代表重复标题会自动变成 409;应用仍要在业务分支中抛出对应异常,并用测试核对实际响应。反过来,也不要把尚未实现的权限、限流或数据库错误写进文档,先让文档和代码的当前行为一致。

用一个完整的文章接口对照文档和响应

本示例实现文章创建和局部修改两条接口。创建成功返回 201,重复标题返回 409,请求校验失败返回 422;修改成功返回 200,文章不存在返回 404,空更新或显式 null 返回 422。自定义请求校验异常处理器让 422 的实际 JSON 与 OpenAPI 中的 ErrorResponse 保持一致。

<!-- file: ch06_docs/documented_api_demo.py -->

from __future__ import annotations


from datetime import datetime, timezone
from typing import Any


from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field




class PostCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")


    title: str = Field(min_length=1, max_length=120)
    content: str = Field(min_length=1, max_length=10_000)




class PostUpdate(BaseModel):
    model_config = ConfigDict(extra="forbid")


    title: str | None = Field(default=None, min_length=1, max_length=120)
    content: str | None = Field(default=None, min_length=1, max_length=10_000)




class PostResponse(BaseModel):
    id: int
    title: str
    content: str
    author_id: int
    created_at: datetime




class ErrorResponse(BaseModel):
    detail: str




app = FastAPI(
    title="文章管理 API",
    version="1.0.0",
    description="用于演示成功和失败响应契约的最小文章接口。",
    openapi_tags=[
        {
            "name": "posts",
            "description": "文章的创建和局部修改操作。",
        }
    ],
)
POSTS: dict[int, dict[str, Any]] = {}




@app.exception_handler(RequestValidationError)
async def public_validation_error(
    _request: object,
    _exc: RequestValidationError,
) -> JSONResponse:
    # 让请求体校验失败的实际形状匹配 responses 中的 ErrorResponse。
    return JSONResponse(status_code=422, content={"detail": "请求数据不符合格式"})




@app.post(
    "/posts",
    response_model=PostResponse,
    status_code=201,
    response_description="文章创建成功",
    tags=["posts"],
    summary="创建文章",
    description="创建一篇文章。作者身份由服务端决定,客户端不能提交 author_id。",
    responses={
        409: {
            "model": ErrorResponse,
            "description": "文章标题已经存在",
            "content": {
                "application/json": {
                    "example": {"detail": "文章标题已存在"}
                }
            },
        },
        422: {
            "model": ErrorResponse,
            "description": "请求体不符合字段规则",
            "content": {
                "application/json": {
                    "example": {"detail": "请求数据不符合格式"}
                }
            },
        },
    },
)
def create_post(payload: PostCreate) -> dict[str, Any]:
    if any(post["title"] == payload.title for post in POSTS.values()):
        raise HTTPException(status_code=409, detail="文章标题已存在")


    post_id = max(POSTS, default=0) + 1
    record = {
        "id": post_id,
        **payload.model_dump(),
        "author_id": 7,
        "created_at": datetime.now(timezone.utc),
    }
    POSTS[post_id] = record
    return record




@app.patch(
    "/posts/{post_id}",
    response_model=PostResponse,
    status_code=200,
    response_description="文章修改成功",
    tags=["posts"],
    summary="局部修改文章",
    description="只修改请求体中出现的字段;未出现的字段保持原值。",
    responses={
        404: {
            "model": ErrorResponse,
            "description": "文章不存在",
            "content": {
                "application/json": {
                    "example": {"detail": "文章不存在"}
                }
            },
        },
        422: {
            "model": ErrorResponse,
            "description": "请求体为空、字段无效或显式传入 null",
            "content": {
                "application/json": {
                    "example": {"detail": "至少提供一个有效字段"}
                }
            },
        },
    },
)
def update_post(post_id: int, payload: PostUpdate) -> dict[str, Any]:
    record = POSTS.get(post_id)
    if record is None:
        raise HTTPException(status_code=404, detail="文章不存在")


    changes = payload.model_dump(exclude_unset=True)
    if not changes or any(value is None for value in changes.values()):
        raise HTTPException(status_code=422, detail="至少提供一个有效字段")


    record.update(changes)
    return record




def self_check() -> None:
    POSTS.clear()
    with TestClient(app) as client:
        malformed = client.post("/posts", json={"content": "正文"})
        assert malformed.status_code == 422
        assert malformed.json() == {"detail": "请求数据不符合格式"}


        created = client.post(
            "/posts",
            json={"title": "接口文档", "content": "成功和失败都要有契约。"},
        )
        assert created.status_code == 201
        assert created.json()["title"] == "接口文档"
        assert "created_at" in created.json()


        duplicate = client.post(
            "/posts",
            json={"title": "接口文档", "content": "不能重复。"},
        )
        assert duplicate.status_code == 409
        assert duplicate.json() == {"detail": "文章标题已存在"}


        updated = client.patch("/posts/1", json={"title": "文档契约"})
        assert updated.status_code == 200
        assert updated.json()["title"] == "文档契约"
        assert updated.json()["content"] == "成功和失败都要有契约。"


        missing = client.patch("/posts/404", json={"title": "不存在"})
        assert missing.status_code == 404
        assert missing.json() == {"detail": "文章不存在"}


        empty = client.patch("/posts/1", json={})
        assert empty.status_code == 422
        assert empty.json() == {"detail": "至少提供一个有效字段"}


    schema = app.openapi()
    create_operation = schema["paths"]["/posts"]["post"]
    assert create_operation["tags"] == ["posts"]
    assert create_operation["summary"] == "创建文章"
    assert create_operation["responses"]["201"]["description"] == "文章创建成功"
    assert create_operation["responses"]["409"]["content"][
        "application/json"
    ]["schema"]["$ref"].endswith("/ErrorResponse")
    assert create_operation["responses"]["422"]["content"][
        "application/json"
    ]["example"] == {"detail": "请求数据不符合格式"}
    assert create_operation["requestBody"]["content"]["application/json"][
        "schema"
    ]["$ref"].endswith("/PostCreate")


    update_operation = schema["paths"]["/posts/{post_id}"]["patch"]
    assert {"200", "404", "422"} <= set(update_operation["responses"])
    assert update_operation["responses"]["404"]["content"][
        "application/json"
    ]["schema"]["$ref"].endswith("/ErrorResponse")


    print("documented_api_demo self-check passed")




if __name__ == "__main__":
    self_check()

运行检查:

python ch06_docs/documented_api_demo.py

预期输出:

documented_api_demo self-check passed

responses 是声明,不是实现

responses 会把额外状态和模型放进 OpenAPI,但不会替路由捕获异常,也不会自动生成 409、404 或 422。示例中的重复标题分支明确抛出 409,缺失文章分支明确抛出 404,请求校验处理器和空更新分支明确返回 422,因而文档声明和实际响应都有代码和自检支撑。

错误模型也要和真实 JSON 一致。FastAPI 默认请求校验错误通常包含一个错误列表;本节为了让示例的错误响应统一为 {"detail": "..."},显式覆盖了 RequestValidationError 处理器。如果项目保留默认错误结构,就应把文档模型和示例改成列表结构,不要只在 OpenAPI 里写一个字符串。

status_code=201 同时影响实际成功响应和 OpenAPI。response_model=PostResponse 让创建和修改的成功 JSON 只包含公开字段;response_descriptionsummarydescriptiontags 只改善说明,不改变业务结果。调用方应根据实际状态码和字段编写客户端处理,不要把默认 200 或一个模糊的“成功响应”当成所有操作的契约。

文档的最小验收方法

先用 app.openapi() 检查路径、标签、请求模型、成功状态和错误状态是否存在,再用 TestClient 发起真实请求,比较状态码和 JSON。两者缺一不可:只看 OpenAPI 可能遗漏错误实现,只看请求结果可能遗漏文档没有描述的契约。接口增加新的错误分支时,先增加响应声明、实现和自检,再考虑发布。

资料来源

6.1 正确理解响应校验与序列化
6.3 按环境管理文档入口
温馨提示
下载编程狮App,免费阅读超1000+编程语言教程
取消
确定
目录

关闭

MIP.setData({ 'pageTheme' : getCookie('pageTheme') || {'day':true, 'night':false}, 'pageFontSize' : getCookie('pageFontSize') || 20 }); MIP.watch('pageTheme', function(newValue){ setCookie('pageTheme', JSON.stringify(newValue)) }); MIP.watch('pageFontSize', function(newValue){ setCookie('pageFontSize', newValue) }); function setCookie(name, value){ var days = 1; var exp = new Date(); exp.setTime(exp.getTime() + days*24*60*60*1000); document.cookie = name + '=' + value + ';expires=' + exp.toUTCString(); } function getCookie(name){ var reg = new RegExp('(^| )' + name + '=([^;]*)(;|$)'); return document.cookie.match(reg) ? JSON.parse(document.cookie.match(reg)[2]) : null; }