mantimon-tcg/backend/app/api/decks.py
Cal Corum 3ec670753b Fix security and validation issues from code review
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>
2026-01-28 14:16:07 -06:00

289 lines
8.3 KiB
Python

"""Decks API router for Mantimon TCG.
This module provides REST endpoints for deck management including
creating, reading, updating, and deleting decks, as well as validation.
The backend is stateless - deck rules come from the request via DeckConfig.
Frontend provides the rules appropriate for the game mode (campaign, freeplay, custom).
Endpoints:
GET /decks - List all user's decks
POST /decks - Create a new deck
GET /decks/{deck_id} - Get a specific deck
PUT /decks/{deck_id} - Update a deck
DELETE /decks/{deck_id} - Delete a deck
POST /decks/validate - Validate a deck without saving
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from app.api.deps import CardServiceDep, CollectionServiceDep, CurrentUser, DeckServiceDep
from app.schemas.deck import (
DeckCreateRequest,
DeckListResponse,
DeckResponse,
DeckUpdateRequest,
DeckValidateRequest,
DeckValidationResponse,
)
from app.services.deck_service import DeckLimitExceededError, DeckNotFoundError
from app.services.deck_validator import validate_deck
router = APIRouter(prefix="/decks", tags=["decks"])
@router.get("", response_model=DeckListResponse)
async def list_decks(
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckListResponse:
"""Get all decks for the authenticated user.
Returns a list of all user's decks along with count and limit information.
Premium users have unlimited decks (deck_limit=None).
Returns:
DeckListResponse with decks, count, and limit.
"""
decks = await deck_service.get_user_decks(current_user.id)
# Convert DeckEntry DTOs to DeckResponse
deck_responses = [
DeckResponse(
id=d.id,
name=d.name,
description=d.description,
cards=d.cards,
energy_cards=d.energy_cards,
is_valid=d.is_valid,
validation_errors=d.validation_errors,
is_starter=d.is_starter,
starter_type=d.starter_type,
created_at=d.created_at,
updated_at=d.updated_at,
)
for d in decks
]
# Premium users have unlimited decks
deck_limit = None if current_user.has_active_premium else current_user.max_decks
return DeckListResponse(
decks=deck_responses,
deck_count=len(decks),
deck_limit=deck_limit,
)
@router.post("", response_model=DeckResponse, status_code=status.HTTP_201_CREATED)
async def create_deck(
deck_in: DeckCreateRequest,
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckResponse:
"""Create a new deck.
Validates the deck composition and stores it. Invalid decks CAN be saved
(with validation errors) to support work-in-progress deck building.
Args:
deck_in: Deck creation request with name, cards, energy, and config.
Returns:
The created deck with validation state.
Raises:
400: If user has reached their deck limit.
"""
try:
deck = await deck_service.create_deck(
user_id=current_user.id,
name=deck_in.name,
cards=deck_in.cards,
energy_cards=deck_in.energy_cards,
deck_config=deck_in.deck_config,
max_decks=current_user.max_decks,
validate_ownership=deck_in.validate_ownership,
description=deck_in.description,
)
except DeckLimitExceededError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from None
return DeckResponse(
id=deck.id,
name=deck.name,
description=deck.description,
cards=deck.cards,
energy_cards=deck.energy_cards,
is_valid=deck.is_valid,
validation_errors=deck.validation_errors,
is_starter=deck.is_starter,
starter_type=deck.starter_type,
created_at=deck.created_at,
updated_at=deck.updated_at,
)
@router.get("/{deck_id}", response_model=DeckResponse)
async def get_deck(
deck_id: UUID,
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckResponse:
"""Get a specific deck by ID.
Only returns decks owned by the authenticated user.
Args:
deck_id: The deck's UUID.
Returns:
The deck details.
Raises:
404: If deck not found or not owned by user.
"""
try:
deck = await deck_service.get_deck(current_user.id, deck_id)
except DeckNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
) from None
return DeckResponse(
id=deck.id,
name=deck.name,
description=deck.description,
cards=deck.cards,
energy_cards=deck.energy_cards,
is_valid=deck.is_valid,
validation_errors=deck.validation_errors,
is_starter=deck.is_starter,
starter_type=deck.starter_type,
created_at=deck.created_at,
updated_at=deck.updated_at,
)
@router.put("/{deck_id}", response_model=DeckResponse)
async def update_deck(
deck_id: UUID,
deck_in: DeckUpdateRequest,
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> DeckResponse:
"""Update an existing deck.
Only provided fields are updated. If cards or energy_cards change,
the deck is re-validated with the provided deck_config.
Args:
deck_id: The deck's UUID.
deck_in: Update request with optional fields.
Returns:
The updated deck.
Raises:
404: If deck not found or not owned by user.
"""
try:
deck = await deck_service.update_deck(
user_id=current_user.id,
deck_id=deck_id,
deck_config=deck_in.deck_config,
name=deck_in.name,
cards=deck_in.cards,
energy_cards=deck_in.energy_cards,
validate_ownership=deck_in.validate_ownership,
description=deck_in.description,
)
except DeckNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
) from None
return DeckResponse(
id=deck.id,
name=deck.name,
description=deck.description,
cards=deck.cards,
energy_cards=deck.energy_cards,
is_valid=deck.is_valid,
validation_errors=deck.validation_errors,
is_starter=deck.is_starter,
starter_type=deck.starter_type,
created_at=deck.created_at,
updated_at=deck.updated_at,
)
@router.delete("/{deck_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_deck(
deck_id: UUID,
current_user: CurrentUser,
deck_service: DeckServiceDep,
) -> None:
"""Delete a deck.
Only deletes decks owned by the authenticated user.
Args:
deck_id: The deck's UUID.
Raises:
404: If deck not found or not owned by user.
"""
try:
await deck_service.delete_deck(current_user.id, deck_id)
except DeckNotFoundError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Deck not found",
) from None
@router.post("/validate", response_model=DeckValidationResponse)
async def validate_deck_endpoint(
request: DeckValidateRequest,
current_user: CurrentUser,
card_service: CardServiceDep,
collection_service: CollectionServiceDep,
) -> DeckValidationResponse:
"""Validate a deck composition without saving.
Useful for checking if a deck is valid before creating it.
Uses the provided deck_config rules from the frontend.
Args:
request: Validation request with cards, energy, and config.
Returns:
Validation result with is_valid and any errors.
"""
# Get owned cards if validating ownership (campaign mode)
owned_cards: dict[str, int] | None = None
if request.validate_ownership:
owned_cards = await collection_service.get_owned_cards_dict(current_user.id)
# Use the pure validation function with config from request
result = validate_deck(
cards=request.cards,
energy_cards=request.energy_cards,
deck_config=request.deck_config,
card_lookup=card_service.get_card,
owned_cards=owned_cards,
)
return DeckValidationResponse(
is_valid=result.is_valid,
errors=result.errors,
)