Critical fixes: - Add admin API key authentication for admin endpoints - Add race condition protection via unique partial index for starter decks - Make starter deck selection atomic with combined method Moderate fixes: - Fix DI pattern violation in validate_deck_endpoint - Add card ID format validation (regex pattern) - Add card quantity validation (1-99 range) - Fix exception chaining with from None (B904) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
316 lines
9.2 KiB
Python
316 lines
9.2 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.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.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.jwt_service import verify_access_token
|
|
from app.services.user_service import user_service
|
|
|
|
# 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
|
|
user = await user_service.get_by_id(db, user_id)
|
|
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
|
|
|
|
return await user_service.get_by_id(db, user_id)
|
|
|
|
|
|
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()
|
|
|
|
|
|
# =============================================================================
|
|
# 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)]
|
|
|
|
# Admin authentication
|
|
AdminAuth = Annotated[None, Depends(verify_admin_token)]
|