- 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>
389 lines
12 KiB
Python
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)]
|