ルーティング、Pydantic モデル、依存性注入
APIRouter でルートを分離し、Pydantic v2 スキーマで入出力を定義し、Depends で共通ロジックを分離するパターンまでまとめます。
第22章 FastAPI のはじめ方とセットアップ では app/main.py 一ファイルにルートを書きました。本章では、それを 拡張しやすい構造 に組み替えます。核となる道具は 3 つです。
- APIRouter — ルートをモジュール別に分離
- Pydantic モデル — 入力 / 出力スキーマと自動検証
Depends— 依存性注入で共通ロジック (DB セッション、認証ユーザーなど) を切り出す
本章は Pydantic の表層だけを扱います。検証 / シリアライズ / discriminated union などの深い部分は、次の 第24章 Pydantic v2 の深層 で扱います。
APIRouter — ルートのモジュール化 #
ルートが増えると 1 つのファイルにすべて書くのは不自然です。APIRouter がモジュール別に分離してくれます。
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": "..."}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.py、users.pyのようにドメイン単位で分けやすい
Pydantic v2 モデル — スキーマの正体 #
Pydantic は dataclass + 検証 + シリアライズ が合わさったライブラリです。第8章 dataclass で dataclass は強い検証の用途には向いていないと述べました。その領域を Pydantic が担当します。
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}核心パターン:
- 入力用 (
TodoCreate、TodoUpdate) と 出力用 (TodoOut) を分離 - 入力はクライアントが送れるもの、出力はサーバーが公開するもの
id、created_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="")EmailStr、HttpUrl は別パッケージが必要です。[standard] オプションなら既に入っています。Field() のオプション:
min_length、max_length— 長さpattern— 正規表現ge、le、gt、lt— 数値の範囲default、default_factory— デフォルト値description、example— Swagger ドキュメント化
ユーザー定義の検証 #
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 を使う #
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
#
同じコードを複数のルートで繰り返さないために、依存性 に切り出します。
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 つの効用:
- 共通ロジックの分離 — 認証、権限、DB セッション、ページネーションなどを一か所に置き、ルートは受け取るだけ
- テスト時の差し替え —
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 userget_current_user が get_db_session に依存。FastAPI が依存性グラフを自動で解き、同じリクエストの中で 1 度だけ実行します。リクエスト単位のキャッシュ も自動。
クラス形式の依存性 — パラメータ付き依存性 #
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() の空括弧にクラスだけを渡すと、そのクラスのフィールドがクエリパラメータに 自動変換されます。ページネーションのように複数のルートで繰り返されるパラメータを一か所にまとめるのにきれいです。
標準的なレスポンスモデル — ページネーション、エラー #
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 がそのまま動作。
例外ハンドラ — 一貫したエラー形式 #
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 の設定が必要です。
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=["*"] は開発段階でだけ。プロダクションでは明示的なドメインリストに絞ってください。
練習問題 #
APIRouterでtodosルーターを別ファイルに作り、app.include_router(...)で登録してください。/docsでルートがtodosタグでグルーピングされるか、prefix が自動で付くかを確認します。TodoCreate/TodoUpdate/TodoOutの 3 つのスキーマを書き、POST / PATCH / GET の 3 つのルートを実装してください。PATCH でmodel_dump(exclude_unset=True)+model_copy(update=...)パターンが動作するか (descriptionだけを送ったリクエストがtitleを上書きしないか) を確認します。Dependsでページネーション依存性Pagination(skip、limitを持つ 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_copy。Dependsで認証 / DB / ページネーションなど共通ロジックを分離、依存性グラフ自動解決 + リクエスト単位のキャッシュ。Page[T]ジェネリックレスポンス、@app.exception_handlerでドメイン例外 → HTTP、CORS ミドルウェア。
次の章 #
次の 第24章 Pydantic v2 の深層 ★新規章では本章の Pydantic 表層を深さに広げます。v1 → v2 の変化、検証ライフサイクル、カスタムシリアライズ、JSON Schema 生成など FastAPI の核を扱います。