目次
23 章

ルーティング、Pydantic モデル、依存性注入

APIRouter でルートを分離し、Pydantic v2 スキーマで入出力を定義し、Depends で共通ロジックを分離するパターンまでまとめます。

第22章 FastAPI のはじめ方とセットアップ では app/main.py 一ファイルにルートを書きました。本章では、それを 拡張しやすい構造 に組み替えます。核となる道具は 3 つです。

  • APIRouter — ルートをモジュール別に分離
  • Pydantic モデル — 入力 / 出力スキーマと自動検証
  • Depends — 依存性注入で共通ロジック (DB セッション、認証ユーザーなど) を切り出す

本章は Pydantic の表層だけを扱います。検証 / シリアライズ / discriminated union などの深い部分は、次の 第24章 Pydantic v2 の深層 で扱います。

APIRouter — ルートのモジュール化 #

ルートが増えると 1 つのファイルにすべて書くのは不自然です。APIRouter がモジュール別に分離してくれます。

app/api/todos.py
from fastapi import APIRouter

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

@router.get("/")
def list_todos():
    return [{"id": 1, "title": "FastAPI 学習"}]

@router.get("/{todo_id}")
def get_todo(todo_id: int):
    return {"id": todo_id, "title": "..."}
app/main.py
from fastapi import FastAPI
from app.api import todos

app = FastAPI(title="Todo API")
app.include_router(todos.router)

3 つの効用:

  • prefix="/todos" — すべてのルートの先頭に自動付与。@router.get("/")GET /todos/ になる
  • tags=["todos"] — Swagger UI でグルーピング
  • モジュール分離auth.pyusers.py のようにドメイン単位で分けやすい

Pydantic v2 モデル — スキーマの正体 #

Pydantic は dataclass + 検証 + シリアライズ が合わさったライブラリです。第8章 dataclass で dataclass は強い検証の用途には向いていないと述べました。その領域を Pydantic が担当します。

app/schemas/todo.py
from datetime import datetime
from pydantic import BaseModel, Field

class TodoCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    description: str | None = None

class TodoUpdate(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=200)
    description: str | None = None
    done: bool | None = None

class TodoOut(BaseModel):
    id: int
    title: str
    description: str | None
    done: bool
    created_at: datetime

    model_config = {"from_attributes": True}

核心パターン:

  • 入力用 (TodoCreateTodoUpdate)出力用 (TodoOut) を分離
  • 入力はクライアントが送れるもの、出力はサーバーが公開するもの
  • idcreated_at のようにサーバーが作るフィールドは 出力にだけ
  • パスワードのように外部に公開してはいけないフィールドは 出力で除外

この分離が セキュリティと明確さ を同時に押さえます。

from_attributes=True — ORM オブジェクトから直接 #

model_config = {"from_attributes": True} オプションは SQLAlchemy モデルのようなオブジェクトから 属性アクセス でデータを読めるようにします (旧 v1 の orm_mode)。第25章 DB 連携 — SQLAlchemy 2.x + Alembic で詳しく。

Pydantic v2 のビルトイン検証 #

よく使う検証
from pydantic import BaseModel, EmailStr, HttpUrl, Field
from datetime import datetime

class UserCreate(BaseModel):
    email: EmailStr                                       # メール形式
    username: str = Field(min_length=3, max_length=30, pattern=r"^[a-zA-Z0-9_]+$")
    password: str = Field(min_length=8)
    website: HttpUrl | None = None                        # URL
    birth_year: int = Field(ge=1900, le=2030)             # 範囲
    bio: str = Field(max_length=500, default="")

EmailStrHttpUrl は別パッケージが必要です。[standard] オプションなら既に入っています。Field() のオプション:

  • min_lengthmax_length — 長さ
  • pattern — 正規表現
  • gelegtlt — 数値の範囲
  • defaultdefault_factory — デフォルト値
  • descriptionexample — Swagger ドキュメント化

ユーザー定義の検証 #

@field_validator / @model_validator
from pydantic import BaseModel, field_validator, model_validator

class TodoCreate(BaseModel):
    title: str
    priority: int = 1

    @field_validator("title")
    @classmethod
    def title_no_html(cls, v: str) -> str:
        if "<" in v or ">" in v:
            raise ValueError("HTML タグは許可されません")
        return v.strip()

    @model_validator(mode="after")
    def check_priority_with_title(self):
        if self.priority > 5 and "緊急" not in self.title:
            raise ValueError("優先度 5 を超える場合はタイトルに '緊急' が必要")
        return self
  • @field_validator("name") — 単一フィールドの検証 / 変換
  • @model_validator(mode="after") — すべてのフィールドが埋まった後にモデル単位で検証

検証失敗時には自動で 422 Unprocessable Entity レスポンスが返ります。検証 / シリアライズのより深い使い方は第24章 Pydantic v2 の深層 で。

ルートで Pydantic を使う #

app/api/todos.py
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException
from app.schemas.todo import TodoCreate, TodoUpdate, TodoOut

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

# 一時的なインメモリストア (第25章で DB に置き換え)
_db: dict[int, TodoOut] = {}
_next_id = 1

@router.post("/", response_model=TodoOut, status_code=201)
def create_todo(payload: TodoCreate) -> TodoOut:
    global _next_id
    todo = TodoOut(
        id=_next_id,
        title=payload.title,
        description=payload.description,
        done=False,
        created_at=datetime.now(timezone.utc),
    )
    _db[_next_id] = todo
    _next_id += 1
    return todo

@router.get("/{todo_id}", response_model=TodoOut)
def read_todo(todo_id: int) -> TodoOut:
    if todo_id not in _db:
        raise HTTPException(status_code=404, detail="Todo not found")
    return _db[todo_id]

@router.patch("/{todo_id}", response_model=TodoOut)
def update_todo(todo_id: int, payload: TodoUpdate) -> TodoOut:
    if todo_id not in _db:
        raise HTTPException(status_code=404, detail="Todo not found")
    current = _db[todo_id]
    update_data = payload.model_dump(exclude_unset=True)
    updated = current.model_copy(update=update_data)
    _db[todo_id] = updated
    return updated

@router.delete("/{todo_id}", status_code=204)
def delete_todo(todo_id: int) -> None:
    if todo_id not in _db:
        raise HTTPException(status_code=404, detail="Todo not found")
    del _db[todo_id]

チェックポイント:

  • payload: TodoCreate — POST / PATCH のボディが自動で検証・パースされる
  • response_model=TodoOut — レスポンスがこのスキーマでシリアライズ。他のフィールドがあっても自動でフィルタリング
  • status_code=201 — レスポンスコードを明示
  • payload.model_dump(exclude_unset=True) — クライアントが明示したフィールドだけを取得 (PATCH の核心)

exclude_unset — PATCH の本質 #

PATCH リクエストでクライアントが title だけを送ったとき、description=None として処理してはいけません (上書き)。明示されていないフィールドはそのまま残す のが PATCH の意味であり、exclude_unset=True がそれを解決します。

model_copy で部分更新 #

current.model_copy(update={...}) が新しいインスタンスを作りつつ、一部のフィールドだけを更新します。イミュータブルオブジェクトのパターン。

依存性注入 — Depends #

同じコードを複数のルートで繰り返さないために、依存性 に切り出します。

app/api/deps.py
from typing import Annotated
from fastapi import Depends, HTTPException, Header

def verify_token(authorization: Annotated[str | None, Header()] = None) -> dict:
    if authorization is None:
        raise HTTPException(401, "Authorization header required")
    if not authorization.startswith("Bearer "):
        raise HTTPException(401, "Invalid auth scheme")
    token = authorization.split(" ", 1)[1]
    # 実際の検証は第26章で
    return {"sub": "user123"}

CurrentUser = Annotated[dict, Depends(verify_token)]
ルートで使用
@router.post("/", response_model=TodoOut)
def create_todo(payload: TodoCreate, user: CurrentUser) -> TodoOut:
    print("user:", user["sub"])
    ...

Depends(fn) が入った場所に fn が返した値 が注入されます。2 つの効用:

  1. 共通ロジックの分離 — 認証、権限、DB セッション、ページネーションなどを一か所に置き、ルートは受け取るだけ
  2. テスト時の差し替えapp.dependency_overrides[verify_token] = mock_verify で一行で差し替え。第28章 テストとデプロイ で詳しく

このパターンは 第12章 デコレータパターン のクラスデコレータと第20章 typing 高度Annotated が合わさった結果です。

依存性も依存性を持てる #

ネストされた依存性
def get_db_session() -> Iterator[Session]:
    with SessionLocal() as session:
        yield session

DBSession = Annotated[Session, Depends(get_db_session)]

def get_current_user(token: Annotated[str, Depends(...)], db: DBSession) -> User:
    user = db.query(User).filter(User.id == token).first()
    if not user:
        raise HTTPException(401)
    return user

get_current_userget_db_session に依存。FastAPI が依存性グラフを自動で解き、同じリクエストの中で 1 度だけ実行します。リクエスト単位のキャッシュ も自動。

クラス形式の依存性 — パラメータ付き依存性 #

page params クラス
from dataclasses import dataclass

@dataclass
class Pagination:
    skip: int = 0
    limit: int = 100

PaginationDep = Annotated[Pagination, Depends()]

@router.get("/")
def list_todos(p: PaginationDep) -> list[TodoOut]:
    return list(_db.values())[p.skip : p.skip + p.limit]

Depends() の空括弧にクラスだけを渡すと、そのクラスのフィールドがクエリパラメータに 自動変換されます。ページネーションのように複数のルートで繰り返されるパラメータを一か所にまとめるのにきれいです。

標準的なレスポンスモデル — ページネーション、エラー #

app/schemas/common.py
from typing import Generic, TypeVar
from pydantic import BaseModel

T = TypeVar("T")

class Page(BaseModel, Generic[T]):
    items: list[T]
    total: int
    skip: int
    limit: int

class ErrorResponse(BaseModel):
    detail: str
    code: str | None = None
ルートで
@router.get("/", response_model=Page[TodoOut])
def list_todos(p: PaginationDep) -> Page[TodoOut]:
    items = list(_db.values())
    return Page(
        items=items[p.skip : p.skip + p.limit],
        total=len(items),
        skip=p.skip,
        limit=p.limit,
    )

Page[TodoOut] のように ジェネリックレスポンス で型安全を保ちつつ、メタデータを一緒に返します。第9章 typing 本格 の Generic がそのまま動作。

例外ハンドラ — 一貫したエラー形式 #

app/main.py
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

class NotFoundError(Exception):
    def __init__(self, resource: str, key: str):
        self.resource = resource
        self.key = key

app = FastAPI()

@app.exception_handler(NotFoundError)
async def not_found_handler(request: Request, exc: NotFoundError):
    return JSONResponse(
        status_code=404,
        content={"detail": f"{exc.resource} {exc.key} なし", "code": "not_found"},
    )

ルートで:

@router.get("/{todo_id}")
def read_todo(todo_id: int) -> TodoOut:
    if todo_id not in _db:
        raise NotFoundError("Todo", str(todo_id))
    return _db[todo_id]

ドメイン例外を作り、それが HTTP レスポンスに変換される処理を 1 箇所にまとめます。ルートは読みやすくなります。第6章 エラーと例外処理 のユーザー定義例外階層のパターンがそのまま生きます。

CORS — フロントエンドと分離運用 #

ブラウザアプリが別ドメインの API を呼び出すには CORS の設定が必要です。

CORS ミドルウェア
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000", "https://myapp.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

allow_origins=["*"] は開発段階でだけ。プロダクションでは明示的なドメインリストに絞ってください。

練習問題 #

  1. APIRoutertodos ルーターを別ファイルに作り、app.include_router(...) で登録してください。/docs でルートが todos タグでグルーピングされるか、prefix が自動で付くかを確認します。
  2. TodoCreate / TodoUpdate / TodoOut の 3 つのスキーマを書き、POST / PATCH / GET の 3 つのルートを実装してください。PATCH で model_dump(exclude_unset=True) + model_copy(update=...) パターンが動作するか (description だけを送ったリクエストが title を上書きしないか) を確認します。
  3. Depends でページネーション依存性 Pagination (skiplimit を持つ dataclass) を作り、GET /todos がクエリパラメータとして受け取るようにします。Depends() の空括弧にクラスを渡すパターンが Swagger UI でどのように見えるかを確認します。

一行まとめ: APIRouter で prefix + tags + モジュール分離。Pydantic v2 の BaseModel + Field 検証、EmailStr / HttpUrl@field_validator / @model_validator。入力 / 出力スキーマの分離はセキュリティ + 明確さ。response_model でレスポンスシリアライズ、PATCH は model_dump(exclude_unset=True) + model_copyDepends で認証 / DB / ページネーションなど共通ロジックを分離、依存性グラフ自動解決 + リクエスト単位のキャッシュ。Page[T] ジェネリックレスポンス、@app.exception_handler でドメイン例外 → HTTP、CORS ミドルウェア。

次の章 #

次の 第24章 Pydantic v2 の深層 ★新規章では本章の Pydantic 表層を深さに広げます。v1 → v2 の変化、検証ライフサイクル、カスタムシリアライズ、JSON Schema 生成など FastAPI の核を扱います。

X