strat-gameplay-webapp/.claude/implementation/player-model-specs/api-client.md
Cal Corum f9aa653c37 CLAUDE: Reorganize Week 6 documentation and separate player model specifications
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>
2025-10-25 23:48:57 -05:00

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