フェーズ2総合演習 — PriceTrackerコア機能のクローズドループ実装
フェーズ2は自動車の最終組立のようなものです。エンジン (データベース), ステアリングホイール (ルーティング), シートベルト (認証), ダッシュボード (ミドルウェア)がすべて完成し, 今やそれらを組み立てて完成した車にします。
1. 学ぶ内容
- 3層DIチェーンの完成:
get_db→get_current_user→require_subscription("pro") - エンドツーエンドのJWT保護:パブリックエンドポイントと認証済みエンドポイントのルーティンググループポリシー
- CORSミドルウェア設定:Bobのフロントエンドドメインからのクロスドメインリソースアクセスを許可
- CRUD操作, ページネーション, 関連データの事前読み込みを含むエンドツーエンドテストワークフロー
- Aliceシナリオ検証:Proユーザーは百万件の価格データを一括インポートでき, Freeユーザーは1,000件に制限される
2. Aliceのリアルストーリー
(1) ペインポイント:機能が散在し統合テストができない
Aliceはミドルウェア, 依存性注入, データベース, CRUD, JWT認証を個別に実装しましたが, 各機能は独立してテストされていました。すべての機能を統合しようとしたとき, CORSミドルウェアとJWT認証の実行順序が競合し, DIチェーンでDBセッションが2回作成され, Proユーザーのレート制限ロジックと権限チェックが互いに上書きし合うことが判明しました。
(2) 体系的な統合アプローチ
フェーズ2の総合演習では, すべてのモジュールを正しい階層と順序で統合します:CORS (最外層)→ レート制限 → 認証 → ビジネスロジック → データベース (最内層)。各層の責任が明確に定義され, 他の層から独立して動作します。
(3) 収益
統合されたPriceTrackerコアサービスが完全に稼働しました。Bobのフロントエンドがクロスドメインリソースに正常にアクセスし, すべての保護されたエンドポイントがJWTトークンを要求し, Proユーザーが一括インポートに問題なく対応し, Freeユーザーが正しくレート制限されています。
3. フェーズ2のアーキテクチャ概要
(1) 完全なリクエストフロー
flowchart TD
Client[Bob Frontend] --> CORS[CORS Middleware]
CORS --> RateLimit[Rate Limit Middleware]
RateLimit --> Router[FastAPI Router]
Router --> AuthDI{Auth Required?}
AuthDI -->|Yes| GetDB[get_db Dependency]
AuthDI -->|No| Handler[Handler]
GetDB --> GetUser[get_current_user]
GetUser --> CheckSub[require_subscription]
CheckSub --> Handler
Handler --> Repo[Repository Layer]
Repo --> DB[(PostgreSQL)]
RateLimit -.->|429| Reject[Rate Limited]
GetUser -.->|401| Unauth[Unauthorized]
CheckSub -.->|403| Forbid[Forbidden]
(2) ルーティンググループポリシー
| ルーティンググループ | プレフィックス | 認証 | 説明 |
|---|---|---|---|
| public | / |
なし | health, docs |
| api_v1 | /api/v1 |
JWT必須 | すべてのビジネスエンドポイント |
| admin | /api/v1/admin |
JWT + Admin | 管理エンドポイント |
4. 完全なコードの統合
(1) アプリケーションエントリポイントとミドルウェア設定
(1) ▶サンプル:main.pyの完全な設定
PYTHON
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
from app.api.routes import products, prices, auth
from app.core.config import settings
app = FastAPI(
title=settings.app_name,
version="1.0.0",
description="PriceTracker SaaS API - E-commerce price tracking service",
)
# ミドルウェアの順序:最後に登録されたものが最内層 (リクエスト時に最初に実行)
# CORSは最外層にする (最後に登録)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ルーターを含める - 最初にパブリック, 次に保護済み
app.include_router(auth.router, tags=["auth"])
app.include_router(
products.router,
prefix="/api/v1",
dependencies=[Depends(get_current_user)],
tags=["products"],
)
app.include_router(
prices.router,
prefix="/api/v1",
dependencies=[Depends(get_current_user)],
tags=["prices"],
)
@app.get("/health")
async def health_check():
return {"status": "healthy", "service": "pricetracker"}
出力:
TEXT
# 関数定義成功
(2) 3層DIチェーンの完全な実装
(2) ▶サンプル:app/core/deps.py
PYTHON
from fastapi import Depends, HTTPException, Header
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from jose import jwt, JWTError
from app.db import async_session
from app.core.config import settings
from app.core.security import verify_password
from app.models import User
from sqlalchemy import select
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
# レベル1:データベースセッション
async def get_db():
async with async_session() as session:
try:
yield session
except Exception:
await session.rollback()
raise
# レベル2:現在のユーザー
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
):
credentials_exception = HTTPException(
status_code=401,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
token, settings.secret_key, algorithms=[settings.algorithm]
)
username = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
stmt = select(User).where(User.email == username)
result = await db.execute(stmt)
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
# レベル3:サブスクリプション確認
def require_subscription(min_level: str = "free"):
LEVELS = {"free": 0, "pro": 1, "enterprise": 2}
async def check(user: User = Depends(get_current_user)):
if LEVELS.get(user.subscription, 0) < LEVELS.get(min_level, 0):
raise HTTPException(
status_code=403,
detail=f"Requires {min_level} plan. Current: {user.subscription}",
)
return user
return check
出力:
TEXT
# 関数定義成功
(3) 権限検証プロセス
flowchart LR
Request[Request with Token] --> ParseToken[Parse JWT Token]
ParseToken --> ValidToken{Token Valid?}
ValidToken -->|No| Return401[Return 401]
ValidToken -->|Yes| QueryUser[Query User from DB]
QueryUser --> UserExists{User Found?}
UserExists -->|No| Return401
UserExists -->|Yes| CheckSub{Subscription Level?}
CheckSub -->|Free| AllowBasic[Allow Basic Endpoints]
CheckSub -->|Pro| AllowPro[Allow Pro Endpoints]
CheckSub -->|Enterprise| AllowAll[Allow All Endpoints]
(3) ▶サンプル:保護されたプロダクトルーティング
PYTHON
from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from app.core.deps import get_db, get_current_user, require_subscription
from app.schemas import ProductCreate, ProductResponse, ProductUpdate
from app.services import ProductService
router = APIRouter()
@router.get("/products", response_model=list[ProductResponse])
async def list_products(
category: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
service = ProductService(db)
return await service.list_products(category=category, skip=skip, limit=limit)
@router.post("/products", response_model=ProductResponse, status_code=201)
async def create_product(
product: ProductCreate,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
service = ProductService(db)
return await service.create_product(product, user_id=user.id)
@router.get("/products/{product_id}", response_model=ProductResponse)
async def get_product(
product_id: int = Path(gt=0),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
):
service = ProductService(db)
product = await service.get_product(product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return product
出力:
TEXT
# 関数定義成功
(4) ▶サンプル:一括インポートエンドポイント (サブスクリプション制限付き)
PYTHON
@router.post("/products/{product_id}/prices/bulk")
async def bulk_import_prices(
product_id: int = Path(gt=0),
prices: list[PriceCreate] = ...,
db: AsyncSession = Depends(get_db),
user=Depends(require_subscription("free")), # Freeユーザーでもインポート可能
):
# Freeプラン:1回のインポートは最大1000件
limits = {"free": 1000, "pro": 100000, "enterprise": 1000000}
max_import = limits.get(user.subscription, 1000)
if len(prices) > max_import:
raise HTTPException(
status_code=403,
detail=f"Import limit: {max_import} for {user.subscription} plan. Upgrade at /pricing",
)
service = PriceService(db)
return await service.bulk_import_prices(product_id, prices)
出力:
TEXT
# 関数定義成功
❓ よくある質問
Q CORSはミドルウェアの順序でなぜ最後に登録されるのですか?
A CORSはすべてのレスポンス (4xxエラーを含む)にCORSヘッダーを追加する必要があります。CORSが最外層でない場合, レート制限ミドルウェアが返す429レスポンスにCORSヘッダーが欠落し, フロントエンドがエラーメッセージを読み取れなくなる可能性があります。
Q ルーティンググループの依存性とエンドポイントの依存性は複数回実行されますか?
A いいえ。FastAPIはリクエストレベルで同じ依存性をキャッシュします。ルーティンググループの
Depends(get_current_user)とエンドポイントのDepends(get_current_user)は同じ実行結果を共有します。Q DIチェーンで
get_dbが複数回宣言されると, 複数の接続が作られますか?A いいえ。
get_dbはリクエストごとに1回だけ実行され, すべての依存性が同じAsyncSessionを共有します。Q FreeユーザーはProにアップグレードするにはどうすればよいですか?
A Stripe決済の統合 (本レッスンの範囲外)が必要です。ここでは
User.subscriptionフィールドでシミュレーションしています。本番環境では決済コールバックで更新されます。Q リクエストフロー全体をテストするにはどうすればよいですか?
A
TestClient + dependency_overridesでget_dbを置き換え, 段階的にテストします:トークンなし→401, Freeトークン→200 (ただし制限あり), Proトークン→フル権限。Q フェーズ2完了後の次は何ですか?
A フェーズ3では高度な機能を紹介します。WebSocketリアルタイムプッシュ, Celery非同期タスク, Redisキャッシュ, ファイルアップロード, pytestテスト, OpenAPIカスタマイズです。
📖 まとめ
- フェーズ2は5つの主要モジュール (ミドルウェア, DI, データベース, CRUD, JWT)を統合し, 完全に機能するサービスを構築します
- 3層DIチェーン (get_db → get_current_user → require_subscription)が認証と認可を一元管理します
- ルーティンググループポリシー:「public」は認証不要, 「api_v1」はJWT保護, 「admin」はJWTとAdmin権限が必要
- CORSは最外層に登録し, すべてのレスポンス (エラーレスポンスを含む)にCORSヘッダーが含まれるようにします
- サブスクリプション階層はDIチェーンで実装:Freeは1,000件インポート制限, Proは100,000件, Enterpriseは1,000,000件
📝 練習問題
- 基本問題 (難易度 ⭐):フェーズ1のインメモリCRUD実装を非同期SQLAlchemy実装に置き換え, すべてのエンドポイントが
Depends(get_db)でデータベースセッションを注入するようにします。ヒント:Repositoryパターン +AsyncSession - 応用問題 (難易度 ⭐⭐):ルーティンググループを実装します。「public」グループ (/health, /auth/login)は認証不要, 「api/v1」下のすべてのビジネスエンドポイントはJWTで保護します。Swagger UIでログインと保護されたエンドポイントへのアクセスをテストします。ヒント:
APIRouter(dependencies=[Depends(get_current_user)]) - チャレンジ (難易度 ⭐⭐⭐):完全な3層DIチェーン + 一括インポートエンドポイントを実装します。Proユーザーは100,000件の価格データをインポートでき, Freeユーザーは1,000件を超えると403エラーを受け取ります。Swagger UIで登録→ログイン→インポートのプロセスを完全に実演します。ヒント:
require_subscription("free")+limits辞書
---|



