Coverage for src/ecnl/adapters/outbound/athleteone_adapter.py: 75%
71 statements
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-28 08:19 +0000
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-28 08:19 +0000
1"""Outbound adapter — translates domain calls into AthleteOne API HTTP requests.
3This is the only place in the codebase that knows about:
4- The AthleteOne API host and URL structure
5- The ``{"result": "success", "data": ...}`` response envelope
6- How to translate non-2xx responses and error envelopes into domain errors
8All endpoints are public read-only JSON; no authentication header is required
9(verified — the site's own HTTP interceptor attaches none for public calls).
10The wire-format -> domain-model mapping lives in athleteone_parsers.py.
11"""
13import asyncio
14import logging
15from typing import Any
17import httpx
19from ...domain.exceptions import ECNLNotFoundError, UpstreamAPIError
20from ...domain.models import Club, Event, EventOverview, Match, Standings, Team
21from .athleteone_parsers import (
22 parse_club,
23 parse_division_with_flights,
24 parse_event,
25 parse_match,
26 parse_standing_row,
27 parse_team,
28)
30logger = logging.getLogger(__name__)
32_DEFAULT_BASE_URL = "https://api.athleteone.com"
33_API = "/api"
36class AthleteOneAdapter:
37 """Calls the AthleteOne (Total Global Sports) public API for ECNL/ECRL data.
39 The underlying httpx.AsyncClient is created once at construction and reused
40 for all requests so the TCP connection pool is retained across calls —
41 avoiding a fresh TCP+TLS handshake on every API call.
42 """
44 def __init__(self, base_url: str = _DEFAULT_BASE_URL, client: httpx.AsyncClient | None = None) -> None:
45 """Initialize the adapter with an optional HTTP client.
47 Args:
48 base_url: Base URL of the AthleteOne API. Defaults to
49 https://api.athleteone.com.
50 client: An httpx.AsyncClient to reuse across all requests. Inject a
51 pre-configured mock in tests.
52 """
53 self._client = client or httpx.AsyncClient(base_url=base_url, timeout=30.0)
55 async def _get(self, path: str) -> Any:
56 """Execute a GET request and return the unwrapped ``data`` payload.
58 Args:
59 path: URL path relative to base_url (including the ``/api`` prefix).
61 Returns:
62 The value of the response's ``data`` field.
64 Raises:
65 ECNLNotFoundError: If the server returns HTTP 404.
66 UpstreamAPIError: For any other non-2xx status or an error envelope.
67 """
68 logger.debug("GET %s", path)
69 try:
70 response = await self._client.get(path)
71 response.raise_for_status()
72 except httpx.HTTPStatusError as exc:
73 if exc.response.status_code == 404:
74 raise ECNLNotFoundError(f"Not found: {path}") from exc
75 raise UpstreamAPIError(f"Upstream error {exc.response.status_code}: {path}") from exc
76 except httpx.HTTPError as exc:
77 raise UpstreamAPIError(f"Request failed: {path}: {exc}") from exc
78 return _unwrap(response.json(), path)
80 async def get_event(self, event_id: int) -> Event:
81 """Return event metadata by event ID."""
82 data = await self._get(f"{_API}/team/get-event-details-by-eventid/{event_id}")
83 if not data:
84 raise ECNLNotFoundError(f"Event not found: {event_id}")
85 return parse_event(data)
87 async def get_event_overview(self, event_id: int) -> EventOverview:
88 """Return the division/flight navigation tree plus event metadata.
90 Fetches event details and the schedule-or-standings index concurrently
91 and merges them into a single overview.
92 """
93 event, tree = await asyncio.gather(
94 self.get_event(event_id),
95 self._get(f"{_API}/Event/get-event-schedule-or-standings/{event_id}"),
96 )
97 divisions = [parse_division_with_flights(d, "girls") for d in (tree or {}).get("girlsDivAndFlightList", [])] + [
98 parse_division_with_flights(d, "boys") for d in (tree or {}).get("boysDivAndFlightList", [])
99 ]
100 return EventOverview(event=event, divisions=divisions)
102 async def get_standings(self, event_id: int, division_id: int, flight_id: int) -> Standings:
103 """Return the standings table for a flight.
105 The feed groups standings into one or more flight groups; rows from all
106 groups are flattened in source order.
107 """
108 data = await self._get(f"{_API}/Event/get-standings-by-div-and-flight/{division_id}/{flight_id}/{event_id}")
109 rows = [parse_standing_row(row) for group in (data or []) for row in group.get("teamStandings", [])]
110 return Standings(event_id=event_id, division_id=division_id, flight_id=flight_id, rows=rows)
112 async def get_schedule(self, event_id: int, flight_id: int) -> list[Match]:
113 """Return all matches for a flight (complexID segment is fixed at 0)."""
114 data = await self._get(f"{_API}/Event/get-schedules-by-flight/{event_id}/{flight_id}/0")
115 return [parse_match(m) for m in (data or [])]
117 async def get_team_schedule(self, event_id: int, team_id: int) -> list[Match]:
118 """Return all matches for a single team within an event."""
119 data = await self._get(f"{_API}/Event/get-game-list-by-eventID-and-teamID/{event_id}/{team_id}")
120 return [parse_match(m) for m in (data or [])]
122 async def get_flight_teams(self, flight_id: int) -> list[Team]:
123 """Return the teams competing in a flight."""
124 data = await self._get(f"{_API}/Event/get-team-list-by-flight/{flight_id}")
125 return [parse_team(t) for t in (data or [])]
127 async def get_event_teams(self, event_id: int) -> list[Team]:
128 """Return every team registered for an event."""
129 data = await self._get(f"{_API}/Event/get-team-list/{event_id}")
130 return [parse_team(t) for t in (data or [])]
132 async def get_clubs(self, event_id: int) -> list[Club]:
133 """Return the clubs participating in an event."""
134 data = await self._get(f"{_API}/Event/get-org-club-list-by-event/{event_id}")
135 return [parse_club(c) for c in (data or [])]
137 async def get_match(self, match_token: str) -> dict:
138 """Return raw match-detail data for a match token."""
139 data = await self._get(f"{_API}/Event/get-match-detail-by-token/{match_token}")
140 return data or {}
142 async def get_brackets(self, event_id: int, flight_id: int) -> dict:
143 """Return raw playoff-bracket data for a flight."""
144 data = await self._get(f"{_API}/Event/get-flight-brackets-by-flight/{event_id}/{flight_id}")
145 return data or {}
147 async def get_org_club_events(self, org_id: int) -> list[int]:
148 """Return distinct active event IDs across an org's clubs.
150 Each club entry carries an ``eventID`` (0 when the club has no active
151 event); the distinct non-zero values are the org's current events.
152 """
153 data = await self._get(f"{_API}/Event/get-org-club-list-by-orgID/{org_id}")
154 event_ids = {eid for club in (data or []) if (eid := club.get("eventID"))}
155 return sorted(event_ids)
158def _unwrap(payload: Any, path: str) -> Any:
159 """Return the ``data`` field of a TGS envelope, raising on error envelopes.
161 The API returns ``{"result": "success", "data": ...}`` on success and a
162 ``{"result": "error", ...}`` (sometimes with HTTP 200) on failure.
164 Raises:
165 UpstreamAPIError: If the envelope reports an error or is malformed.
166 """
167 if not isinstance(payload, dict):
168 raise UpstreamAPIError(f"Unexpected response shape from {path}")
169 result = str(payload.get("result", payload.get("Result", ""))).lower()
170 if result and result != "success":
171 message = payload.get("message") or payload.get("Message") or "error"
172 raise UpstreamAPIError(f"Upstream error from {path}: {message}")
173 return payload.get("data")