Split player model architecture into dedicated documentation files for clarity and maintainability. Added Phase 1 status tracking and comprehensive player model specs covering API models, game models, mappers, and testing strategy. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
14 KiB
14 KiB
API Client Specification
Purpose: HTTP client for fetching player data from league APIs
File: backend/app/data/api_client.py
Overview
LeagueApiClient handles all HTTP communication with external league APIs:
- Fetches player data
- Fetches ratings data (PD only)
- Handles errors and retries
- Returns API models (exact API responses)
LeagueApiClient Class
Implementation
import logging
from typing import Optional
import httpx
from app.config.league_configs import get_league_config
from app.models.api_models import (
# SBA
SbaPlayerApi,
# PD
PdPlayerApi,
PdBattingRatingsResponseApi,
PdPitchingRatingsResponseApi
)
logger = logging.getLogger(f'{__name__}.LeagueApiClient')
class LeagueApiClient:
"""
HTTP client for league REST APIs
Handles all communication with external PD and SBA APIs.
Returns API models (not game models - that's the mapper's job).
"""
def __init__(self, league_id: str):
"""
Initialize API client for a league
Args:
league_id: 'sba' or 'pd'
"""
self.league_id = league_id
self.config = get_league_config(league_id)
self.base_url = self.config.get_api_base_url()
# Create HTTP client with timeout
self.client = httpx.AsyncClient(
base_url=self.base_url,
timeout=10.0,
follow_redirects=True
)
logger.info(f"LeagueApiClient initialized for {league_id} ({self.base_url})")
async def close(self):
"""Close HTTP client connection"""
await self.client.aclose()
logger.debug(f"LeagueApiClient closed for {self.league_id}")
# -------------------------------------------------------------------------
# SBA API Methods
# -------------------------------------------------------------------------
async def get_sba_player(self, player_id: int) -> SbaPlayerApi:
"""
Fetch SBA player data
Args:
player_id: SBA player ID
Returns:
SbaPlayerApi model with full nested team data
Raises:
httpx.HTTPError: If API call fails
"""
if self.league_id != "sba":
raise ValueError("This method is only for SBA league")
try:
logger.info(f"Fetching SBA player {player_id}")
response = await self.client.get(
f"/players/{player_id}",
params={"short_output": "false"}
)
response.raise_for_status()
data = response.json()
player = SbaPlayerApi(**data)
logger.info(f"Successfully fetched SBA player: {player.name}")
return player
except httpx.HTTPError as e:
logger.error(f"Failed to fetch SBA player {player_id}: {e}")
raise
# -------------------------------------------------------------------------
# PD API Methods
# -------------------------------------------------------------------------
async def get_pd_player(self, player_id: int) -> PdPlayerApi:
"""
Fetch PD player data (basic info, no ratings)
Args:
player_id: PD player ID
Returns:
PdPlayerApi model with cardset/rarity/mlbplayer nested
Raises:
httpx.HTTPError: If API call fails
"""
if self.league_id != "pd":
raise ValueError("This method is only for PD league")
try:
logger.info(f"Fetching PD player {player_id}")
response = await self.client.get(
f"/api/v2/players/{player_id}",
params={"csv": "false"}
)
response.raise_for_status()
data = response.json()
player = PdPlayerApi(**data)
logger.info(f"Successfully fetched PD player: {player.p_name}")
return player
except httpx.HTTPError as e:
logger.error(f"Failed to fetch PD player {player_id}: {e}")
raise
async def get_pd_batting_ratings(
self,
player_id: int
) -> PdBattingRatingsResponseApi:
"""
Fetch PD batting ratings (vs L and vs R)
Args:
player_id: PD player ID
Returns:
PdBattingRatingsResponseApi with 2 ratings (vs L and vs R)
Raises:
httpx.HTTPError: If API call fails
"""
if self.league_id != "pd":
raise ValueError("This method is only for PD league")
try:
logger.info(f"Fetching PD batting ratings for player {player_id}")
response = await self.client.get(
f"/api/v2/battingcardratings/player/{player_id}",
params={"short_output": "false"}
)
response.raise_for_status()
data = response.json()
ratings = PdBattingRatingsResponseApi(**data)
logger.info(f"Successfully fetched batting ratings ({ratings.count} ratings)")
return ratings
except httpx.HTTPError as e:
logger.error(f"Failed to fetch batting ratings for {player_id}: {e}")
raise
async def get_pd_pitching_ratings(
self,
player_id: int
) -> PdPitchingRatingsResponseApi:
"""
Fetch PD pitching ratings (vs L and vs R)
Args:
player_id: PD player ID
Returns:
PdPitchingRatingsResponseApi with 2 ratings (vs L and vs R)
Raises:
httpx.HTTPError: If API call fails (player may not be a pitcher)
"""
if self.league_id != "pd":
raise ValueError("This method is only for PD league")
try:
logger.info(f"Fetching PD pitching ratings for player {player_id}")
response = await self.client.get(
f"/api/v2/pitchingcardratings/player/{player_id}",
params={"short_output": "false"}
)
response.raise_for_status()
data = response.json()
ratings = PdPitchingRatingsResponseApi(**data)
logger.info(f"Successfully fetched pitching ratings ({ratings.count} ratings)")
return ratings
except httpx.HTTPError as e:
logger.error(f"Failed to fetch pitching ratings for {player_id}: {e}")
raise
# -------------------------------------------------------------------------
# Generic Methods (League-Agnostic)
# -------------------------------------------------------------------------
async def get_player(self, player_id: int):
"""
Fetch player for current league (generic method)
Args:
player_id: Player ID in current league
Returns:
SbaPlayerApi or PdPlayerApi depending on league
"""
if self.league_id == "sba":
return await self.get_sba_player(player_id)
elif self.league_id == "pd":
return await self.get_pd_player(player_id)
else:
raise ValueError(f"Unknown league: {self.league_id}")
async def get_batting_ratings(self, player_id: int):
"""
Fetch batting ratings (PD only)
Args:
player_id: PD player ID
Returns:
PdBattingRatingsResponseApi
Raises:
ValueError: If called for SBA league
"""
if self.league_id != "pd":
raise ValueError("Batting ratings only available for PD league")
return await self.get_pd_batting_ratings(player_id)
async def get_pitching_ratings(self, player_id: int):
"""
Fetch pitching ratings (PD only)
Args:
player_id: PD player ID
Returns:
PdPitchingRatingsResponseApi
Raises:
ValueError: If called for SBA league
httpx.HTTPError: If player is not a pitcher
"""
if self.league_id != "pd":
raise ValueError("Pitching ratings only available for PD league")
return await self.get_pd_pitching_ratings(player_id)
Usage Examples
Basic Usage
from app.data.api_client import LeagueApiClient
# Fetch SBA player
async with LeagueApiClient("sba") as client:
player = await client.get_player(12288)
print(f"{player.name} - {player.team.lname}")
# Fetch PD player with ratings
async with LeagueApiClient("pd") as client:
player = await client.get_player(10633)
batting = await client.get_batting_ratings(10633)
print(f"{player.p_name} - {batting.count} ratings")
Context Manager Support
Add async context manager protocol:
class LeagueApiClient:
# ... existing code ...
async def __aenter__(self):
"""Async context manager entry"""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit"""
await self.close()
Usage:
# Automatically closes client
async with LeagueApiClient("pd") as client:
player = await client.get_player(10633)
# client.close() called automatically
Error Handling
HTTP Errors
from httpx import HTTPStatusError
try:
player = await client.get_player(999999)
except HTTPStatusError as e:
if e.response.status_code == 404:
# Player not found
print(f"Player {player_id} does not exist")
elif e.response.status_code == 500:
# Server error
print("API server error, try again later")
else:
# Other error
print(f"Unexpected error: {e}")
Network Errors
from httpx import RequestError
try:
player = await client.get_player(12288)
except RequestError as e:
# Network error (timeout, connection refused, etc.)
print(f"Network error: {e}")
Validation Errors
from pydantic import ValidationError
try:
player = await client.get_player(12288)
except ValidationError as e:
# API response doesn't match expected model
# This indicates API contract changed
logger.error(f"API response validation failed: {e}")
raise
Retry Strategy
For production, add retry logic for transient errors:
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
import httpx
class LeagueApiClient:
# ... existing code ...
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type(httpx.TimeoutException),
reraise=True
)
async def get_player(self, player_id: int):
"""Fetch player with automatic retry on timeout"""
# ... existing implementation ...
Testing Strategy
Unit Tests with Mocking
import pytest
from unittest.mock import AsyncMock, patch
from app.data.api_client import LeagueApiClient
@pytest.mark.asyncio
async def test_get_sba_player_success(mock_sba_response):
"""Test successful SBA player fetch"""
# Mock httpx client
with patch('httpx.AsyncClient') as mock_client:
mock_response = AsyncMock()
mock_response.json.return_value = mock_sba_response
mock_response.raise_for_status = AsyncMock()
mock_client.return_value.get = AsyncMock(return_value=mock_response)
client = LeagueApiClient("sba")
player = await client.get_player(12288)
assert player.name == "Ronald Acuna Jr"
assert player.team.lname == "West Virginia Black Bears"
@pytest.mark.asyncio
async def test_get_pd_player_404():
"""Test 404 error handling"""
with patch('httpx.AsyncClient') as mock_client:
mock_response = AsyncMock()
mock_response.status_code = 404
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Not Found", request=..., response=mock_response
)
mock_client.return_value.get = AsyncMock(return_value=mock_response)
client = LeagueApiClient("pd")
with pytest.raises(httpx.HTTPStatusError):
await client.get_player(999999)
Integration Tests with Real API
@pytest.mark.integration
@pytest.mark.asyncio
async def test_real_sba_api():
"""Test with real SBA API (requires network)"""
async with LeagueApiClient("sba") as client:
player = await client.get_player(12288)
# Verify structure
assert player.id == 12288
assert isinstance(player.name, str)
assert isinstance(player.team, SbaTeamApi)
assert len(player.team.lname) > 0
@pytest.mark.integration
@pytest.mark.asyncio
async def test_real_pd_api_with_ratings():
"""Test with real PD API (requires network)"""
async with LeagueApiClient("pd") as client:
player = await client.get_player(10633)
batting = await client.get_batting_ratings(10633)
# Verify structure
assert player.player_id == 10633
assert batting.count == 2 # vs L and vs R
assert len(batting.ratings) == 2
Performance Considerations
Request Timing
import time
class LeagueApiClient:
async def get_player(self, player_id: int):
start = time.time()
# ... fetch logic ...
elapsed = time.time() - start
logger.info(f"Player fetch took {elapsed:.2f}s")
return player
Connection Pooling
httpx automatically handles connection pooling:
- Reuses connections for multiple requests
- Default pool size: 10 connections
- Customize if needed:
limits = httpx.Limits(max_keepalive_connections=20, max_connections=50)
self.client = httpx.AsyncClient(
base_url=self.base_url,
timeout=10.0,
limits=limits
)
Configuration
Add API base URLs to league configs:
# app/config/league_configs.py
class SbaConfig(BaseGameConfig):
league_id: str = "sba"
def get_api_base_url(self) -> str:
return "https://api.sba.manticorum.com/"
class PdConfig(BaseGameConfig):
league_id: str = "pd"
def get_api_base_url(self) -> str:
return "https://pd.manticorum.com/"
Next: See testing-strategy.md for comprehensive test plans