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>
546 lines
14 KiB
Markdown
546 lines
14 KiB
Markdown
# 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
|