FastAPIで壊れにくいAPIを作る:型定義、依存性注入、テストの実践

入力モデル、処理、出力モデルを半透明のブロックで表したAPI設計のイメージ

FastAPIは、Pythonの型ヒントから入力検証とOpenAPI文書を組み立てられるWeb APIフレームワークです。

ただし、型を付けるだけで保守しやすいAPIになるわけではありません。

入力と出力の境界、認証やデータベース接続の置き場所、非同期処理の選択、テストする契約をそろえてこそ、変更に耐えられる設計になります。

FastAPIの特徴や採用判断から確認したい場合は、先にFastAPIの基本とAPI設計の要点を参照してください。

  • リクエストとレスポンスに別のモデルを使う
  • 横断的な処理を依存性として切り出す
  • 外部入出力の性質に合わせてasync defdefを選ぶ
  • 実装の内部ではなくHTTP契約をテストする

型定義をAPI契約として扱う

FastAPIは、Pydanticモデルとして宣言したリクエスト本文を読み取り、型変換と検証を行い、そのスキーマをOpenAPIにも反映します。

この仕組みが役立つのは、入力ミスを早く見つけられるからだけではありません。

クライアントが送れる値と、サーバーが返す値をコード上で固定できるため、フロントエンドとバックエンドの認識差をレビューしやすくなります。

入力モデルと出力モデルを分ける

次の例では、タスク登録用のTaskCreateと返却用のTaskOutを分けています。

from typing import Annotated

from fastapi import Depends, FastAPI, Header, HTTPException, status
from pydantic import BaseModel, ConfigDict, Field

app = FastAPI()


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

    title: str = Field(min_length=1, max_length=120)
    estimate_hours: int = Field(ge=1, le=80)


class TaskOut(BaseModel):
    id: int
    title: str
    estimate_hours: int


async def require_api_key(
    x_api_key: Annotated[str, Header()]
) -> None:
    if x_api_key != "example-secret":
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid API key",
        )


@app.post(
    "/tasks",
    response_model=TaskOut,
    status_code=status.HTTP_201_CREATED,
)
async def create_task(
    payload: TaskCreate,
    _: Annotated[None, Depends(require_api_key)],
) -> TaskOut:
    return TaskOut(id=1, **payload.model_dump())

Fieldの制約は、題名の空文字や想定外の工数を経路処理へ渡す前に拒否します。

extra="forbid"は、モデルに定義していないフィールドを受け入れない設定です。

クライアントの誤字を黙って無視したくないAPIでは、意図しない入力を早く検出できます。

response_model=TaskOutは、返却データを出力モデルの形に検証し、宣言外のフィールドを除外します。

データベースの行や内部オブジェクトをそのまま返す設計より、内部用の値を誤って公開する危険を抑えられます。

経路処理の役割を小さく保つ

経路処理が入力検証、認証、業務判断、永続化、外部API呼び出しをすべて抱えると、HTTPの確認だけをしたいテストでも多くの準備が必要になります。

責務を次のように分けると、変更の影響範囲が見えやすくなります。

受け持つ処理 避けたい処理
経路 HTTP入力、ステータスコード、レスポンス変換 複雑な業務判断
サービス 業務ルール、処理の順序、トランザクション境界 HTTPヘッダーの解釈
リポジトリ データの保存と取得 認可判断
外部連携 外部APIの呼び出しと応答変換 画面向けの文言決定

この分類は、ファイル数を増やすこと自体を目的にしていません。

変更理由が異なる処理を分け、テストで置き換える境界を作るための分類です。

依存性注入で横断的な処理を集約する

FastAPIのDependsは、認証、権限確認、設定値、データベースセッションなど、複数の経路が共有する処理に向いています。

依存関数も通常の経路処理と同様に引数を宣言でき、その要件はOpenAPIスキーマにも反映されます。

認証を各経路の冒頭へコピーすると、エラー形式や監査ログの扱いが経路ごとにずれます。

認証情報の解決を一つの依存関数へ集めれば、変更箇所を限定でき、テストではapp.dependency_overridesで代替実装へ差し替えられます。

ただし、依存関数へ業務処理を詰め込むと実行順序が読みにくくなります。

依存関数は、経路処理を実行するために必要な値の準備と前提条件の確認までにとどめると追跡しやすくなります。

非同期処理は待ち時間の性質で選ぶ

async defは、どの処理にも付ければ速くなる指定ではありません。

非同期対応のHTTPクライアントやデータベースドライバーをawaitしている間に、同じプロセスが別のリクエストを進められる点に価値があります。

FastAPIは、通常のdefで宣言した経路処理と依存関数を外部スレッドプールで実行します。

同期ライブラリによるブロッキング入出力をすぐ置き換えられない場合は、defを選ぶ理由になります。

一方、async defの中で同期HTTP通信や長いファイル処理を直接実行すると、イベントループを止めて同時処理を妨げます。

CPUを長時間占有する画像処理や集計は、async defへ変えるだけでは解決しないため、別プロセスのジョブ実行基盤へ分離する判断が必要です。

HTTP契約をテストする

FastAPIのTestClientはHTTPXを基盤とし、実際のソケットを開かずに経路を呼び出せます。

成功例だけでなく、入力エラー、認証エラー、出力フィールドの範囲をテストすると、API契約の意図しない変更を検出できます。

from fastapi.testclient import TestClient

client = TestClient(app)


def test_create_task() -> None:
    response = client.post(
        "/tasks",
        headers={"X-API-Key": "example-secret"},
        json={"title": "Review API contract", "estimate_hours": 2},
    )

    assert response.status_code == 201
    assert response.json() == {
        "id": 1,
        "title": "Review API contract",
        "estimate_hours": 2,
    }


def test_rejects_unknown_field() -> None:
    response = client.post(
        "/tasks",
        headers={"X-API-Key": "example-secret"},
        json={
            "title": "Review API contract",
            "estimate_hours": 2,
            "owner_role": "admin",
        },
    )

    assert response.status_code == 422

外部認証や課金APIを呼ぶ依存関数は、テスト中だけ差し替えます。

これにより、経路の契約テストと外部サービスの結合テストを分けられます。

失敗時のdetailまで固定する場合は、それが公開仕様なのか、内部都合で変えてよい文言なのかを先に決めてください。

起動と停止をライフサイクルとして管理する

接続プールや共有モデルの読み込みは、リクエストごとではなくアプリケーションの起動時に一度だけ行う対象です。

FastAPIの公式文書は、起動処理と停止処理をFastAPIlifespan引数へ渡す非同期コンテキストマネージャーで記述する方法を推奨しています。

yieldより前で資源を準備し、後で解放すると、確保と後始末を一つの流れで読めます。

複数ワーカーを使う場合、各ワーカーは別プロセスです。

メモリ上のキャッシュや接続プールがプロセスごとに作られることを前提に、必要メモリと同時接続数を見積もります。

コンテナ基盤で複製数を管理する構成では、一つのコンテナに一つのプロセスを置く方式も選べるため、ワーカー数は配備方式と一緒に決めます。

実装前後の確認項目

  • 入力モデルと出力モデルを分け、公開しないフィールドを出力モデルから外したか
  • 文字列長や数値範囲など、業務上の制約をモデルに表したか
  • 認証、設定、セッション取得を再利用可能な依存関数へ分けたか
  • async defの中にブロッキング入出力が残っていないか
  • 成功、入力不正、未認証、権限不足、対象なしの応答をテストしたか
  • 接続や共有資源の初期化と解放をlifespanで対にしたか
  • ワーカー数に応じたメモリと接続数を見積もったか
  • OpenAPIの差分をレビュー対象に含めたか

よくある質問

FastAPIではすべての関数をasync defにすべきですか

非同期対応ライブラリをawaitする経路にはasync defが合いますが、同期のブロッキング入出力を使う経路は通常のdefを選べます。

直接呼び出す補助関数はFastAPIがスレッドプールへ移さないため、その関数の性質も別に確認します。

Pydanticモデルを一つだけ使い回してもよいですか

小さな試作では成立しますが、保存用の内部項目や機密情報が増えると、入力と出力を同じモデルにする危険が高まります。

公開境界が異なる時点でモデルを分けるほうが安全です。

入力エラーの422応答を独自形式に変えられますか

RequestValidationErrorの例外ハンドラーを登録すれば変更できます。

ただし、既存クライアントが標準のエラー構造へ依存していないかを確認してから変更します。

BackgroundTasksで重い処理を実行してもよいですか

同じプロセス内で完了する小さな後処理には使えますが、長時間の計算や再試行が必要な処理は外部のジョブキューへ分けるほうが運用しやすくなります。

参考資料

投稿者 greeden Inc.

コメントを残す

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

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