FastAPI×OpenAPIで使えるAPIドキュメントを作る:スキーマ、エラー、Examples、SDK生成

green snake
Photo by Pixabay on Pexels.com

FastAPIのドキュメント画面は、エンドポイントが並んでいるだけでは実務で使い切れません。

利用者が知りたいのは、何を送ればよいか、何が返るか、失敗時にどう判断するか、認証が必要か、仕様変更にどう追随するかです。

OpenAPIは、HTTP APIのパス、入出力、応答、認証などを機械が読める形式で表す仕様です。

FastAPIの特徴と導入前の設計ポイントを押さえたうえで、型定義と実際の応答をOpenAPIへ正しく反映すると、ドキュメントを実装とともに保守できます。

使えるAPIドキュメントに必要な情報

ドキュメントの役割は、API利用者が実装者へ問い合わせなくても、基本的な接続とエラー処理を判断できる状態を作ることです。

APIドキュメントでそろえる情報
項目 利用者が確認すること FastAPIでの主な表現
入力 必須項目、型、文字数などの制約 Pydanticモデル、Path、Query、Header
出力 成功時に返る項目と型 response_model
失敗 ステータスコード、エラーコード、対処の手掛かり responses、例外ハンドラ
具体例 送受信するJSONの形 examples、openapi_examples
認証 認証方式と必要なエンドポイント FastAPIのSecurity依存
変更 利用中の版と移行方法 APIのversion、パス、変更履歴

この六つをコードと別々に管理すると、どちらか一方だけが更新される可能性があります。

FastAPIでは、型定義、ルート宣言、依存関係からOpenAPIを生成できるため、実装に近い場所で仕様を管理できます。

入出力モデルを分ける

入力用モデルと出力用モデルは役割が異なります。

入力用モデルは利用者が送れる項目を表し、出力用モデルはAPIが公開する項目を表します。

分けておけば、内部でだけ使う値を誤って応答へ含める危険を抑えながら、入力と出力の違いをドキュメント上でも示せます。

from datetime import datetime

from pydantic import BaseModel, ConfigDict, Field


class ArticleCreate(BaseModel):
    title: str = Field(max_length=120, description="記事タイトル")
    body: str = Field(description="記事本文")


class Article(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    title: str
    body: str
    created_at: datetime

ConfigDict(from_attributes=True)は、属性を持つオブジェクトからモデルを組み立てる場合に使うPydantic v2の設定です。

辞書だけを返す構成なら、必要性を確認したうえで省いて構いません。

response_modelと成功例を対応させる

response_modelは、成功時の応答形式をOpenAPIへ載せるための中心となる宣言です。

成功例も同じルートに置くと、利用者はスキーマと具体的なJSONを続けて確認できます。

from datetime import datetime, timezone

from fastapi import APIRouter, status

router = APIRouter(prefix="/articles", tags=["articles"])


@router.post(
    "",
    response_model=Article,
    status_code=status.HTTP_201_CREATED,
    responses={
        201: {
            "description": "記事を作成した場合",
            "content": {
                "application/json": {
                    "example": {
                        "id": 101,
                        "title": "API設計の心得",
                        "body": "入出力と失敗時の応答を先に決めます。",
                        "created_at": "2025-10-02T09:00:00Z",
                    }
                }
            },
        }
    },
)
async def create_article(payload: ArticleCreate) -> Article:
    # 実際の処理ではデータベースなどへ保存する
    return Article(
        id=101,
        title=payload.title,
        body=payload.body,
        created_at=datetime.now(timezone.utc),
    )

この例では保存処理を省略しています。

実際のAPIでは、ドキュメントに載せた項目とステータスコードを実装が返していることまでテストします。

共通エラーモデルで失敗時の判断を助ける

成功時の型が整っていても、エラーごとにJSONの形が違えば、クライアント側は個別の分岐を増やすことになります。

共通エラーモデルは、機械が判定するコード、人が読む説明、必要に応じた補足情報を同じ構造で返すための型です。

from typing import Any

from pydantic import BaseModel, Field


class ErrorDetail(BaseModel):
    code: str = Field(description="クライアントが判定に使うエラーコード")
    message: str = Field(description="利用者向けの説明")
    info: dict[str, Any] | None = Field(
        default=None,
        description="エラーに応じた補足情報",
    )


class ErrorResponse(BaseModel):
    detail: ErrorDetail

codeとmessageを分けると、クライアントは文言ではなく安定したコードで処理を分岐できます。

infoへ何を入れるかは無制限にせず、エラーごとに必要な情報を決めます。

例外ハンドラとHTTPステータスを一致させる

responsesはドキュメント上の宣言であり、それだけで実行時の応答は変わりません。

例外ハンドラが返すHTTPステータスと本文を、ルートに記載した失敗応答へ合わせる必要があります。

from typing import Any

from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse


class DomainError(Exception):
    def __init__(
        self,
        status_code: int,
        code: str,
        message: str,
        info: dict[str, Any] | None = None,
    ):
        self.status_code = status_code
        self.code = code
        self.message = message
        self.info = info


app = FastAPI(title="OpenAPI Oriented API")


@app.exception_handler(DomainError)
async def handle_domain_error(
    _: Request,
    exc: DomainError,
) -> JSONResponse:
    payload = ErrorResponse(
        detail=ErrorDetail(
            code=exc.code,
            message=exc.message,
            info=exc.info,
        )
    )
    return JSONResponse(
        status_code=exc.status_code,
        content=payload.model_dump(),
    )

Pydantic v2では、モデルを辞書へ変換する処理にmodel_dump()を使います。

404応答をルートへ記載する

from fastapi import status


@router.get(
    "/{article_id}",
    response_model=Article,
    responses={
        404: {
            "model": ErrorResponse,
            "description": "指定した記事が見つからない場合",
            "content": {
                "application/json": {
                    "example": {
                        "detail": {
                            "code": "ARTICLE_NOT_FOUND",
                            "message": "記事がありません",
                            "info": None,
                        }
                    }
                }
            },
        }
    },
)
async def get_article(article_id: int) -> Article:
    # 取得処理は省略する
    raise DomainError(
        status_code=status.HTTP_404_NOT_FOUND,
        code="ARTICLE_NOT_FOUND",
        message="記事がありません",
    )

ここでは、OpenAPIに記載した404と、実際に投げる例外の404を一致させています。

同じ現象には同じエラーコードを使い、実装では返さない応答をドキュメントへ並べないことが保守の基本です。

共通のresponsesを再利用する

複数のルートへ同じエラー定義を複製すると、説明やモデルが少しずつ変わりやすくなります。

共通辞書へまとめたうえで、各ルートに固有の応答を追加すると、語彙をそろえやすくなります。

COMMON_ERROR_RESPONSES = {
    400: {
        "model": ErrorResponse,
        "description": "入力内容を処理できない場合",
    },
    409: {
        "model": ErrorResponse,
        "description": "現在の状態と操作が競合する場合",
    },
}


@router.put(
    "/{article_id}",
    response_model=Article,
    responses={
        **COMMON_ERROR_RESPONSES,
        404: {
            "model": ErrorResponse,
            "description": "指定した記事が見つからない場合",
        },
    },
)
async def update_article(
    article_id: int,
    payload: ArticleCreate,
) -> Article:
    ...

再利用するのは、意味と構造が本当に共通する応答だけです。

ステータスコードが同じでも原因や対処が異なるなら、説明やエラーコードを分けます。

Examplesは利用場面ごとに選ぶ

例は数を増やすことより、利用者が判断に使える組み合わせを選ぶことが大切です。

用意したいExamplesの種類
種類 示す内容 確認できること
最小例 必須項目だけを含む入力 最短で送れるJSON
代表例 通常の利用で現れる値 項目同士の関係
境界例 空の配列や許容上限に近い値 制約の解釈
失敗例 入力、認証、競合などのエラー 分岐に使うコードと本文

入力例はBody(openapi_examples={...})などで複数提示できます。

応答例はresponsesのcontentへ置けます。

長い例を外部ファイルで管理する場合も、OpenAPIへ組み込まれる内容とテスト用データが食い違わない仕組みを用意します。

タグとメタ情報で入口を整える

タグは画面上の飾りではなく、利用者が目的の操作へたどり着くための分類です。

データベースのテーブル名ではなく、「記事」「ユーザー」「認証」のような利用機能で分けると、APIの使い方に沿った順序になります。

from fastapi import FastAPI


app = FastAPI(
    title="OpenAPI Oriented API",
    description=(
        "記事、ユーザー、認証の機能を提供するAPIです。"
        "日時はUTCのISO 8601形式で扱います。"
    ),
    version="1.2.0",
    contact={
        "name": "APIサポート",
        "email": "support@example.com",
    },
    license_info={"name": "MIT"},
    servers=[
        {
            "url": "https://api.example.com",
            "description": "本番環境",
        },
        {
            "url": "https://stg-api.example.com",
            "description": "検証環境",
        },
    ],
    openapi_tags=[
        {
            "name": "articles",
            "description": "記事の作成、取得、更新、削除",
        },
        {
            "name": "users",
            "description": "ユーザー管理",
        },
        {
            "name": "auth",
            "description": "認証とトークン",
        },
    ],
)

versionは、このAPI自身の版を示します。

実在する環境では、利用を許可したサーバURL、連絡先、ライセンス情報だけを掲載します。

レート制限、本文サイズ、日時形式など全体へ影響する制約は、利用者が個々のルートを開く前に読める説明へまとめます。

認証の必須範囲をOpenAPIへ反映する

認証方式を書くだけでは、どのルートで資格情報が必要かが伝わりません。

FastAPIのSecurity依存をルートへ組み込むと、認証方式と適用範囲をOpenAPIへ反映できます。

from typing import Annotated

from fastapi import Depends, HTTPException, status
from fastapi.security import (
    HTTPAuthorizationCredentials,
    HTTPBearer,
)


bearer_scheme = HTTPBearer(auto_error=False)


def get_current_user(
    credentials: Annotated[
        HTTPAuthorizationCredentials | None,
        Depends(bearer_scheme),
    ],
) -> dict[str, str]:
    if credentials is None or credentials.scheme.lower() != "bearer":
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="unauthorized",
        )

    # 署名、有効期限、発行者などの検証は省略する
    return {"sub": "alice"}


@router.get("/me", tags=["auth"])
async def me(
    user: Annotated[dict[str, str], Depends(get_current_user)],
) -> dict[str, str]:
    return user

このコードはドキュメント用の最小例であり、トークンの検証処理を含みません。

実装では採用する認証方式に応じて検証条件を定め、権限の範囲がある場合は、その範囲を表現できる方式を選びます。

OpenAPIからクライアントSDKを生成する

OpenAPIは画面表示だけでなく、クライアントSDKの入力にも使えます。

まず、アプリケーションからOpenAPI JSONを書き出します。

python -c "import json; from app.main import app; print(json.dumps(app.openapi(), ensure_ascii=False, indent=2))" > openapi.json

起動済みの環境では、設定したOpenAPI URLからJSONを取得する方法もあります。

OpenAPI Generatorを使う場合の例は次のとおりです。

openapi-generator generate -i openapi.json -g typescript-axios -o client-ts

生成後は、メソッド名、型、認証処理、例外の扱いがクライアントの利用方法に合うかを確認します。

SDK生成と契約テストまで進める場合は、FastAPI×OpenAPIのSDK生成と契約テストも参照してください。

非互換変更を見分ける

OpenAPIの差分が見えれば、すべての変更が安全になるわけではありません。

クライアントへどの影響が出るかを分類し、移行方法と期限を伝える必要があります。

API変更を評価する例
変更 主な影響 判断
既存フィールドの削除 参照しているクライアントが動かなくなる 原則として非互換変更として扱う
型や意味の変更 変換や分岐の前提が崩れる 同じ名前のまま置き換えない
必須項目の追加 既存の入力が検証を通らなくなる 移行期間か新しい版を用意する
任意項目の追加 通常は受け入れやすいが、厳格なクライアントへ影響する場合がある 利用側の条件まで確認する

パスを/api/v1と/api/v2に分ける方法は、非互換変更を隔離する選択肢の一つです。

変更のたびに新しい版を作るのではなく、影響範囲、利用中のクライアント、移行期間を見て判断します。

ドキュメント画面と重要な制約

FastAPIは通常、Swagger UIを/docs、ReDocを/redocで提供します。

公開範囲や運用方針に応じて、docs_url、redoc_url、openapi_urlを設定します。

画面を見栄えだけで評価せず、次の情報へ短い操作で到達できるかを確認します。

  • 認証を設定する場所と対象ルート
  • 必須の入力項目と制約
  • 成功例と代表的な失敗例
  • 本番環境と検証環境の違い
  • APIの版、変更履歴、移行案内
  • 問い合わせ先

FastAPIの構成を複数ファイルへ分ける段階では、保守しやすいFastAPIのプロジェクト構成も併せて確認すると、スキーマとルーターの配置を整理できます。

OpenAPIと実装のずれをテストで検出する

OpenAPIがコードから生成されても、説明、Examples、独自の例外処理まで正しいとは限りません。

次の検査を組み合わせます。

  1. CIでOpenAPI JSONを生成し、JSONとして読み込めることとスキーマの妥当性を検査する。
  2. 代表的なルートのOpenAPIをスナップショットとして比較し、意図しない差分をレビューする。
  3. 正常時と失敗時の実応答をテストし、ステータスコードと本文がresponsesに合うことを確かめる。
  4. SDKを再生成し、変更差分とクライアント側の型検査を確認する。

OpenAPIの差分だけでは、処理の意味や認可条件の誤りまでは保証できません。

仕様の検査と実際のAPIテストを分けて実施します。

よくある不整合と直し方

APIドキュメントの不整合
症状 原因 直し方
実装と説明が違う 説明だけを別の文書で更新している 型とルート宣言へ仕様を寄せ、実応答をテストする
エラーごとに本文が違う 各ルートが独自に例外を返している 共通モデルと例外ハンドラで基本形をそろえる
試し方が分からない 具体的な入力例がない 最小例、代表例、失敗例を用意する
認証の対象が分からない 認証方式を文章だけで説明している Security依存を対象ルートへ設定する
SDKのメソッド名が扱いにくい operation IDやタグを設計していない 一意性を保ちながら命名規則を決める
変更時に利用側が混乱する 影響と移行条件を伝えていない 差分、期限、移行手順を同じ変更履歴へ残す

導入する順序

既存APIのドキュメントを一度に作り直す必要はありません。

  1. 利用頻度の高いルートで、入力モデルとresponse_modelを確認する。
  2. 共通エラーモデルを決め、実際の例外応答をそろえる。
  3. 成功例と代表的な失敗例を追加する。
  4. タグ、説明、サーバ、認証、問い合わせ先を整理する。
  5. OpenAPI JSONの差分と実応答をCIで検査する。
  6. 必要なクライアント向けにSDKを生成し、更新手順を決める。

最初に着手するなら、正常時の出力と失敗時の基本形を固定します。

この二つがそろうと、Examples、テスト、SDK生成を同じ仕様へ接続しやすくなります。

参考資料

使えるドキュメントへ仕上げる要点

APIドキュメントは、情報量ではなく、実装と同じ内容を利用者が判断できる形で示しているかで評価します。

  • 入力モデルと出力モデルを分け、公開する項目を明確にする。
  • 共通エラーモデルを定め、宣言と実際のHTTPステータスを一致させる。
  • Examplesは最小例、代表例、境界例、失敗例から必要なものを選ぶ。
  • タグ、認証、サーバ、制約、変更履歴を利用者の探し方に沿って整理する。
  • OpenAPIの差分、実応答、生成したSDKを継続的に検査する。

型定義だけに任せず、説明と例とテストを同じAPI契約へ結び付けることが、変更後も使えるドキュメントを保つ方法です。

この記事に関連する株式会社greedenの取り組み

APIドキュメントは、実装者と利用者が同じ仕様を確認できてこそ役立ちます。株式会社greedenは、要件定義からAPI設計、実装、テスト、保守運用まで一気通貫で支援し、変更に強いシステムづくりをお手伝いします。

投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)