## 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)
938 lines
27 KiB
Markdown
938 lines
27 KiB
Markdown
# 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
|
|
```python
|
|
# 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
|
|
```python
|
|
# 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
|
|
```python
|
|
# 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 `httpx` or `aiohttp`
|
|
- Automatic retry logic with exponential backoff
|
|
- Request/response logging
|
|
- Error handling and custom exceptions
|
|
- Authentication header management
|
|
- Rate limiting protection
|
|
|
|
**Example Implementation**:
|
|
```python
|
|
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 details
|
|
- `GET /teams/:id/roster` - Team roster
|
|
- `GET /players/:id` - Player details (simple model)
|
|
- `POST /games/submit` - Submit completed game
|
|
|
|
**Example Implementation**:
|
|
```python
|
|
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 details
|
|
- `GET /api/v2/teams/:id/roster` - Team roster
|
|
- `GET /api/v2/players/:id` - Player/card details
|
|
- `GET /api/v2/battingcardratings/player/:id` - Batting scouting data
|
|
- `GET /api/v2/pitchingcardratings/player/:id` - Pitching scouting data
|
|
- `GET /api/v2/cardsets/:id` - Cardset details
|
|
- `POST /api/v2/games/submit` - Submit completed game
|
|
|
|
**Example Implementation**:
|
|
```python
|
|
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)
|
|
```python
|
|
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)
|
|
```python
|
|
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
|
|
```python
|
|
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)
|
|
|
|
```python
|
|
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`):
|
|
```bash
|
|
# 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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
```python
|
|
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
|
|
|
|
1. **Add method to appropriate client**:
|
|
```python
|
|
# 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}")
|
|
```
|
|
|
|
2. **Update tests**:
|
|
```python
|
|
async def test_new_endpoint(sba_client):
|
|
"""Test new endpoint"""
|
|
result = await sba_client.get_new_endpoint(123)
|
|
assert result is not None
|
|
```
|
|
|
|
3. **Document in this file**: Add example to usage section
|
|
|
|
### Modifying Cache Behavior
|
|
|
|
1. **Adjust TTL for specific data type**:
|
|
```python
|
|
# 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)
|
|
```
|
|
|
|
2. **Add cache invalidation triggers**:
|
|
```python
|
|
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):
|
|
```python
|
|
from app.data.cache import MemoryCache
|
|
|
|
cache = MemoryCache()
|
|
```
|
|
|
|
Production (Redis):
|
|
```python
|
|
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**:
|
|
1. Verify API URL in config: `config.get_api_base_url()`
|
|
2. Check network connectivity: `curl https://api.sba.manticorum.com/health`
|
|
3. Verify API key is set: `echo $SBA_API_KEY`
|
|
4. Check firewall/network access
|
|
|
|
### Authentication Failures
|
|
|
|
**Symptom**: `ApiAuthenticationError: Authentication failed`
|
|
|
|
**Checks**:
|
|
1. Verify API key is correct
|
|
2. Check API key format (Bearer token vs other)
|
|
3. Confirm API key has required permissions
|
|
4. Check API key expiration
|
|
|
|
### Cache Not Working
|
|
|
|
**Symptom**: Every request hits API instead of cache
|
|
|
|
**Checks**:
|
|
1. Verify cache is initialized: `cache is not None`
|
|
2. Check Redis is running: `redis-cli ping` (should return "PONG")
|
|
3. Verify cache keys are consistent
|
|
4. Check TTL isn't too short
|
|
|
|
### Rate Limiting
|
|
|
|
**Symptom**: API returns 429 (Too Many Requests)
|
|
|
|
**Solutions**:
|
|
1. Implement exponential backoff retry logic
|
|
2. Reduce API call frequency
|
|
3. Increase cache TTL to reduce calls
|
|
4. 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**:
|
|
```txt
|
|
httpx>=0.25.0 # Async HTTP client
|
|
pydantic>=2.10.0 # Response validation (already installed)
|
|
```
|
|
|
|
**Optional**:
|
|
```txt
|
|
redis>=5.0.0 # Redis cache backend
|
|
aiohttp>=3.9.0 # Alternative HTTP client
|
|
```
|
|
|
|
## Implementation Priority
|
|
|
|
**Phase 1** (Week 7-8):
|
|
1. ✅ Create base API client with error handling
|
|
2. ✅ Implement SBA client for simple player data
|
|
3. ✅ Add basic integration tests
|
|
4. ⏳ Connect to game engine for roster loading
|
|
|
|
**Phase 2** (Week 9-10):
|
|
1. ⏳ Implement PD client with scouting data
|
|
2. ⏳ Add in-memory cache for development
|
|
3. ⏳ Add result submission endpoints
|
|
|
|
**Phase 3** (Post-MVP):
|
|
1. ⏳ Add Redis cache for production
|
|
2. ⏳ Implement advanced retry logic
|
|
3. ⏳ Add request/response logging
|
|
4. ⏳ 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.
|