mantimon-tcg/backend/app/api/deps.py
Cal Corum 7fcb86ff51 Implement UserRepository pattern with dependency injection
- Add UserRepository and LinkedAccountRepository protocols to protocols.py
- Add UserEntry and LinkedAccountEntry DTOs for service layer decoupling
- Implement PostgresUserRepository and PostgresLinkedAccountRepository
- Refactor UserService to use constructor-injected repositories
- Add get_user_service factory and UserServiceDep to API deps
- Update auth.py and users.py endpoints to use UserServiceDep
- Rewrite tests to use FastAPI dependency overrides (no monkey patching)

This follows the established repository pattern used by DeckService and
CollectionService, enabling future offline fork support.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 07:30:16 -06:00

389 lines
12 KiB
Python

"""FastAPI dependencies for Mantimon TCG API.
This module provides dependency injection functions for authentication,
database access, and service layer access in API endpoints.
Usage:
from app.api.deps import CurrentUser, DbSession, DeckServiceDep
@router.get("/decks")
async def get_decks(
user: CurrentUser,
db: DbSession,
deck_service: DeckServiceDep,
):
return await deck_service.get_user_decks(user.id)
Dependencies:
- get_db: Async database session
- get_current_user: Authenticated user from JWT (required)
- get_optional_user: Authenticated user or None
- get_current_premium_user: User with active premium
- verify_admin_token: Admin API key validation
- get_deck_service: DeckService with repositories
- get_collection_service: CollectionService with repository
"""
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db import get_session
from app.db.models import User
from app.repositories.postgres.collection import PostgresCollectionRepository
from app.repositories.postgres.deck import PostgresDeckRepository
from app.repositories.postgres.linked_account import PostgresLinkedAccountRepository
from app.repositories.postgres.user import PostgresUserRepository
from app.services.card_service import CardService, get_card_service
from app.services.collection_service import CollectionService
from app.services.deck_service import DeckService
from app.services.game_service import GameService, game_service
from app.services.game_state_manager import GameStateManager, game_state_manager
from app.services.jwt_service import verify_access_token
from app.services.user_service import UserService
# OAuth2 scheme for extracting Bearer token from Authorization header
# tokenUrl is for OpenAPI docs - points to where tokens are obtained
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="/api/auth/token", # For OpenAPI docs
auto_error=True, # Raise 401 if no token
)
oauth2_scheme_optional = OAuth2PasswordBearer(
tokenUrl="/api/auth/token",
auto_error=False, # Return None if no token
)
# Admin API key header for admin-only endpoints
admin_api_key_header = APIKeyHeader(
name="X-Admin-API-Key",
auto_error=True,
description="Admin API key for privileged operations",
)
async def verify_admin_token(
api_key: Annotated[str, Depends(admin_api_key_header)],
) -> None:
"""Verify the admin API key.
Validates the X-Admin-API-Key header against the configured admin key.
Admin key must be configured via ADMIN_API_KEY environment variable.
Args:
api_key: API key from X-Admin-API-Key header.
Raises:
HTTPException: 403 if admin key is not configured or doesn't match.
Example:
@router.post("/admin/grant-cards")
async def grant_cards(
_: None = Depends(verify_admin_token),
):
# Only accessible with valid admin key
...
"""
configured_key = settings.admin_api_key
if configured_key is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin API key not configured",
)
if api_key != configured_key.get_secret_value():
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid admin API key",
)
async def get_db() -> AsyncSession:
"""Get async database session.
Yields:
AsyncSession for database operations.
Example:
@router.get("/items")
async def get_items(db: AsyncSession = Depends(get_db)):
...
"""
async with get_session() as session:
yield session
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
"""Get the current authenticated user from JWT token.
Validates the access token and fetches the user from database.
Args:
token: JWT access token from Authorization header.
db: Database session.
Returns:
The authenticated User.
Raises:
HTTPException: 401 if token is invalid or user not found.
Example:
@router.get("/me")
async def get_me(user: User = Depends(get_current_user)):
return {"email": user.email}
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify token and extract user ID
user_id = verify_access_token(token)
if user_id is None:
raise credentials_exception
# Fetch user from database (direct query for auth - not business logic)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
async def get_optional_user(
token: Annotated[str | None, Depends(oauth2_scheme_optional)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User | None:
"""Get the current user if authenticated, or None.
Useful for endpoints that work both with and without authentication,
but may provide additional features for authenticated users.
Args:
token: JWT access token or None.
db: Database session.
Returns:
The authenticated User, or None if not authenticated.
Example:
@router.get("/cards")
async def get_cards(user: User | None = Depends(get_optional_user)):
if user:
# Show user's collection
else:
# Show public cards
"""
if token is None:
return None
user_id = verify_access_token(token)
if user_id is None:
return None
# Direct query for auth - not business logic
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()
async def get_current_premium_user(
user: Annotated[User, Depends(get_current_user)],
) -> User:
"""Get the current user and verify they have premium.
Args:
user: The authenticated user.
Returns:
The authenticated User with active premium.
Raises:
HTTPException: 403 if user doesn't have active premium.
Example:
@router.post("/decks")
async def create_unlimited_decks(
user: User = Depends(get_current_premium_user)
):
# Only premium users can have unlimited decks
...
"""
if not user.has_active_premium:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Premium subscription required",
)
return user
# =============================================================================
# Service Dependencies
# =============================================================================
def get_collection_service(
db: Annotated[AsyncSession, Depends(get_db)],
) -> CollectionService:
"""Get CollectionService with PostgreSQL repository.
Creates a CollectionService instance with the current database session.
Args:
db: Database session from request.
Returns:
CollectionService configured for PostgreSQL.
Example:
@router.get("/collections/me")
async def get_collection(
service: CollectionService = Depends(get_collection_service),
):
...
"""
repo = PostgresCollectionRepository(db)
card_service = get_card_service()
return CollectionService(repo, card_service)
def get_deck_service(
db: Annotated[AsyncSession, Depends(get_db)],
) -> DeckService:
"""Get DeckService with PostgreSQL repositories.
Creates a DeckService instance with deck and collection repositories.
Args:
db: Database session from request.
Returns:
DeckService configured for PostgreSQL.
Example:
@router.post("/decks")
async def create_deck(
service: DeckService = Depends(get_deck_service),
):
...
"""
deck_repo = PostgresDeckRepository(db)
collection_repo = PostgresCollectionRepository(db)
card_service = get_card_service()
return DeckService(deck_repo, card_service, collection_repo)
def get_card_service_dep() -> CardService:
"""Get the CardService singleton.
CardService is a singleton that loads card definitions from JSON files.
This dependency provides consistent access for endpoints that need
direct card lookup functionality.
Returns:
The CardService singleton instance.
Example:
@router.post("/validate")
async def validate(
card_service: CardService = Depends(get_card_service_dep),
):
card = card_service.get_card("a1-001-bulbasaur")
"""
return get_card_service()
def get_game_service_dep() -> GameService:
"""Get the GameService singleton.
GameService orchestrates game lifecycle operations between
WebSocket/REST layers and the core GameEngine.
Returns:
The GameService singleton instance.
Example:
@router.post("/games")
async def create_game(
game_service: GameService = Depends(get_game_service_dep),
):
result = await game_service.create_game(...)
"""
return game_service
def get_game_state_manager_dep() -> GameStateManager:
"""Get the GameStateManager singleton.
GameStateManager handles game state persistence across Redis and Postgres.
Returns:
The GameStateManager singleton instance.
Example:
@router.get("/games/me/active")
async def list_active_games(
state_manager: GameStateManager = Depends(get_game_state_manager_dep),
):
games = await state_manager.get_player_active_games(user_id)
"""
return game_state_manager
def get_user_service(
db: Annotated[AsyncSession, Depends(get_db)],
) -> UserService:
"""Get UserService with PostgreSQL repositories.
Creates a UserService instance with user and linked account repositories.
Args:
db: Database session from request.
Returns:
UserService configured for PostgreSQL.
Example:
@router.post("/auth/google/callback")
async def google_callback(
user_service: UserService = Depends(get_user_service),
):
user, created = await user_service.get_or_create_from_oauth(oauth_info)
"""
user_repo = PostgresUserRepository(db)
linked_repo = PostgresLinkedAccountRepository(db)
return UserService(user_repo, linked_repo)
# =============================================================================
# Type Aliases for Cleaner Endpoint Signatures
# =============================================================================
# User dependencies
CurrentUser = Annotated[User, Depends(get_current_user)]
OptionalUser = Annotated[User | None, Depends(get_optional_user)]
PremiumUser = Annotated[User, Depends(get_current_premium_user)]
# Database session
DbSession = Annotated[AsyncSession, Depends(get_db)]
# Service dependencies
DeckServiceDep = Annotated[DeckService, Depends(get_deck_service)]
CollectionServiceDep = Annotated[CollectionService, Depends(get_collection_service)]
CardServiceDep = Annotated[CardService, Depends(get_card_service_dep)]
GameServiceDep = Annotated[GameService, Depends(get_game_service_dep)]
GameStateManagerDep = Annotated[GameStateManager, Depends(get_game_state_manager_dep)]
UserServiceDep = Annotated[UserService, Depends(get_user_service)]
# Admin authentication
AdminAuth = Annotated[None, Depends(verify_admin_token)]