# 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 ```python 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 ```python 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: ```python 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: ```python # Automatically closes client async with LeagueApiClient("pd") as client: player = await client.get_player(10633) # client.close() called automatically ``` --- ## Error Handling ### HTTP Errors ```python 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 ```python 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 ```python 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: ```python 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 ```python 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 ```python @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 ```python 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: ```python 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: ```python # 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](./testing-strategy.md) for comprehensive test plans