## Refactoring - Changed `ManualOutcomeSubmission.outcome` from `str` to `PlayOutcome` enum type - Removed custom validator (Pydantic handles enum validation automatically) - Added direct import of PlayOutcome (no circular dependency due to TYPE_CHECKING guard) - Updated tests to use enum values while maintaining backward compatibility Benefits: - Better type safety with IDE autocomplete - Cleaner code (removed 15 lines of validator boilerplate) - Backward compatible (Pydantic auto-converts strings to enum) - Access to helper methods (is_hit(), is_out(), etc.) Files modified: - app/models/game_models.py: Enum type + import - tests/unit/config/test_result_charts.py: Updated 7 tests + added compatibility test ## Documentation Created comprehensive CLAUDE.md files for all backend/app/ subdirectories to help future AI agents quickly understand and work with the code. Added 8,799 lines of documentation covering: - api/ (906 lines): FastAPI routes, health checks, auth patterns - config/ (906 lines): League configs, PlayOutcome enum, result charts - core/ (1,288 lines): GameEngine, StateManager, PlayResolver, dice system - data/ (937 lines): API clients (planned), caching layer - database/ (945 lines): Async sessions, operations, recovery - models/ (1,270 lines): Pydantic/SQLAlchemy models, polymorphic patterns - utils/ (959 lines): Logging, JWT auth, security - websocket/ (1,588 lines): Socket.io handlers, real-time events - tests/ (475 lines): Testing patterns and structure Each CLAUDE.md includes: - Purpose & architecture overview - Key components with detailed explanations - Patterns & conventions - Integration points - Common tasks (step-by-step guides) - Troubleshooting with solutions - Working code examples - Testing guidance Total changes: +9,294 lines / -24 lines Tests: All passing (62/62 model tests, 7/7 ManualOutcomeSubmission tests)
27 KiB
Data Layer - External API Integration & Caching
Overview
The data layer provides external data integration for the Paper Dynasty game engine. It handles communication with league REST APIs to fetch team rosters, player data, and submit completed game results.
Status: 🚧 NOT YET IMPLEMENTED - This directory is currently empty and awaits implementation.
Purpose:
- Fetch team/roster data from league-specific REST APIs (SBA and PD)
- Retrieve detailed player/card information
- Submit completed game results to league systems
- Cache frequently accessed data to reduce API calls
- Abstract API differences between leagues
Planned Architecture
app/data/
├── __init__.py # Public API exports
├── api_client.py # Base API client with HTTP operations
├── sba_client.py # SBA League API wrapper
├── pd_client.py # PD League API wrapper
└── cache.py # Optional caching layer (Redis or in-memory)
Integration Points
With Game Engine
# Game engine needs player data at game start
from app.data import get_api_client
# Get league-specific client
api_client = get_api_client(league_id="sba")
# Fetch roster for game
roster = await api_client.get_team_roster(team_id=123)
# Create lineup from roster data
lineup = create_lineup_from_roster(roster)
With Database Layer
# Store fetched data in database
from app.database.operations import DatabaseOperations
db_ops = DatabaseOperations()
# Fetch and persist player data
player_data = await api_client.get_player(player_id=456)
await db_ops.store_player_metadata(player_data)
With Player Models
# Parse API responses into typed player models
from app.models import SbaPlayer, PdPlayer
# SBA league
sba_data = await sba_client.get_player(player_id=123)
player = SbaPlayer.from_api_response(sba_data)
# PD league (with scouting data)
pd_data = await pd_client.get_player(player_id=456)
batting_data = await pd_client.get_batting_card(player_id=456)
pitching_data = await pd_client.get_pitching_card(player_id=456)
player = PdPlayer.from_api_response(pd_data, batting_data, pitching_data)
API Clients Design
Base API Client Pattern
Location: app/data/api_client.py (not yet created)
Purpose: Abstract HTTP client with common patterns for all league APIs.
Key Features:
- Async HTTP requests using
httpxoraiohttp - Automatic retry logic with exponential backoff
- Request/response logging
- Error handling and custom exceptions
- Authentication header management
- Rate limiting protection
Example Implementation:
import httpx
import logging
from typing import Dict, Any, Optional
from abc import ABC, abstractmethod
logger = logging.getLogger(f'{__name__}.BaseApiClient')
class ApiClientError(Exception):
"""Base exception for API client errors"""
pass
class ApiConnectionError(ApiClientError):
"""Raised when API connection fails"""
pass
class ApiAuthenticationError(ApiClientError):
"""Raised when authentication fails"""
pass
class ApiNotFoundError(ApiClientError):
"""Raised when resource not found (404)"""
pass
class BaseApiClient(ABC):
"""Abstract base class for league API clients
Provides common HTTP operations and error handling.
Subclasses implement league-specific endpoints.
"""
def __init__(self, base_url: str, api_key: Optional[str] = None):
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self._client: Optional[httpx.AsyncClient] = None
async def __aenter__(self):
"""Async context manager entry"""
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers=self._get_headers(),
timeout=30.0
)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit"""
if self._client:
await self._client.aclose()
def _get_headers(self) -> Dict[str, str]:
"""Get request headers including auth"""
headers = {
"Accept": "application/json",
"Content-Type": "application/json"
}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
return headers
async def _get(self, endpoint: str, params: Optional[Dict] = None) -> Dict[str, Any]:
"""Execute GET request with error handling
Args:
endpoint: API endpoint path (e.g., "/teams/123")
params: Optional query parameters
Returns:
Parsed JSON response
Raises:
ApiConnectionError: Network/connection error
ApiAuthenticationError: Auth failure (401, 403)
ApiNotFoundError: Resource not found (404)
ApiClientError: Other API errors
"""
if not self._client:
raise RuntimeError("Client not initialized. Use 'async with' context manager.")
url = endpoint if endpoint.startswith('/') else f'/{endpoint}'
try:
logger.debug(f"GET {url} with params {params}")
response = await self._client.get(url, params=params)
response.raise_for_status()
data = response.json()
logger.debug(f"Response: {data}")
return data
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
raise ApiNotFoundError(f"Resource not found: {url}") from e
elif e.response.status_code in (401, 403):
raise ApiAuthenticationError(f"Authentication failed: {e.response.text}") from e
else:
raise ApiClientError(f"HTTP {e.response.status_code}: {e.response.text}") from e
except httpx.RequestError as e:
logger.error(f"Connection error for {url}: {e}")
raise ApiConnectionError(f"Failed to connect to API: {e}") from e
async def _post(self, endpoint: str, data: Dict[str, Any]) -> Dict[str, Any]:
"""Execute POST request with error handling"""
if not self._client:
raise RuntimeError("Client not initialized. Use 'async with' context manager.")
url = endpoint if endpoint.startswith('/') else f'/{endpoint}'
try:
logger.debug(f"POST {url} with data {data}")
response = await self._client.post(url, json=data)
response.raise_for_status()
result = response.json()
logger.debug(f"Response: {result}")
return result
except httpx.HTTPStatusError as e:
if e.response.status_code in (401, 403):
raise ApiAuthenticationError(f"Authentication failed: {e.response.text}") from e
else:
raise ApiClientError(f"HTTP {e.response.status_code}: {e.response.text}") from e
except httpx.RequestError as e:
logger.error(f"Connection error for {url}: {e}")
raise ApiConnectionError(f"Failed to connect to API: {e}") from e
# Abstract methods for subclasses to implement
@abstractmethod
async def get_team(self, team_id: int) -> Dict[str, Any]:
"""Fetch team details"""
pass
@abstractmethod
async def get_team_roster(self, team_id: int) -> Dict[str, Any]:
"""Fetch team roster with all cards/players"""
pass
@abstractmethod
async def get_player(self, player_id: int) -> Dict[str, Any]:
"""Fetch player details"""
pass
@abstractmethod
async def submit_game_result(self, game_data: Dict[str, Any]) -> Dict[str, Any]:
"""Submit completed game results to league system"""
pass
SBA League API Client
Location: app/data/sba_client.py (not yet created)
API Base URL: https://api.sba.manticorum.com (from config)
Key Endpoints:
GET /teams/:id- Team detailsGET /teams/:id/roster- Team rosterGET /players/:id- Player details (simple model)POST /games/submit- Submit completed game
Example Implementation:
from typing import Dict, Any
from .api_client import BaseApiClient
class SbaApiClient(BaseApiClient):
"""SBA League API client
Handles communication with SBA REST API for team/player data.
Uses simple player model (id, name, image, positions).
"""
async def get_team(self, team_id: int) -> Dict[str, Any]:
"""Fetch SBA team details
Args:
team_id: SBA team ID
Returns:
Team data including name, manager, season
"""
return await self._get(f"/teams/{team_id}")
async def get_team_roster(self, team_id: int) -> Dict[str, Any]:
"""Fetch SBA team roster
Args:
team_id: SBA team ID
Returns:
Roster data with list of players
"""
return await self._get(f"/teams/{team_id}/roster")
async def get_player(self, player_id: int) -> Dict[str, Any]:
"""Fetch SBA player details
Args:
player_id: SBA player ID
Returns:
Player data (id, name, image, positions, wara, team)
"""
return await self._get(f"/players/{player_id}")
async def submit_game_result(self, game_data: Dict[str, Any]) -> Dict[str, Any]:
"""Submit completed SBA game to league system
Args:
game_data: Complete game data including plays, stats, final score
Returns:
Confirmation response from API
"""
return await self._post("/games/submit", game_data)
PD League API Client
Location: app/data/pd_client.py (not yet created)
API Base URL: https://api.pd.manticorum.com (from config)
Key Endpoints:
GET /api/v2/teams/:id- Team detailsGET /api/v2/teams/:id/roster- Team rosterGET /api/v2/players/:id- Player/card detailsGET /api/v2/battingcardratings/player/:id- Batting scouting dataGET /api/v2/pitchingcardratings/player/:id- Pitching scouting dataGET /api/v2/cardsets/:id- Cardset detailsPOST /api/v2/games/submit- Submit completed game
Example Implementation:
from typing import Dict, Any, Optional
from .api_client import BaseApiClient
class PdApiClient(BaseApiClient):
"""PD League API client
Handles communication with PD REST API for team/player/card data.
Supports detailed scouting data with batting/pitching ratings.
"""
async def get_team(self, team_id: int) -> Dict[str, Any]:
"""Fetch PD team details"""
return await self._get(f"/api/v2/teams/{team_id}")
async def get_team_roster(self, team_id: int) -> Dict[str, Any]:
"""Fetch PD team roster"""
return await self._get(f"/api/v2/teams/{team_id}/roster")
async def get_player(self, player_id: int) -> Dict[str, Any]:
"""Fetch PD player/card details
Returns basic card info without scouting data.
Use get_batting_card() and get_pitching_card() for detailed ratings.
"""
return await self._get(f"/api/v2/players/{player_id}")
async def get_batting_card(self, player_id: int) -> Optional[Dict[str, Any]]:
"""Fetch PD batting card scouting data
Args:
player_id: PD card ID
Returns:
Batting ratings (steal, bunting, hit ratings vs LHP/RHP) or None
"""
try:
return await self._get(f"/api/v2/battingcardratings/player/{player_id}")
except ApiNotFoundError:
# Not all cards have batting ratings (pitcher-only cards)
return None
async def get_pitching_card(self, player_id: int) -> Optional[Dict[str, Any]]:
"""Fetch PD pitching card scouting data
Args:
player_id: PD card ID
Returns:
Pitching ratings (balk, hold, ratings vs LHB/RHB) or None
"""
try:
return await self._get(f"/api/v2/pitchingcardratings/player/{player_id}")
except ApiNotFoundError:
# Not all cards have pitching ratings (position players)
return None
async def get_cardset(self, cardset_id: int) -> Dict[str, Any]:
"""Fetch PD cardset details
Args:
cardset_id: Cardset ID
Returns:
Cardset info (name, description, ranked_legal)
"""
return await self._get(f"/api/v2/cardsets/{cardset_id}")
async def submit_game_result(self, game_data: Dict[str, Any]) -> Dict[str, Any]:
"""Submit completed PD game to league system"""
return await self._post("/api/v2/games/submit", game_data)
Caching Strategy
Location: app/data/cache.py (not yet created)
Purpose: Reduce API calls by caching frequently accessed data.
Caching Targets:
- Player/card data (rarely changes)
- Team rosters (changes during roster management, not during games)
- Cardset information (static)
- NOT game state (always use database)
Cache Backend Options:
Option 1: Redis (Recommended for Production)
import redis.asyncio as redis
import json
from typing import Optional, Any
class RedisCache:
"""Redis-based cache for API data
Provides async get/set operations with TTL support.
"""
def __init__(self, redis_url: str = "redis://localhost:6379"):
self.redis = redis.from_url(redis_url, decode_responses=True)
async def get(self, key: str) -> Optional[Any]:
"""Get cached value
Args:
key: Cache key
Returns:
Cached value or None if not found/expired
"""
value = await self.redis.get(key)
if value:
return json.loads(value)
return None
async def set(self, key: str, value: Any, ttl: int = 3600) -> None:
"""Set cache value with TTL
Args:
key: Cache key
value: Value to cache (must be JSON-serializable)
ttl: Time to live in seconds (default 1 hour)
"""
await self.redis.set(key, json.dumps(value), ex=ttl)
async def delete(self, key: str) -> None:
"""Delete cached value"""
await self.redis.delete(key)
async def clear_pattern(self, pattern: str) -> None:
"""Delete all keys matching pattern
Args:
pattern: Redis key pattern (e.g., "player:*")
"""
keys = await self.redis.keys(pattern)
if keys:
await self.redis.delete(*keys)
Option 2: In-Memory Cache (Development/Testing)
import asyncio
import pendulum
from typing import Dict, Any, Optional, Tuple
from dataclasses import dataclass
@dataclass
class CacheEntry:
"""Cache entry with expiration"""
value: Any
expires_at: pendulum.DateTime
class MemoryCache:
"""In-memory cache for API data
Simple dict-based cache with TTL support.
Suitable for development/testing, not production.
"""
def __init__(self):
self._cache: Dict[str, CacheEntry] = {}
self._lock = asyncio.Lock()
async def get(self, key: str) -> Optional[Any]:
"""Get cached value if not expired"""
async with self._lock:
if key in self._cache:
entry = self._cache[key]
if pendulum.now('UTC') < entry.expires_at:
return entry.value
else:
# Expired, remove it
del self._cache[key]
return None
async def set(self, key: str, value: Any, ttl: int = 3600) -> None:
"""Set cache value with TTL"""
expires_at = pendulum.now('UTC').add(seconds=ttl)
async with self._lock:
self._cache[key] = CacheEntry(value=value, expires_at=expires_at)
async def delete(self, key: str) -> None:
"""Delete cached value"""
async with self._lock:
self._cache.pop(key, None)
async def clear(self) -> None:
"""Clear entire cache"""
async with self._lock:
self._cache.clear()
Cached API Client Pattern
from typing import Dict, Any, Optional
from .api_client import BaseApiClient
from .cache import RedisCache
class CachedApiClient:
"""Wrapper that adds caching to API client
Caches player/team data to reduce API calls.
"""
def __init__(self, api_client: BaseApiClient, cache: RedisCache):
self.api_client = api_client
self.cache = cache
async def get_player(self, player_id: int, use_cache: bool = True) -> Dict[str, Any]:
"""Get player data with optional caching
Args:
player_id: Player ID
use_cache: Whether to use cached data (default True)
Returns:
Player data from cache or API
"""
cache_key = f"player:{player_id}"
# Try cache first
if use_cache:
cached = await self.cache.get(cache_key)
if cached:
return cached
# Cache miss, fetch from API
data = await self.api_client.get_player(player_id)
# Store in cache (1 hour TTL for player data)
await self.cache.set(cache_key, data, ttl=3600)
return data
async def invalidate_player(self, player_id: int) -> None:
"""Invalidate cached player data
Call this when player data changes (roster updates).
"""
cache_key = f"player:{player_id}"
await self.cache.delete(cache_key)
Configuration Integration
API clients should read from league configs:
Location: app/config/league_configs.py (already exists)
from app.config import get_league_config
# Get league-specific API base URL
config = get_league_config("sba")
api_url = config.get_api_base_url() # "https://api.sba.manticorum.com"
# Or for PD
config = get_league_config("pd")
api_url = config.get_api_base_url() # "https://api.pd.manticorum.com"
Environment Variables (.env):
# SBA League API
SBA_API_URL=https://api.sba.manticorum.com
SBA_API_KEY=your-api-key-here
# PD League API
PD_API_URL=https://api.pd.manticorum.com
PD_API_KEY=your-api-key-here
# Cache (optional)
REDIS_URL=redis://localhost:6379
CACHE_ENABLED=true
CACHE_DEFAULT_TTL=3600
Usage Examples
Fetching Team Roster
from app.data import get_api_client
from app.models import SbaPlayer, PdPlayer
async def load_team_roster(league_id: str, team_id: int):
"""Load team roster from API"""
# Get league-specific client
api_client = get_api_client(league_id)
async with api_client:
# Fetch roster data
roster_data = await api_client.get_team_roster(team_id)
# Parse into player models
players = []
for player_data in roster_data['players']:
if league_id == "sba":
player = SbaPlayer.from_api_response(player_data)
else: # PD
# Fetch detailed scouting data
batting = await api_client.get_batting_card(player_data['id'])
pitching = await api_client.get_pitching_card(player_data['id'])
player = PdPlayer.from_api_response(player_data, batting, pitching)
players.append(player)
return players
Submitting Game Results
from app.data import get_api_client
async def submit_completed_game(game_id: str, league_id: str):
"""Submit completed game to league system"""
# Fetch game data from database
game_data = await db_ops.export_game_data(game_id)
# Get league API client
api_client = get_api_client(league_id)
async with api_client:
# Submit to league system
result = await api_client.submit_game_result(game_data)
return result
Using Cache
from app.data import get_cached_client
async def get_player_with_cache(league_id: str, player_id: int):
"""Get player data with caching"""
# Get cached API client
client = get_cached_client(league_id)
async with client:
# First call fetches from API and caches
player_data = await client.get_player(player_id)
# Second call returns from cache (fast)
player_data = await client.get_player(player_id)
return player_data
Error Handling
All API client methods raise specific exceptions:
from app.data import get_api_client, ApiClientError, ApiNotFoundError, ApiConnectionError
async def safe_api_call():
"""Example of proper error handling"""
api_client = get_api_client("sba")
try:
async with api_client:
player = await api_client.get_player(123)
except ApiNotFoundError:
# Player doesn't exist
logger.warning(f"Player 123 not found")
return None
except ApiConnectionError:
# Network error, retry later
logger.error("API connection failed, will retry")
raise
except ApiClientError as e:
# Other API error
logger.error(f"API error: {e}")
raise
return player
Testing Patterns
Mocking API Clients
from unittest.mock import AsyncMock, Mock
import pytest
@pytest.fixture
def mock_sba_client():
"""Mock SBA API client for testing"""
client = Mock()
# Mock get_player method
client.get_player = AsyncMock(return_value={
"id": 123,
"name": "Mike Trout",
"image": "https://example.com/trout.jpg",
"pos_1": "CF",
"wara": 8.5
})
# Mock context manager
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=None)
return client
async def test_game_with_mock_api(mock_sba_client):
"""Test game engine with mocked API"""
# Use mock instead of real API
async with mock_sba_client:
player_data = await mock_sba_client.get_player(123)
assert player_data['name'] == "Mike Trout"
Testing Cache Behavior
async def test_cache_hit():
"""Test cache returns cached value"""
cache = MemoryCache()
# Set value
await cache.set("test:key", {"data": "value"}, ttl=60)
# Get value (should be cached)
result = await cache.get("test:key")
assert result == {"data": "value"}
async def test_cache_expiration():
"""Test cache expires after TTL"""
cache = MemoryCache()
# Set value with 1 second TTL
await cache.set("test:key", {"data": "value"}, ttl=1)
# Wait for expiration
await asyncio.sleep(2)
# Should be expired
result = await cache.get("test:key")
assert result is None
Common Tasks
Adding a New API Endpoint
- Add method to appropriate client:
# In sba_client.py or pd_client.py
async def get_new_endpoint(self, param: int) -> Dict[str, Any]:
"""Fetch new endpoint data"""
return await self._get(f"/new/endpoint/{param}")
- Update tests:
async def test_new_endpoint(sba_client):
"""Test new endpoint"""
result = await sba_client.get_new_endpoint(123)
assert result is not None
- Document in this file: Add example to usage section
Modifying Cache Behavior
- Adjust TTL for specific data type:
# Player data: 1 hour (changes rarely)
await cache.set(f"player:{player_id}", data, ttl=3600)
# Team roster: 10 minutes (may change during roster management)
await cache.set(f"roster:{team_id}", data, ttl=600)
# Cardset data: 24 hours (static)
await cache.set(f"cardset:{cardset_id}", data, ttl=86400)
- Add cache invalidation triggers:
async def on_roster_update(team_id: int):
"""Invalidate roster cache when roster changes"""
await cache.delete(f"roster:{team_id}")
Switching Cache Backends
Development (in-memory):
from app.data.cache import MemoryCache
cache = MemoryCache()
Production (Redis):
from app.data.cache import RedisCache
from app.config import settings
cache = RedisCache(settings.redis_url)
Troubleshooting
API Connection Issues
Symptom: ApiConnectionError: Failed to connect to API
Checks:
- Verify API URL in config:
config.get_api_base_url() - Check network connectivity:
curl https://api.sba.manticorum.com/health - Verify API key is set:
echo $SBA_API_KEY - Check firewall/network access
Authentication Failures
Symptom: ApiAuthenticationError: Authentication failed
Checks:
- Verify API key is correct
- Check API key format (Bearer token vs other)
- Confirm API key has required permissions
- Check API key expiration
Cache Not Working
Symptom: Every request hits API instead of cache
Checks:
- Verify cache is initialized:
cache is not None - Check Redis is running:
redis-cli ping(should return "PONG") - Verify cache keys are consistent
- Check TTL isn't too short
Rate Limiting
Symptom: API returns 429 (Too Many Requests)
Solutions:
- Implement exponential backoff retry logic
- Reduce API call frequency
- Increase cache TTL to reduce calls
- Add rate limiting protection in client
Performance Targets
- API Response Time: < 500ms for typical requests
- Cache Hit Ratio: > 80% for player/team data
- Cache Lookup Time: < 10ms (Redis), < 1ms (memory)
- Concurrent Requests: Support 10+ simultaneous API calls
Security Considerations
- API Keys: Store in environment variables, never commit to git
- HTTPS Only: All API communication over encrypted connections
- Input Validation: Validate all API responses with Pydantic models
- Error Messages: Don't expose API keys or internal details in logs
- Rate Limiting: Respect API rate limits, implement backoff
Dependencies
Required:
httpx>=0.25.0 # Async HTTP client
pydantic>=2.10.0 # Response validation (already installed)
Optional:
redis>=5.0.0 # Redis cache backend
aiohttp>=3.9.0 # Alternative HTTP client
Implementation Priority
Phase 1 (Week 7-8):
- ✅ Create base API client with error handling
- ✅ Implement SBA client for simple player data
- ✅ Add basic integration tests
- ⏳ Connect to game engine for roster loading
Phase 2 (Week 9-10):
- ⏳ Implement PD client with scouting data
- ⏳ Add in-memory cache for development
- ⏳ Add result submission endpoints
Phase 3 (Post-MVP):
- ⏳ Add Redis cache for production
- ⏳ Implement advanced retry logic
- ⏳ Add request/response logging
- ⏳ Performance optimization
References
- PRD API Section:
../../prd-web-scorecard-1.1.md(lines 87-90, 1119-1126) - Player Models:
../models/player_models.py- SbaPlayer, PdPlayer classes - League Configs:
../config/league_configs.py- API URLs and settings - Backend Architecture:
../CLAUDE.md- Overall backend structure
Status: 🚧 AWAITING IMPLEMENTATION Current Phase: Phase 2 - Week 7 (Runner Advancement & Play Resolution) Next Steps: Implement base API client and SBA client for roster loading
Note: This directory will be populated during Phase 2 integration work when game engine needs to fetch real player data from league APIs.