目次
29 章

総合実習 — TODO API を完成させる

第1〜28章のパターンを 1 つの動作するサービスに編みます。認証されたユーザー別の TODO CRUD、タグフィルタ、ページネーション、バックグラウンド通知、テスト、デプロイまで。

4部の最後の章であり、本書の第1 〜 28章の すべての道具を 1 つのサービスに束ねる総合実習 です。第22 〜 28章で FastAPI の各道具 (ルーティング / Pydantic / DB / 認証 / 非同期 / テスト / デプロイ) を個別に見たなら、本章はそれらが 実際の 1 つのプロジェクトでどう噛み合うか を段階的に作りながら見ます。

本章で作る成果物は次の 5部の 第30章 型チェッカ設定と CI 統合第31章 logging と観測性第33章 CLI ツールを作る (Typer) の実戦適用対象になります。そのため本章は 4部と 5部をつなぐ橋の役割も果たします。

完成するサービス #

TODO API — 認証されたユーザーが自分の TODO を管理する小さなバックエンド。

機能適用章
会員登録 / ログイン (JWT)26
TODO CRUD23、24、25
タグフィルタ + ページネーション23
優先度 (1 〜 5)24
締め切り間近通知 (バックグラウンド)27
ヘルスチェック + メトリクス22、31
統合テスト28
Docker + クラウドデプロイ28

エンドポイント表:

メソッドパス意図
POST/auth/register会員登録
POST/auth/loginログイン、JWT 発行
GET/me現在のユーザー情報
POST/todosTODO 作成
GET/todosTODO 一覧 (フィルタ + ページネーション)
GET/todos/{id}TODO 単件
PATCH/todos/{id}TODO 部分更新
DELETE/todos/{id}TODO 削除
GET/healthヘルスチェック

本章は 段階的に 一つずつ構築しながら、各段階でどの章のパターンが入るかをボックスで表記します。

0. 最終プロジェクト構造 #

まず成果物の構造を見て始めます。

todo-api/
todo-api/
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── .dockerignore
├── alembic.ini
├── alembic/
│   ├── env.py
│   └── versions/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── core/
│   │   ├── config.py
│   │   ├── security.py
│   │   └── logging.py
│   ├── api/
│   │   ├── deps.py
│   │   ├── auth.py
│   │   ├── todos.py
│   │   └── health.py
│   ├── models/
│   │   ├── base.py
│   │   ├── user.py
│   │   └── todo.py
│   ├── schemas/
│   │   ├── common.py
│   │   ├── user.py
│   │   └── todo.py
│   ├── services/
│   │   ├── user.py
│   │   └── todo.py
│   ├── db/
│   │   └── session.py
│   └── tasks/
│       └── notifier.py
└── tests/
    ├── conftest.py
    ├── test_auth.py
    └── test_todos.py

第7章 モジュール、パッケージと pyproject.toml のパッケージ分離パターンがそのまま生きます。この構造が本書全体で「モジュールをよく分けることがなぜ重要なのか」の総合的な答えです。

1. プロジェクトセットアップ [第22章] #

適用章: 第1章 はじめ方と uv セットアップ第7章 モジュール、パッケージと pyproject.toml第22章 FastAPI のはじめ方とセットアップ

セットアップ
uv init todo-api --python 3.14
cd todo-api
uv add "fastapi[standard]" sqlalchemy "sqlalchemy[asyncio]" aiosqlite asyncpg
uv add argon2-cffi pyjwt pydantic-settings
uv add --dev pytest pytest-asyncio pytest-cov httpx pytest-httpx
uv add --dev pyright ruff alembic
app/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    app_name: str = "Todo API"
    database_url: str = "sqlite+aiosqlite:///./todo.db"
    jwt_secret: str = "dev-only-change-me"
    jwt_expire_minutes: int = 60
    notification_check_interval: int = 60   # バックグラウンド作業の間隔 (秒)

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

settings = Settings()
app/main.py — 出発点
from fastapi import FastAPI
from app.core.config import settings
from app.api import auth, todos, health

app = FastAPI(title=settings.app_name, version="0.1.0")
app.include_router(auth.router)
app.include_router(todos.router)
app.include_router(health.router)

2. DB モデル設計 [第25章] #

適用章: 第8章 dataclass第25章 DB 連携

app/models/base.py
from sqlalchemy.orm import DeclarativeBase

class Base(DeclarativeBase):
    pass
app/models/user.py
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base

class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(200), unique=True, index=True)
    hashed_password: Mapped[str]

    todos: Mapped[list["Todo"]] = relationship(back_populates="owner", cascade="all, delete-orphan")
app/models/todo.py
from datetime import datetime, date
from sqlalchemy import String, Boolean, DateTime, Date, ForeignKey, Integer, JSON, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base

class Todo(Base):
    __tablename__ = "todos"

    id: Mapped[int] = mapped_column(primary_key=True)
    owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)

    title: Mapped[str] = mapped_column(String(200))
    description: Mapped[str] = mapped_column(default="")
    done: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
    priority: Mapped[int] = mapped_column(Integer, default=3)
    due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
    tags: Mapped[list[str]] = mapped_column(JSON, default=list)

    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
    )

    owner: Mapped["User"] = relationship(back_populates="todos")
app/db/session.py
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession
from app.core.config import settings

engine = create_async_engine(settings.database_url, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

cascade="all, delete-orphan" — ユーザー削除時にそのユーザーのすべての TODO も一緒に削除。ドメインポリシーを一行で明示。

tags: list[str] を JSON カラムで — 小さなプロジェクトでは別途タグテーブル + 多対多関係は過剰な場合。運用規模が大きくなれば正規化を検討。

3. 認証 [第26章] #

適用章: 第26章 認証 — OAuth2 + JWT

app/core/security.py
from datetime import datetime, timedelta, timezone
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from app.core.config import settings

_hasher = PasswordHasher()
ALGORITHM = "HS256"

def hash_password(plain: str) -> str:
    return _hasher.hash(plain)

def verify_password(plain: str, hashed: str) -> bool:
    try:
        _hasher.verify(hashed, plain)
        return True
    except VerifyMismatchError:
        return False

def create_access_token(subject: str) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": subject,
        "iat": now,
        "exp": now + timedelta(minutes=settings.jwt_expire_minutes),
    }
    return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)

def decode_token(token: str) -> dict:
    return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
app/services/user.py
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.core.security import hash_password, verify_password

async def create(db: AsyncSession, email: str, password: str) -> User:
    user = User(email=email, hashed_password=hash_password(password))
    db.add(user)
    await db.commit()
    await db.refresh(user)
    return user

async def get_by_email(db: AsyncSession, email: str) -> User | None:
    result = await db.execute(select(User).where(User.email == email))
    return result.scalar_one_or_none()

async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
    user = await get_by_email(db, email)
    if user is None or not verify_password(password, user.hashed_password):
        return None
    return user

4. 依存性注入で権限 + セッションを整理 [第23章] #

適用章: 第23章 ルーティング、Pydantic モデル、依存性注入

app/api/deps.py
from typing import Annotated, AsyncIterator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jwt import InvalidTokenError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import AsyncSessionLocal
from app.core.security import decode_token
from app.models.user import User

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")

async def get_session() -> AsyncIterator[AsyncSession]:
    async with AsyncSessionLocal() as session:
        yield session

DBSession = Annotated[AsyncSession, Depends(get_session)]

async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)],
    db: DBSession,
) -> User:
    exc = HTTPException(
        status.HTTP_401_UNAUTHORIZED,
        "有効でないトークン",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = decode_token(token)
        user_id = int(payload["sub"])
    except (InvalidTokenError, KeyError, ValueError):
        raise exc

    user = await db.get(User, user_id)
    if user is None:
        raise exc
    return user

CurrentUser = Annotated[User, Depends(get_current_user)]

# ページネーション依存性
from dataclasses import dataclass

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

PaginationDep = Annotated[Pagination, Depends()]

DBSessionCurrentUserPaginationDep の 3 つの依存性がほぼすべてのルートに入ります。

5. 認証ルート [第26章] #

app/schemas/user.py
from pydantic import BaseModel, EmailStr, Field, ConfigDict, SecretStr

class UserCreate(BaseModel):
    email: EmailStr
    password: SecretStr = Field(min_length=8, max_length=128)

class UserOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    email: EmailStr

class Token(BaseModel):
    access_token: str
    token_type: str = "bearer"

SecretStr の使用は 第24章 Pydantic v2 の深層 の PII マスキングパターン。入力は平文ですが、ログ / repr では自動マスキング。

app/api/auth.py
from typing import Annotated
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm
from app.api.deps import DBSession, CurrentUser
from app.schemas.user import UserCreate, UserOut, Token
from app.services import user as user_service
from app.core.security import create_access_token

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

@router.post("/register", response_model=UserOut, status_code=201)
async def register(payload: UserCreate, db: DBSession) -> UserOut:
    existing = await user_service.get_by_email(db, payload.email)
    if existing:
        raise HTTPException(409, "既に登録済みのメール")
    return await user_service.create(db, payload.email, payload.password.get_secret_value())

@router.post("/login", response_model=Token)
async def login(
    form: Annotated[OAuth2PasswordRequestForm, Depends()],
    db: DBSession,
) -> Token:
    user = await user_service.authenticate(db, form.username, form.password)
    if user is None:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Incorrect credentials")
    token = create_access_token(subject=str(user.id))
    return Token(access_token=token)

# /me ルートも一緒に
me_router = APIRouter(tags=["me"])

@me_router.get("/me", response_model=UserOut)
async def me(current: CurrentUser) -> UserOut:
    return current

6. TODO CRUD [第23、24、25章] #

app/schemas/todo.py
from datetime import datetime, date
from pydantic import BaseModel, Field, field_validator, ConfigDict
from typing import Annotated

Priority = Annotated[int, Field(ge=1, le=5)]

class TodoBase(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True)

    title: str = Field(min_length=1, max_length=200)
    description: str = ""
    priority: Priority = 3
    due_date: date | None = None
    tags: list[str] = Field(default_factory=list)

    @field_validator("tags")
    @classmethod
    def normalize_tags(cls, v: list[str]) -> list[str]:
        # 重複除去 + 小文字化 + 空タグ除去
        return sorted({t.strip().lower() for t in v if t.strip()})

class TodoCreate(TodoBase):
    pass

class TodoUpdate(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=200)
    description: str | None = None
    done: bool | None = None
    priority: Priority | None = None
    due_date: date | None = None
    tags: list[str] | None = None

class TodoOut(TodoBase):
    model_config = ConfigDict(from_attributes=True)

    id: int
    done: bool
    created_at: datetime
    updated_at: datetime
app/schemas/common.py
from pydantic import BaseModel

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

この Page[T]第9章 typing 本格 の Generic + 第24章 Pydantic v2 の深層 の Generic モデルパターン。

app/services/todo.py
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.todo import Todo
from app.schemas.todo import TodoCreate, TodoUpdate

async def create(db: AsyncSession, data: TodoCreate, owner_id: int) -> Todo:
    todo = Todo(**data.model_dump(), owner_id=owner_id)
    db.add(todo)
    await db.commit()
    await db.refresh(todo)
    return todo

async def get(db: AsyncSession, todo_id: int, owner_id: int) -> Todo | None:
    result = await db.execute(
        select(Todo).where(Todo.id == todo_id, Todo.owner_id == owner_id)
    )
    return result.scalar_one_or_none()

async def list_filtered(
    db: AsyncSession,
    owner_id: int,
    *,
    skip: int = 0,
    limit: int = 50,
    done: bool | None = None,
    tag: str | None = None,
) -> tuple[list[Todo], int]:
    stmt = select(Todo).where(Todo.owner_id == owner_id)
    if done is not None:
        stmt = stmt.where(Todo.done == done)
    if tag is not None:
        # JSON カラムで tag が含まれる行 (DB 依存的 — SQLite/Postgres 両方サポート)
        stmt = stmt.where(Todo.tags.contains([tag]))

    total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
    items = list((await db.execute(
        stmt.order_by(Todo.created_at.desc()).offset(skip).limit(limit)
    )).scalars())
    return items, total

async def update(db: AsyncSession, todo: Todo, data: TodoUpdate) -> Todo:
    for field, value in data.model_dump(exclude_unset=True).items():
        setattr(todo, field, value)
    await db.commit()
    await db.refresh(todo)
    return todo

async def delete(db: AsyncSession, todo: Todo) -> None:
    await db.delete(todo)
    await db.commit()

所有者検証は service レイヤで — owner_id が一致しなければ None を返します。ルートは None → 404 に変換します。

app/api/todos.py
from fastapi import APIRouter, HTTPException, Query
from typing import Annotated
from app.api.deps import DBSession, CurrentUser, PaginationDep
from app.schemas.todo import TodoCreate, TodoUpdate, TodoOut
from app.schemas.common import Page
from app.services import todo as todo_service

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

@router.post("", response_model=TodoOut, status_code=201)
async def create_todo(payload: TodoCreate, user: CurrentUser, db: DBSession) -> TodoOut:
    return await todo_service.create(db, payload, owner_id=user.id)

@router.get("", response_model=Page[TodoOut])
async def list_todos(
    user: CurrentUser,
    db: DBSession,
    pagination: PaginationDep,
    done: Annotated[bool | None, Query()] = None,
    tag: Annotated[str | None, Query()] = None,
) -> Page[TodoOut]:
    items, total = await todo_service.list_filtered(
        db, owner_id=user.id,
        skip=pagination.skip, limit=pagination.limit,
        done=done, tag=tag,
    )
    return Page(items=items, total=total, skip=pagination.skip, limit=pagination.limit)

@router.get("/{todo_id}", response_model=TodoOut)
async def read_todo(todo_id: int, user: CurrentUser, db: DBSession) -> TodoOut:
    todo = await todo_service.get(db, todo_id, owner_id=user.id)
    if todo is None:
        raise HTTPException(404, "Todo not found")
    return todo

@router.patch("/{todo_id}", response_model=TodoOut)
async def update_todo(
    todo_id: int,
    payload: TodoUpdate,
    user: CurrentUser,
    db: DBSession,
) -> TodoOut:
    todo = await todo_service.get(db, todo_id, owner_id=user.id)
    if todo is None:
        raise HTTPException(404, "Todo not found")
    return await todo_service.update(db, todo, payload)

@router.delete("/{todo_id}", status_code=204)
async def delete_todo(todo_id: int, user: CurrentUser, db: DBSession) -> None:
    todo = await todo_service.get(db, todo_id, owner_id=user.id)
    if todo is None:
        raise HTTPException(404, "Todo not found")
    await todo_service.delete(db, todo)

user: CurrentUser の一行がすべてのルートの認証 + 権限分離を解きます。ルートコードがビジネスロジックに集中。

7. ヘルスチェック + メトリクス [第22、31章] #

app/api/health.py
from fastapi import APIRouter
from app.api.deps import DBSession
from sqlalchemy import text

router = APIRouter(tags=["ops"])

@router.get("/health")
async def health() -> dict[str, str]:
    return {"status": "healthy"}

@router.get("/health/db")
async def health_db(db: DBSession) -> dict[str, str]:
    try:
        await db.execute(text("SELECT 1"))
        return {"status": "ok", "db": "connected"}
    except Exception as e:
        return {"status": "error", "db": str(e)}

基本ヘルスチェックと DB ヘルスチェックを分離。クラウドの health probe (Railway / Fly など) は /health のみ使用、/health/db は運用者がデバッグ用。

8. バックグラウンド通知 [第27章] #

app/tasks/notifier.py
import asyncio
from datetime import date, timedelta
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.todo import Todo
from app.core.config import settings

async def check_due_todos() -> list[int]:
    """締め切りが今日または明日の未完了 TODO の id 一覧。"""
    tomorrow = date.today() + timedelta(days=1)
    async with AsyncSessionLocal() as db:
        result = await db.execute(
            select(Todo.id).where(
                Todo.done == False,
                Todo.due_date.between(date.today(), tomorrow),
            )
        )
        return list(result.scalars())

async def notifier_loop():
    """周期的に締め切り間近の TODO を確認して通知シミュレーション。"""
    while True:
        ids = await check_due_todos()
        for todo_id in ids:
            print(f"[notifier] 締め切り間近 TODO #{todo_id}")    # 実際はメール/Slack など
        await asyncio.sleep(settings.notification_check_interval)
app/main.py 拡張 — lifespan
from contextlib import asynccontextmanager
import asyncio
from fastapi import FastAPI
from app.tasks.notifier import notifier_loop
from app.api import auth, todos, health

@asynccontextmanager
async def lifespan(app: FastAPI):
    # 起動時にバックグラウンド作業開始
    task = asyncio.create_task(notifier_loop())
    app.state.notifier_task = task
    yield
    # 終了時に整理
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        pass

app = FastAPI(title="Todo API", lifespan=lifespan)
app.include_router(auth.router)
app.include_router(todos.router)
app.include_router(health.router)

第27章 非同期とバックグラウンドジョブlifespan + Task パターン。このシンプルな通知がさらに大きくなれば ARQ / Celery のような外部キューに移す時点。

9. テスト [第28、30章] #

tests/conftest.py
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.main import app
from app.api.deps import get_session, get_current_user
from app.models.base import Base
from app.models.user import User
from app.core.security import hash_password

TEST_URL = "sqlite+aiosqlite:///:memory:"

@pytest_asyncio.fixture
async def db_engine():
    engine = create_async_engine(TEST_URL)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()

@pytest_asyncio.fixture
async def db_session(db_engine):
    Session = async_sessionmaker(db_engine, expire_on_commit=False)
    async with Session() as session:
        yield session

@pytest_asyncio.fixture
async def test_user(db_session) -> User:
    user = User(email="test@example.com", hashed_password=hash_password("password123"))
    db_session.add(user)
    await db_session.commit()
    await db_session.refresh(user)
    return user

@pytest_asyncio.fixture
async def authed_client(db_session, test_user):
    async def override_session():
        yield db_session

    async def override_user():
        return test_user

    app.dependency_overrides[get_session] = override_session
    app.dependency_overrides[get_current_user] = override_user

    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as c:
        yield c

    app.dependency_overrides.clear()
tests/test_todos.py
import pytest

pytestmark = pytest.mark.asyncio

async def test_create_and_get_todo(authed_client):
    create = await authed_client.post("/todos", json={"title": "テスト", "priority": 4})
    assert create.status_code == 201
    todo_id = create.json()["id"]

    get = await authed_client.get(f"/todos/{todo_id}")
    assert get.json()["title"] == "テスト"
    assert get.json()["priority"] == 4

async def test_filter_by_done(authed_client):
    await authed_client.post("/todos", json={"title": "open"})
    closed = await authed_client.post("/todos", json={"title": "closed"})
    await authed_client.patch(f"/todos/{closed.json()['id']}", json={"done": True})

    open_only = await authed_client.get("/todos?done=false")
    assert open_only.json()["total"] == 1
    assert open_only.json()["items"][0]["title"] == "open"

async def test_tag_normalization(authed_client):
    resp = await authed_client.post("/todos", json={
        "title": "with tags",
        "tags": ["Python", "  fastapi  ", "python", ""],
    })
    # 重複除去 + 小文字 + 空タグ除去
    assert sorted(resp.json()["tags"]) == ["fastapi", "python"]

async def test_404_on_other_user_todo(authed_client, db_session):
    # 別ユーザー + そのユーザーの TODO を直接挿入
    from app.models.user import User
    from app.models.todo import Todo
    other = User(email="other@x.com", hashed_password="x")
    db_session.add(other)
    await db_session.commit()
    foreign = Todo(title="not yours", owner_id=other.id)
    db_session.add(foreign)
    await db_session.commit()

    resp = await authed_client.get(f"/todos/{foreign.id}")
    assert resp.status_code == 404    # 別ユーザーの TODO は見えない

所有権分離のテストは最も重要なセキュリティケースの一つ。必ず 含めてください。

10. Docker + デプロイ [第28章] #

Dockerfile
FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY app/ ./app/
COPY alembic/ ./alembic/
COPY alembic.ini ./

FROM python:3.14-slim AS runtime
RUN useradd --create-home --uid 1000 appuser
WORKDIR /app
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
USER appuser
EXPOSE 8000

# マイグレーション適用後にサーバー開始
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]

小さなプロジェクトはマイグレーションをコンテナ起動時に一緒に — 大きなプロジェクトは 第28章 のオプション 2 (CI/CD 段階分離) を推奨。

docker-compose.yml — ローカルフルスタック
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql+asyncpg://todo:todo@db:5432/todo
      JWT_SECRET: dev-secret-only
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: todo
      POSTGRES_PASSWORD: todo
      POSTGRES_DB: todo
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U todo"]
      interval: 5s
      retries: 5
    volumes:
      - pg-data:/var/lib/postgresql/data

volumes:
  pg-data:
実行
docker compose up -d
curl http://localhost:8000/health    # {"status":"healthy"}

本書全体のパターンがどこに入ったか — チェックリスト #

この総合実習の 1 プロジェクトで本書全体の道具がどう生きたかをチェック。

どこに
第1章 uv1 — プロジェクトセットアップ
第2章 型ヒントすべての関数シグネチャ
第3章 制御フローservice レイヤの分岐
第4章 コレクション / 内包表記tags 正規化 (set 内包表記)
第5章 関数の引数service 関数の keyword-only 引数 (*)
第6章 例外処理HTTPException、JWT デコード except
第7章 モジュール / pyprojectプロジェクト構造
第8章 dataclassPagination 依存性
第9章 Generic / ProtocolPage[T] レスポンス
第10章 コンテキストマネージャDB セッション依存性、lifespan
第11章 イテラブル / ジェネレータget_session async generator
第12章 デコレータ@router.post(...)、Depends
第13章 パターンマッチ(オプション — イベント処理を追加するとき)
第14章 非同期入門すべてのルートが async、asyncio.sleep
第15章 マジックメソッドPydantic の __init__ / __call__ の上に
第16章 ディスクリプタSQLAlchemy Mapped[T] / Pydantic Field
第17章 メタクラスSQLAlchemy DeclarativeBase、Pydantic BaseModel
第18章 非同期の深層lifespan の create_task + cancel
第19章 GIL / 並行性async def 選択根拠
第20章 typing 高度Annotated[int, Field(...)]Self
第21章 性能(運用段階で — cProfile / py-spy)
第22章 FastAPI セットアップapp/main.py
第23章 ルーティング / DIAPIRouter、Depends
第24章 Pydantic v2すべてのスキーマ、SecretStr、field_validator
第25章 DB SQLAlchemyモデル + セッション + Alembic
第26章 認証argon2 + JWT + OAuth2
第27章 非同期 / バックグラウンドnotifier_loop
第28章 テスト / デプロイconftest、Dockerfile、compose

5部の運用章はこの総合実習の上に乗ります:

練習問題 #

  1. 上のコードを直接書き写して todo-api を動かしてください。docker compose up -dcurl /health が OK か、registerloginPOST /todos の流れが最後まで動くかを確認します。Swagger UI の「Authorize」で JWT を入力して認証済みの呼び出しも直接試します。
  2. due_date が今日の TODO があるとき、バックグラウンド通知のログが出力されるかを確認します。notification_check_interval を 5 秒に縮めて速くテストします。
  3. この総合実習に次の機能のいずれかを追加してください: (1) TODO 検索 (?q= クエリで title LIKE)、(2) TODO 並び替えオプション (?sort=priority / ?sort=-due_date)、(3) 統計エンドポイント (GET /stats — 全体 / 完了 / 未完了の数)。変更後にテストケースも一緒に追加します。

一行まとめ: 総合実習は第1 〜 28章のすべての道具が実際の 1 プロジェクトでどう噛み合うかを見せる場。段階 = プロジェクトセットアップ → DB モデル → 認証 → 依存性 / ルート → CRUD → ヘルスチェック → バックグラウンド → テスト → Docker。各段階でどの章のパターンが入るかを意識しながら直接書き写すのが本章の核心的な活用。5部の運用章はこの総合実習の上に乗る。

次の章 #

次の 第30章 型チェッカ設定と CI 統合 から 5部 運用・パッケージング・テスト の新規 4 章が始まります。この総合実習プロジェクトが 5部の適用対象になります。

X